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