jedmund-svelte/src/routes/admin/albums/+page.svelte
Justin Edmund 1c98aff722 feat(admin): complete Task 7 Phase 2 - styling rollout
Extended the theming system to additional pages and components, continuing
to eliminate hardcoded colors and duplicated styles.

**Pages Refactored:**
- /admin/media - Integrated EmptyState with action button (~20 lines removed)
- /admin/albums - Integrated EmptyState & ErrorMessage (~25 lines removed)
  - Fixed hardcoded spacing in loading spinner (32px → calc($unit * 4))
  - Replaced hardcoded error color (#d33 → $error-text)

**Components Updated with Semantic Colors:**
- Button.svelte - Replaced 3 instances of #dc2626 → $error-text
- AlbumSelector.svelte - Error message uses $error-bg, $error-text
- AlbumSelectorModal.svelte - Error message uses $error-bg, $error-text, $error-border
  - Fixed border width (1px → $unit-1px)

**Phase 2 Results:**
- Total lines removed: ~105 across 4 pages (Phase 1: 60, Phase 2: 45)
- EmptyState component now used in 4 pages
- ErrorMessage component now used in 3 pages
- Standardized error colors across 3 modal components

**Theming Benefits:**
- Error styling centralized (change $error-bg once, updates everywhere)
- Empty states guaranteed visual consistency
- Dark mode ready (just remap CSS variables in themes.scss)

**Remaining work (future):**
- ~30 files with remaining hardcoded colors
- ~15 files with spacing that could use $unit system
- Opportunity for additional semantic variables as needed

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 22:02:33 -07:00

380 lines
9.5 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation'
import { onMount } from 'svelte'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import AdminHeader from '$lib/components/admin/AdminHeader.svelte'
import AdminFilters from '$lib/components/admin/AdminFilters.svelte'
import AlbumListItem from '$lib/components/admin/AlbumListItem.svelte'
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
import EmptyState from '$lib/components/admin/EmptyState.svelte'
import ErrorMessage from '$lib/components/admin/ErrorMessage.svelte'
import Button from '$lib/components/admin/Button.svelte'
import Select from '$lib/components/admin/Select.svelte'
interface Photo {
id: number
url: string
thumbnailUrl: string | null
caption: string | null
}
interface Album {
id: number
slug: string
title: string
description: string | null
date: string | null
location: string | null
coverPhotoId: number | null
status: string
showInUniverse: boolean
publishedAt: string | null
createdAt: string
updatedAt: string
photos: Photo[]
content?: any
_count: {
media: number
}
}
// State
let albums = $state<Album[]>([])
let filteredAlbums = $state<Album[]>([])
let isLoading = $state(true)
let error = $state('')
let total = $state(0)
let albumTypeCounts = $state<Record<string, number>>({})
let showDeleteModal = $state(false)
let albumToDelete = $state<Album | null>(null)
let activeDropdown = $state<number | null>(null)
// Filter state
let statusFilter = $state<string>('all')
let sortBy = $state<string>('newest')
// Filter options
const filterOptions = [
{ value: 'all', label: 'All albums' },
{ value: 'published', label: 'Published' },
{ value: 'draft', label: 'Drafts' }
]
const sortOptions = [
{ value: 'newest', label: 'Newest first' },
{ value: 'oldest', label: 'Oldest first' },
{ value: 'title-asc', label: 'Title (A-Z)' },
{ value: 'title-desc', label: 'Title (Z-A)' },
{ value: 'date-desc', label: 'Date (newest)' },
{ value: 'date-asc', label: 'Date (oldest)' },
{ value: 'status-published', label: 'Published first' },
{ value: 'status-draft', label: 'Draft first' }
]
onMount(async () => {
await loadAlbums()
// Close dropdown when clicking outside
document.addEventListener('click', handleOutsideClick)
return () => document.removeEventListener('click', handleOutsideClick)
})
function handleOutsideClick(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.dropdown-container')) {
activeDropdown = null
}
}
async function loadAlbums() {
try {
const response = await fetch('/api/albums', {
credentials: 'same-origin'
})
if (!response.ok) {
if (response.status === 401) {
goto('/admin/login')
return
}
throw new Error('Failed to load albums')
}
const data = await response.json()
albums = data.albums || []
total = data.pagination?.total || albums.length
// Calculate album status counts
const counts: Record<string, number> = {
all: albums.length,
published: albums.filter((a) => a.status === 'published').length,
draft: albums.filter((a) => a.status === 'draft').length
}
albumTypeCounts = counts
// Apply initial filter and sort
applyFilterAndSort()
} catch (err) {
error = 'Failed to load albums'
console.error(err)
} finally {
isLoading = false
}
}
function applyFilterAndSort() {
let filtered = [...albums]
// Apply filter
if (statusFilter === 'published') {
filtered = filtered.filter((album) => album.status === 'published')
} else if (statusFilter === 'draft') {
filtered = filtered.filter((album) => album.status === 'draft')
}
// Apply sorting
switch (sortBy) {
case 'oldest':
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
break
case 'title-asc':
filtered.sort((a, b) => a.title.localeCompare(b.title))
break
case 'title-desc':
filtered.sort((a, b) => b.title.localeCompare(a.title))
break
case 'date-desc':
filtered.sort((a, b) => {
if (!a.date && !b.date) return 0
if (!a.date) return 1
if (!b.date) return -1
return new Date(b.date).getTime() - new Date(a.date).getTime()
})
break
case 'date-asc':
filtered.sort((a, b) => {
if (!a.date && !b.date) return 0
if (!a.date) return 1
if (!b.date) return -1
return new Date(a.date).getTime() - new Date(b.date).getTime()
})
break
case 'status-published':
filtered.sort((a, b) => {
if (a.status === b.status) return 0
return a.status === 'published' ? -1 : 1
})
break
case 'status-draft':
filtered.sort((a, b) => {
if (a.status === b.status) return 0
return a.status === 'draft' ? -1 : 1
})
break
case 'newest':
default:
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
break
}
filteredAlbums = filtered
}
function handleToggleDropdown(event: CustomEvent<{ albumId: number; event: MouseEvent }>) {
event.detail.event.stopPropagation()
activeDropdown = activeDropdown === event.detail.albumId ? null : event.detail.albumId
}
function handleEdit(event: CustomEvent<{ album: Album; event: MouseEvent }>) {
event.detail.event.stopPropagation()
goto(`/admin/albums/${event.detail.album.id}/edit`)
}
async function handleTogglePublish(event: CustomEvent<{ album: Album; event: MouseEvent }>) {
event.detail.event.stopPropagation()
activeDropdown = null
const album = event.detail.album
try {
const newStatus = album.status === 'published' ? 'draft' : 'published'
const response = await fetch(`/api/albums/${album.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: newStatus }),
credentials: 'same-origin'
})
if (response.ok) {
await loadAlbums()
} else if (response.status === 401) {
goto('/admin/login')
}
} catch (err) {
console.error('Failed to update album status:', err)
}
}
function handleDelete(event: CustomEvent<{ album: Album; event: MouseEvent }>) {
event.detail.event.stopPropagation()
activeDropdown = null
albumToDelete = event.detail.album
showDeleteModal = true
}
async function confirmDelete() {
if (!albumToDelete) return
try {
const response = await fetch(`/api/albums/${albumToDelete.id}`, {
method: 'DELETE',
credentials: 'same-origin'
})
if (response.ok) {
await loadAlbums()
} else if (response.status === 401) {
goto('/admin/login')
} else {
const errorData = await response.json()
error = errorData.error || 'Failed to delete album'
}
} catch (err) {
console.error('Failed to delete album:', err)
error = 'Failed to delete album. Please try again.'
} finally {
showDeleteModal = false
albumToDelete = null
}
}
function cancelDelete() {
showDeleteModal = false
albumToDelete = null
}
function handleFilterChange() {
applyFilterAndSort()
}
function handleSortChange() {
applyFilterAndSort()
}
function handleNewAlbum() {
goto('/admin/albums/new')
}
</script>
<svelte:head>
<title>Albums - Admin @jedmund</title>
</svelte:head>
<AdminPage>
<AdminHeader title="Albums" slot="header">
{#snippet actions()}
<Button variant="primary" buttonSize="large" onclick={handleNewAlbum}>New Album</Button>
{/snippet}
</AdminHeader>
{#if error}
<ErrorMessage message={error} />
{:else}
<!-- Filters -->
<AdminFilters>
{#snippet left()}
<Select
bind:value={statusFilter}
options={filterOptions}
size="small"
variant="minimal"
onchange={handleFilterChange}
/>
{/snippet}
{#snippet right()}
<Select
bind:value={sortBy}
options={sortOptions}
size="small"
variant="minimal"
onchange={handleSortChange}
/>
{/snippet}
</AdminFilters>
<!-- Albums List -->
{#if isLoading}
<div class="loading">
<div class="spinner"></div>
<p>Loading albums...</p>
</div>
{:else if filteredAlbums.length === 0}
<EmptyState
title="No albums found"
message={statusFilter === 'all'
? 'Create your first album to get started!'
: 'No albums found matching the current filters. Try adjusting your filters or create a new album.'}
/>
{:else}
<div class="albums-list">
{#each filteredAlbums as album}
<AlbumListItem
{album}
isDropdownActive={activeDropdown === album.id}
on:toggleDropdown={handleToggleDropdown}
on:edit={handleEdit}
on:togglePublish={handleTogglePublish}
on:delete={handleDelete}
/>
{/each}
</div>
{/if}
{/if}
</AdminPage>
<DeleteConfirmationModal
bind:isOpen={showDeleteModal}
title="Delete album?"
message={albumToDelete
? `Are you sure you want to delete "${albumToDelete.title}"? The album will be deleted but all photos will remain in your media library. This action cannot be undone.`
: ''}
onConfirm={confirmDelete}
onCancel={cancelDelete}
/>
<style lang="scss">
@import '$styles/variables.scss';
.loading {
padding: $unit-8x;
text-align: center;
color: $gray-40;
.spinner {
width: calc($unit * 4); // 32px
height: calc($unit * 4); // 32px
border: calc($unit / 2 + $unit-1px) solid $gray-80; // 3px
border-top-color: $gray-40;
border-radius: 50%;
margin: 0 auto $unit-2x;
animation: spin 0.8s linear infinite;
}
p {
margin: 0;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.albums-list {
display: flex;
flex-direction: column;
}
</style>