refactor(admin): load projects list on server

This commit is contained in:
Justin Edmund 2025-10-07 05:30:34 -07:00
parent 22e53a7d30
commit dbcd7a9e1b
4 changed files with 289 additions and 255 deletions

View file

@ -3,32 +3,18 @@
import { createEventDispatcher, onMount } from 'svelte'
import AdminByline from './AdminByline.svelte'
interface Project {
id: number
title: string
subtitle: string | null
year: number
client: string | null
status: string
projectType: string
logoUrl: string | null
backgroundColor: string | null
highlightColor: string | null
publishedAt: string | null
createdAt: string
updatedAt: string
}
import type { AdminProject } from '$lib/types/admin'
interface Props {
project: Project
project: AdminProject
}
let { project }: Props = $props()
const dispatch = createEventDispatcher<{
edit: { project: Project }
togglePublish: { project: Project }
delete: { project: Project }
edit: { project: AdminProject }
togglePublish: { project: AdminProject }
delete: { project: AdminProject }
}>()
let isDropdownOpen = $state(false)
@ -114,7 +100,12 @@
</div>
<div class="dropdown-container">
<button class="action-button" onclick={handleToggleDropdown} aria-label="Project actions">
<button
class="action-button"
type="button"
onclick={handleToggleDropdown}
aria-label="Project actions"
>
<svg
width="20"
height="20"
@ -130,12 +121,16 @@
{#if isDropdownOpen}
<div class="dropdown-menu">
<button class="dropdown-item" onclick={handleEdit}>Edit project</button>
<button class="dropdown-item" onclick={handleTogglePublish}>
<button class="dropdown-item" type="button" onclick={handleEdit}>
Edit project
</button>
<button class="dropdown-item" type="button" onclick={handleTogglePublish}>
{project.status === 'published' ? 'Unpublish' : 'Publish'} project
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item danger" onclick={handleDelete}>Delete project</button>
<button class="dropdown-item danger" type="button" onclick={handleDelete}>
Delete project
</button>
</div>
{/if}
</div>

15
src/lib/types/admin.ts Normal file
View file

@ -0,0 +1,15 @@
export interface AdminProject {
id: number
title: string
subtitle: string | null
year: number
client: string | null
status: string
projectType: string
logoUrl: string | null
backgroundColor: string | null
highlightColor: string | null
publishedAt: string | null
createdAt: string
updatedAt: string
}

View file

@ -0,0 +1,85 @@
import { fail } from '@sveltejs/kit'
import type { Actions, PageServerLoad } from './$types'
import { adminFetch, adminFetchJson } from '$lib/server/admin/authenticated-fetch'
import type { AdminProject } from '$lib/types/admin'
interface ProjectsResponse {
projects: AdminProject[]
}
function toStatusCounts(projects: AdminProject[]) {
return projects.reduce(
(counts, project) => {
counts.all += 1
counts[project.status as 'draft' | 'published'] += 1
return counts
},
{ all: 0, published: 0, draft: 0 }
)
}
function toTypeCounts(projects: AdminProject[]) {
return projects.reduce(
(counts, project) => {
counts.all += 1
if (project.projectType === 'work') counts.work += 1
if (project.projectType === 'labs') counts.labs += 1
return counts
},
{ all: 0, work: 0, labs: 0 }
)
}
export const load = (async (event) => {
event.depends('admin:projects')
const { projects } = await adminFetchJson<ProjectsResponse>(event, '/api/projects')
return {
items: projects,
filters: {
statusCounts: toStatusCounts(projects),
typeCounts: toTypeCounts(projects)
}
}
}) satisfies PageServerLoad
export const actions = {
toggleStatus: async (event) => {
const formData = await event.request.formData()
const id = Number(formData.get('id'))
const status = formData.get('status')
const updatedAt = formData.get('updatedAt')
if (!Number.isFinite(id) || typeof status !== 'string' || typeof updatedAt !== 'string') {
return fail(400, { message: 'Invalid toggle request' })
}
await adminFetch(event, `/api/projects/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
status,
updatedAt
})
})
return { success: true }
},
delete: async (event) => {
const formData = await event.request.formData()
const id = Number(formData.get('id'))
if (!Number.isFinite(id)) {
return fail(400, { message: 'Invalid project id' })
}
await adminFetch(event, `/api/projects/${id}`, {
method: 'DELETE'
})
return { success: true }
}
} satisfies Actions

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import { api } from '$lib/admin/api'
import { goto } from '$app/navigation'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import AdminHeader from '$lib/components/admin/AdminHeader.svelte'
import AdminFilters from '$lib/components/admin/AdminFilters.svelte'
@ -9,37 +8,29 @@ import { api } from '$lib/admin/api'
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 type { PageData } from './$types'
import type { AdminProject } from '$lib/types/admin'
interface Project {
id: number
title: string
subtitle: string | null
year: number
client: string | null
status: string
projectType: string
logoUrl: string | null
backgroundColor: string | null
highlightColor: string | null
publishedAt: string | null
createdAt: string
updatedAt: string
}
const { data, form } = $props<{ data: PageData; form?: { message?: string } }>()
let projects = $state<Project[]>([])
let filteredProjects = $state<Project[]>([])
let isLoading = $state(true)
let error = $state('')
let showDeleteModal = $state(false)
let projectToDelete = $state<Project | null>(null)
let statusCounts = $state<Record<string, number>>({})
let projectToDelete = $state<AdminProject | null>(null)
// Filter state
let selectedTypeFilter = $state<string>('all')
let selectedStatusFilter = $state<string>('all')
let sortBy = $state<string>('newest')
// Create filter options
const actionError = $derived(form?.message ?? '')
const projects = $derived(data.items ?? [])
let toggleForm: HTMLFormElement | null = null
let toggleIdField: HTMLInputElement | null = null
let toggleStatusField: HTMLInputElement | null = null
let toggleUpdatedAtField: HTMLInputElement | null = null
let deleteForm: HTMLFormElement | null = null
let deleteIdField: HTMLInputElement | null = null
const typeFilterOptions = $derived([
{ value: 'all', label: 'All projects' },
{ value: 'work', label: 'Work' },
@ -63,9 +54,55 @@ import { api } from '$lib/admin/api'
{ value: 'status-draft', label: 'Draft first' }
]
onMount(async () => {
await loadProjects()
// Handle clicks outside dropdowns
const filteredProjects = $derived(() => {
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
}
return next
})
onMount(() => {
document.addEventListener('click', handleOutsideClick)
return () => document.removeEventListener('click', handleOutsideClick)
})
@ -73,268 +110,170 @@ import { api } from '$lib/admin/api'
function handleOutsideClick(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.dropdown-container')) {
// Close any open dropdowns by telling all ProjectListItems
document.dispatchEvent(new CustomEvent('closeDropdowns'))
}
}
async function loadProjects() {
try {
const data = await api.get('/api/projects')
projects = data.projects
// Calculate status counts
const counts: Record<string, number> = {
all: projects.length,
published: projects.filter((p) => p.status === 'published').length,
draft: projects.filter((p) => p.status === 'draft').length
}
statusCounts = counts
// Apply initial filter and sort
applyFilterAndSort()
} catch (err) {
error = 'Failed to load projects'
console.error(err)
} finally {
isLoading = false
}
}
function handleEdit(event: CustomEvent<{ project: Project }>) {
function handleEdit(event: CustomEvent<{ project: AdminProject }>) {
goto(`/admin/projects/${event.detail.project.id}/edit`)
}
async function handleTogglePublish(event: CustomEvent<{ project: Project }>) {
function handleTogglePublish(event: CustomEvent<{ project: AdminProject }>) {
const project = event.detail.project
try {
const newStatus = project.status === 'published' ? 'draft' : 'published'
await api.patch(`/api/projects/${project.id}`, { status: newStatus, updatedAt: project.updatedAt })
await loadProjects()
} catch (err) {
console.error('Failed to update project status:', err)
if (!toggleForm || !toggleIdField || !toggleStatusField || !toggleUpdatedAtField) {
return
}
toggleIdField.value = String(project.id)
toggleStatusField.value = project.status === 'published' ? 'draft' : 'published'
toggleUpdatedAtField.value = project.updatedAt
toggleForm.requestSubmit()
}
function handleDelete(event: CustomEvent<{ project: Project }>) {
function handleDelete(event: CustomEvent<{ project: AdminProject }>) {
projectToDelete = event.detail.project
showDeleteModal = true
}
async function confirmDelete() {
if (!projectToDelete) return
function confirmDelete() {
if (!projectToDelete || !deleteForm || !deleteIdField) return
try {
await api.delete(`/api/projects/${projectToDelete.id}`)
await loadProjects()
} catch (err) {
console.error('Failed to delete project:', err)
} finally {
showDeleteModal = false
projectToDelete = null
}
deleteIdField.value = String(projectToDelete.id)
showDeleteModal = false
deleteForm.requestSubmit()
projectToDelete = null
}
function cancelDelete() {
showDeleteModal = false
projectToDelete = null
}
function applyFilterAndSort() {
let filtered = [...projects]
// Apply status filter
if (selectedStatusFilter !== 'all') {
filtered = filtered.filter((project) => project.status === selectedStatusFilter)
}
// Apply type filter based on projectType field
if (selectedTypeFilter !== 'all') {
filtered = filtered.filter((project) => project.projectType === selectedTypeFilter)
}
// 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 'year-desc':
filtered.sort((a, b) => b.year - a.year)
break
case 'year-asc':
filtered.sort((a, b) => a.year - b.year)
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
}
filteredProjects = filtered
}
function handleStatusFilterChange() {
applyFilterAndSort()
}
function handleTypeFilterChange() {
applyFilterAndSort()
}
function handleSortChange() {
applyFilterAndSort()
}
</script>
<svelte:head>
<title>Projects - Admin @jedmund</title>
<title>Work - Admin @jedmund</title>
</svelte:head>
<AdminPage>
<AdminHeader title="Projects" slot="header">
<AdminHeader title="Work" slot="header">
{#snippet actions()}
<Button variant="primary" buttonSize="large" href="/admin/projects/new">New Project</Button>
<Button
variant="primary"
buttonSize="large"
onclick={() => goto('/admin/projects/new')}
>
New project
</Button>
{/snippet}
</AdminHeader>
{#if error}
<div class="error">{error}</div>
{:else}
<!-- Filters -->
<AdminFilters>
{#snippet left()}
<Select
bind:value={selectedTypeFilter}
options={typeFilterOptions}
size="small"
variant="minimal"
onchange={handleTypeFilterChange}
/>
<Select
bind:value={selectedStatusFilter}
options={statusFilterOptions}
size="small"
variant="minimal"
onchange={handleStatusFilterChange}
/>
{/snippet}
{#snippet right()}
<Select
bind:value={sortBy}
options={sortOptions}
size="small"
variant="minimal"
onchange={handleSortChange}
/>
{/snippet}
</AdminFilters>
<AdminFilters>
{#snippet left()}
<Select
bind:value={selectedTypeFilter}
options={typeFilterOptions}
size="small"
variant="minimal"
/>
<Select
bind:value={selectedStatusFilter}
options={statusFilterOptions}
size="small"
variant="minimal"
/>
{/snippet}
{#snippet right()}
<Select bind:value={sortBy} options={sortOptions} size="small" variant="minimal" />
{/snippet}
</AdminFilters>
<!-- Projects List -->
{#if isLoading}
<div class="loading">
<div class="spinner"></div>
<p>Loading projects...</p>
</div>
{:else if filteredProjects.length === 0}
<div class="empty-state">
<p>
{#if selectedStatusFilter === 'all' && selectedTypeFilter === 'all'}
No projects found. Create your first project!
{:else}
No projects found matching the current filters. Try adjusting your filters or create a
new project.
{/if}
</p>
</div>
{:else}
<div class="projects-list">
{#each filteredProjects as project}
<ProjectListItem
{project}
onedit={handleEdit}
ontogglePublish={handleTogglePublish}
ondelete={handleDelete}
/>
{/each}
</div>
{/if}
{#if actionError}
<div class="error">{actionError}</div>
{/if}
{#if filteredProjects.length === 0}
<div class="empty-state">
<h3>No projects found</h3>
<p>
{#if selectedTypeFilter === 'all' && selectedStatusFilter === 'all'}
Create your first project to get started!
{:else}
No projects found matching the current filters. Try adjusting your filters or create a new
project.
{/if}
</p>
</div>
{:else}
<div class="projects-list">
{#each filteredProjects as project (project.id)}
<ProjectListItem
{project}
on:edit={handleEdit}
on:togglePublish={handleTogglePublish}
on:delete={handleDelete}
/>
{/each}
</div>
{/if}
</AdminPage>
<DeleteConfirmationModal
bind:isOpen={showDeleteModal}
title="Delete project?"
message={projectToDelete
? `Are you sure you want to delete "${projectToDelete.title}"? This action cannot be undone.`
: ''}
title="Delete Project?"
message="Are you sure you want to delete this project? This action cannot be undone."
confirmText="Delete Project"
onConfirm={confirmDelete}
onCancel={cancelDelete}
/>
<form method="POST" action="?/toggle-status" class="hidden-form" bind:this={toggleForm}>
<input type="hidden" name="id" bind:this={toggleIdField} />
<input type="hidden" name="status" bind:this={toggleStatusField} />
<input type="hidden" name="updatedAt" bind:this={toggleUpdatedAtField} />
</form>
<form method="POST" action="?/delete" class="hidden-form" bind:this={deleteForm}>
<input type="hidden" name="id" bind:this={deleteIdField} />
</form>
<style lang="scss">
@import '$styles/variables.scss';
.error {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
padding: $unit-3x;
border-radius: $unit-2x;
border: 1px solid rgba(239, 68, 68, 0.2);
text-align: center;
padding: $unit-6x;
color: #d33;
}
.loading {
padding: $unit-8x;
text-align: center;
color: $gray-40;
.spinner {
width: 32px;
height: 32px;
border: 3px solid $gray-80;
border-top-color: $primary-color;
border-radius: 50%;
margin: 0 auto $unit-2x;
animation: spin 0.8s linear infinite;
}
p {
margin: 0;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
margin-bottom: $unit-4x;
}
.empty-state {
padding: $unit-8x;
text-align: center;
padding: $unit-8x $unit-4x;
color: $gray-40;
h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 $unit-2x;
color: $gray-20;
}
p {
margin: 0;
line-height: 1.5;
}
}
.projects-list {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.hidden-form {
display: none;
}
</style>