refactor(admin): migrate media list to use URL params and server loads

Refactored media list to follow SvelteKit data loading patterns:
- Removed client-side fetch() calls and manual state management
- Filter/sort/search state now driven by URL search params
- Page navigation triggers server-side reloads via goto()
- Mutations use invalidate('admin:media') to reload data
- Replaced error state with toast notifications for better UX
- Removed redundant loading state (handled by SvelteKit)

This completes Task 2 - all admin lists now use server-side data loading with proper session authentication.

🤖 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 18:30:06 -07:00
parent 39e82146d9
commit eebaf86b64

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { goto, invalidate } from '$app/navigation'
import { page } from '$app/stores'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import AdminHeader from '$lib/components/admin/AdminHeader.svelte'
import AdminFilters from '$lib/components/admin/AdminFilters.svelte'
@ -13,25 +14,24 @@
import AlbumSelectorModal from '$lib/components/admin/AlbumSelectorModal.svelte'
import ChevronDown from '$icons/chevron-down.svg?component'
import PlayIcon from '$icons/play.svg?component'
import { toast } from '$lib/stores/toast'
import type { Media } from '@prisma/client'
import type { PageData } from './$types'
const { data } = $props<{ data: PageData }>()
let media = $state<Media[]>(data.items ?? [])
let isLoading = $state(false)
let error = $state('')
let currentPage = $state(data.pagination?.page ?? 1)
let totalPages = $state(data.pagination?.totalPages ?? 1)
let total = $state(data.pagination?.total ?? (data.items?.length ?? 0))
// Only using grid view
const media = $derived(data.items ?? [])
const currentPage = $derived(data.pagination?.page ?? 1)
const totalPages = $derived(data.pagination?.totalPages ?? 1)
const total = $derived(data.pagination?.total ?? 0)
// Filter states
let filterType = $state<string>('all')
let publishedFilter = $state<string>('all')
let searchQuery = $state('')
// Read filter states from URL
const filterType = $derived($page.url.searchParams.get('mimeType') ?? 'all')
const publishedFilter = $derived($page.url.searchParams.get('publishedFilter') ?? 'all')
const sortBy = $derived($page.url.searchParams.get('sort') ?? 'newest')
let searchQuery = $state($page.url.searchParams.get('search') ?? '')
let searchTimeout: ReturnType<typeof setTimeout>
let sortBy = $state<string>('newest')
// Filter options
const typeFilterOptions = [
@ -77,64 +77,45 @@
if (searchQuery !== undefined) {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
handleSearch()
updateURL({ search: searchQuery || undefined })
}, 300)
}
})
async function loadMedia(page = 1) {
try {
isLoading = true
let url = `/api/media?page=${page}&limit=24`
if (filterType !== 'all') {
url += `&mimeType=${filterType}`
}
if (publishedFilter !== 'all') {
url += `&publishedFilter=${publishedFilter}`
}
if (searchQuery) {
url += `&search=${encodeURIComponent(searchQuery)}`
}
if (sortBy) {
url += `&sort=${sortBy}`
}
function updateURL(params: Record<string, string | undefined>) {
const url = new URL($page.url)
const response = await fetch(url, {
credentials: 'same-origin'
})
// Update or remove params
Object.entries(params).forEach(([key, value]) => {
if (value && value !== 'all') {
url.searchParams.set(key, value)
} else {
url.searchParams.delete(key)
}
})
if (!response.ok) throw new Error('Failed to load media')
const data = await response.json()
media = data.media
currentPage = data.pagination.page
totalPages = data.pagination.totalPages
total = data.pagination.total
} catch (err) {
error = 'Failed to load media'
console.error(err)
} finally {
isLoading = false
// Reset to page 1 if filters changed (not page navigation)
if (!params.page) {
url.searchParams.delete('page')
}
goto(url.toString(), { replaceState: false, keepFocus: true })
}
function handlePageChange(page: number) {
loadMedia(page)
updateURL({ page: String(page) })
}
function handleFilterChange() {
currentPage = 1
loadMedia(1)
function handleTypeFilterChange(value: string) {
updateURL({ mimeType: value })
}
function handleSearch() {
currentPage = 1
loadMedia(1)
function handlePublishedFilterChange(value: string) {
updateURL({ publishedFilter: value })
}
function handleSortChange() {
currentPage = 1
loadMedia(1)
function handleSortChange(value: string) {
updateURL({ sort: value })
}
function formatFileSize(bytes: number): string {
@ -167,17 +148,14 @@
isDetailsModalOpen = false
}
function handleMediaUpdate(updatedMedia: Media) {
// Update the media item in the list
const index = media.findIndex((m) => m.id === updatedMedia.id)
if (index !== -1) {
media[index] = updatedMedia
}
async function handleMediaUpdate(updatedMedia: Media) {
// Invalidate to reload from server
await invalidate('admin:media')
}
function handleUploadComplete() {
async function handleUploadComplete() {
// Reload media list after successful upload
loadMedia(currentPage)
await invalidate('admin:media')
}
function openUploadModal() {
@ -263,18 +241,15 @@
}
await response.json()
// Remove deleted media from the list
media = media.filter((m) => !selectedMediaIds.has(m.id))
// Clear selection and exit multiselect mode
selectedMediaIds.clear()
selectedMediaIds = new Set()
isMultiSelectMode = false
// Reload to get updated total count
await loadMedia(currentPage)
// Reload to get updated data
await invalidate('admin:media')
} catch (err) {
error = 'Failed to delete media files. Please try again.'
toast.error('Failed to delete media files. Please try again.')
console.error('Failed to delete media:', err)
} finally {
isDeleting = false
@ -304,17 +279,15 @@
await Promise.all(promises)
// Update local media items
media = media.map((item) =>
selectedMediaIds.has(item.id) ? { ...item, isPhotography: true } : item
)
// Clear selection
selectedMediaIds.clear()
selectedMediaIds = new Set()
isMultiSelectMode = false
// Reload to get updated data
await invalidate('admin:media')
} catch (err) {
error = 'Failed to mark items as photography. Please try again.'
toast.error('Failed to mark items as photography. Please try again.')
console.error('Failed to mark as photography:', err)
}
}
@ -342,17 +315,15 @@
await Promise.all(promises)
// Update local media items
media = media.map((item) =>
selectedMediaIds.has(item.id) ? { ...item, isPhotography: false } : item
)
// Clear selection
selectedMediaIds.clear()
selectedMediaIds = new Set()
isMultiSelectMode = false
// Reload to get updated data
await invalidate('admin:media')
} catch (err) {
error = 'Failed to remove photography status. Please try again.'
toast.error('Failed to remove photography status. Please try again.')
console.error('Failed to unmark photography:', err)
}
}
@ -388,39 +359,35 @@
{/snippet}
</AdminHeader>
{#if error}
<div class="error">{error}</div>
{:else}
<!-- Filters -->
<!-- Filters -->
<AdminFilters>
{#snippet left()}
<Select
bind:value={filterType}
value={filterType}
options={typeFilterOptions}
size="small"
variant="minimal"
onchange={handleFilterChange}
onchange={(e) => handleTypeFilterChange((e.target as HTMLSelectElement).value)}
/>
<Select
bind:value={publishedFilter}
value={publishedFilter}
options={publishedFilterOptions}
size="small"
variant="minimal"
onchange={handleFilterChange}
onchange={(e) => handlePublishedFilterChange((e.target as HTMLSelectElement).value)}
/>
{/snippet}
{#snippet right()}
<Select
bind:value={sortBy}
value={sortBy}
options={sortOptions}
size="small"
variant="minimal"
onchange={handleSortChange}
onchange={(e) => handleSortChange((e.target as HTMLSelectElement).value)}
/>
<Input
type="search"
bind:value={searchQuery}
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Search files..."
buttonSize="small"
fullWidth={false}
@ -500,9 +467,7 @@
</div>
{/if}
{#if isLoading}
<div class="loading">Loading media...</div>
{:else if media.length === 0}
{#if media.length === 0}
<div class="empty-state">
<p>No media files found.</p>
<Button variant="primary" onclick={openUploadModal}>Upload your first file</Button>
@ -601,7 +566,6 @@
</button>
</div>
{/if}
{/if}
</AdminPage>
<!-- Media Details Modal -->