refactor(admin): load posts list on server

This commit is contained in:
Justin Edmund 2025-10-07 05:30:50 -07:00
parent dbcd7a9e1b
commit 3a588fdf89
4 changed files with 266 additions and 246 deletions

View file

@ -2,32 +2,18 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { createEventDispatcher, onMount } from 'svelte' import { createEventDispatcher, onMount } from 'svelte'
import AdminByline from './AdminByline.svelte' import AdminByline from './AdminByline.svelte'
import type { AdminPost } from '$lib/types/admin'
interface Post {
id: number
slug: string
postType: string
title: string | null
content: any // JSON content
excerpt: string | null
status: string
tags: string[] | null
featuredImage: string | null
publishedAt: string | null
createdAt: string
updatedAt: string
}
interface Props { interface Props {
post: Post post: AdminPost
} }
let { post }: Props = $props() let { post }: Props = $props()
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
edit: { post: Post } edit: { post: AdminPost }
togglePublish: { post: Post } togglePublish: { post: AdminPost }
delete: { post: Post } delete: { post: AdminPost }
}>() }>()
let isDropdownOpen = $state(false) let isDropdownOpen = $state(false)
@ -77,7 +63,7 @@
return () => document.removeEventListener('closeDropdowns', handleCloseDropdowns) return () => document.removeEventListener('closeDropdowns', handleCloseDropdowns)
}) })
function getPostSnippet(post: Post): string { function getPostSnippet(post: AdminPost): string {
// Try excerpt first // Try excerpt first
if (post.excerpt) { if (post.excerpt) {
return post.excerpt.length > 150 ? post.excerpt.substring(0, 150) + '...' : post.excerpt return post.excerpt.length > 150 ? post.excerpt.substring(0, 150) + '...' : post.excerpt
@ -161,7 +147,12 @@
</div> </div>
<div class="dropdown-container"> <div class="dropdown-container">
<button class="action-button" onclick={handleToggleDropdown} aria-label="Post actions"> <button
class="action-button"
type="button"
onclick={handleToggleDropdown}
aria-label="Post actions"
>
<svg <svg
width="20" width="20"
height="20" height="20"
@ -177,12 +168,16 @@
{#if isDropdownOpen} {#if isDropdownOpen}
<div class="dropdown-menu"> <div class="dropdown-menu">
<button class="dropdown-item" onclick={handleEdit}>Edit post</button> <button class="dropdown-item" type="button" onclick={handleEdit}>
<button class="dropdown-item" onclick={handleTogglePublish}> Edit post
</button>
<button class="dropdown-item" type="button" onclick={handleTogglePublish}>
{post.status === 'published' ? 'Unpublish' : 'Publish'} post {post.status === 'published' ? 'Unpublish' : 'Publish'} post
</button> </button>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<button class="dropdown-item danger" onclick={handleDelete}>Delete post</button> <button class="dropdown-item danger" type="button" onclick={handleDelete}>
Delete post
</button>
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -13,3 +13,20 @@ export interface AdminProject {
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
export interface AdminPost {
id: number
slug: string
postType: string
title: string | null
content: unknown
excerpt?: string | null
status: string
tags: string[] | null
featuredImage: string | null
publishedAt: string | null
createdAt: string
updatedAt: string
attachments?: unknown
linkDescription?: string | null
}

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 { AdminPost } from '$lib/types/admin'
interface PostsResponse {
posts: AdminPost[]
}
function toStatusCounts(posts: AdminPost[]) {
return posts.reduce(
(counts, post) => {
counts.all += 1
counts[post.status as 'draft' | 'published'] += 1
return counts
},
{ all: 0, published: 0, draft: 0 }
)
}
function toTypeCounts(posts: AdminPost[]) {
return posts.reduce(
(counts, post) => {
counts.all += 1
if (post.postType === 'post') counts.post += 1
if (post.postType === 'essay') counts.essay += 1
return counts
},
{ all: 0, post: 0, essay: 0 }
)
}
export const load = (async (event) => {
event.depends('admin:posts')
const { posts } = await adminFetchJson<PostsResponse>(event, '/api/posts')
return {
items: posts,
filters: {
statusCounts: toStatusCounts(posts),
typeCounts: toTypeCounts(posts)
}
}
}) 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/posts/${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 post id' })
}
await adminFetch(event, `/api/posts/${id}`, {
method: 'DELETE'
})
return { success: true }
}
} satisfies Actions

View file

@ -1,54 +1,38 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { goto } from '$app/navigation' import { goto, invalidate } from '$app/navigation'
import { api } from '$lib/admin/api'
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'
import PostListItem from '$lib/components/admin/PostListItem.svelte' import PostListItem from '$lib/components/admin/PostListItem.svelte'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
import Select from '$lib/components/admin/Select.svelte'
import InlineComposerModal from '$lib/components/admin/InlineComposerModal.svelte' import InlineComposerModal from '$lib/components/admin/InlineComposerModal.svelte'
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte' import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
import Button from '$lib/components/admin/Button.svelte' import Button from '$lib/components/admin/Button.svelte'
import Select from '$lib/components/admin/Select.svelte'
import type { PageData } from './$types'
import type { AdminPost } from '$lib/types/admin'
interface Post { const { data, form } = $props<{ data: PageData; form?: { message?: string } }>()
id: number
slug: string
postType: string
title: string | null
content: any // JSON content
excerpt: string | null
status: string
tags: string[] | null
featuredImage: string | null
publishedAt: string | null
createdAt: string
updatedAt: string
}
let posts = $state<Post[]>([]) let showInlineComposer = $state(true)
let filteredPosts = $state<Post[]>([]) let showDeleteConfirmation = $state(false)
let isLoading = $state(true) let postToDelete = $state<AdminPost | null>(null)
let error = $state('')
let total = $state(0)
let postTypeCounts = $state<Record<string, number>>({})
let statusCounts = $state<Record<string, number>>({})
// Filter state
let selectedTypeFilter = $state<string>('all') let selectedTypeFilter = $state<string>('all')
let selectedStatusFilter = $state<string>('all') let selectedStatusFilter = $state<string>('all')
let sortBy = $state<string>('newest') let sortBy = $state<string>('newest')
// Composer state const actionError = $derived(form?.message ?? '')
let showInlineComposer = $state(true) const posts = $derived(data.items ?? [])
let isInteractingWithFilters = $state(false)
// Delete confirmation state let toggleForm: HTMLFormElement | null = null
let showDeleteConfirmation = $state(false) let toggleIdField: HTMLInputElement | null = null
let postToDelete = $state<Post | null>(null) let toggleStatusField: HTMLInputElement | null = null
let toggleUpdatedAtField: HTMLInputElement | null = null
let deleteForm: HTMLFormElement | null = null
let deleteIdField: HTMLInputElement | null = null
// Create filter options
const typeFilterOptions = $derived([ const typeFilterOptions = $derived([
{ value: 'all', label: 'All posts' }, { value: 'all', label: 'All posts' },
{ value: 'post', label: 'Posts' }, { value: 'post', label: 'Posts' },
@ -70,154 +54,101 @@ import { api } from '$lib/admin/api'
{ value: 'status-draft', label: 'Draft first' } { value: 'status-draft', label: 'Draft first' }
] ]
const postTypeIcons: Record<string, string> = { const filteredPosts = $derived(() => {
post: '💭', let next = [...posts]
essay: '📝'
}
const postTypeLabels: Record<string, string> = {
post: 'Post',
essay: 'Essay'
}
onMount(async () => {
await loadPosts()
})
async function loadPosts() {
try {
const data = await api.get('/api/posts')
posts = data.posts || []
total = data.pagination?.total || posts.length
// Calculate post type counts
const typeCounts: Record<string, number> = {
all: posts.length,
post: 0,
essay: 0
}
posts.forEach((post) => {
if (post.postType === 'post') {
typeCounts.post++
} else if (post.postType === 'essay') {
typeCounts.essay++
}
})
postTypeCounts = typeCounts
// Calculate status counts
const statusCountsTemp: Record<string, number> = {
all: posts.length,
published: posts.filter((p) => p.status === 'published').length,
draft: posts.filter((p) => p.status === 'draft').length
}
statusCounts = statusCountsTemp
// Apply initial filter and sort
applyFilterAndSort()
} catch (err) {
error = 'Failed to load posts'
console.error(err)
} finally {
isLoading = false
}
}
function applyFilterAndSort() {
let filtered = [...posts]
// Apply type filter
if (selectedTypeFilter !== 'all') { if (selectedTypeFilter !== 'all') {
filtered = filtered.filter((post) => post.postType === selectedTypeFilter) next = next.filter((post) => post.postType === selectedTypeFilter)
} }
// Apply status filter
if (selectedStatusFilter !== 'all') { if (selectedStatusFilter !== 'all') {
filtered = filtered.filter((post) => post.status === selectedStatusFilter) next = next.filter((post) => post.status === selectedStatusFilter)
} }
// Apply sorting
switch (sortBy) { switch (sortBy) {
case 'oldest': case 'oldest':
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) next.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
break break
case 'title-asc': case 'title-asc':
filtered.sort((a, b) => (a.title || '').localeCompare(b.title || '')) next.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
break break
case 'title-desc': case 'title-desc':
filtered.sort((a, b) => (b.title || '').localeCompare(a.title || '')) next.sort((a, b) => (b.title || '').localeCompare(a.title || ''))
break break
case 'status-published': case 'status-published':
filtered.sort((a, b) => { next.sort((a, b) => {
if (a.status === b.status) return 0 if (a.status === b.status) return 0
return a.status === 'published' ? -1 : 1 return a.status === 'published' ? -1 : 1
}) })
break break
case 'status-draft': case 'status-draft':
filtered.sort((a, b) => { next.sort((a, b) => {
if (a.status === b.status) return 0 if (a.status === b.status) return 0
return a.status === 'draft' ? -1 : 1 return a.status === 'draft' ? -1 : 1
}) })
break break
case 'newest': case 'newest':
default: default:
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) next.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
break break
} }
filteredPosts = filtered return next
} })
function handleTypeFilterChange() { onMount(() => {
applyFilterAndSort() document.addEventListener('click', handleOutsideClick)
} return () => document.removeEventListener('click', handleOutsideClick)
})
function handleStatusFilterChange() { function handleOutsideClick(event: MouseEvent) {
applyFilterAndSort() const target = event.target as HTMLElement
} if (!target.closest('.dropdown-container')) {
document.dispatchEvent(new CustomEvent('closeDropdowns'))
function handleSortChange() { }
applyFilterAndSort()
}
function handleComposerSaved() {
// Reload posts when a new post is created
loadPosts()
} }
function handleNewEssay() { function handleNewEssay() {
goto('/admin/posts/new?type=essay') goto('/admin/posts/new?type=essay')
} }
async function handleTogglePublish(event: CustomEvent<{ post: Post }>) { async function handleComposerSaved() {
const { post } = event.detail await invalidate('admin:posts')
const newStatus = post.status === 'published' ? 'draft' : 'published'
try {
await api.patch(`/api/posts/${post.id}`, { status: newStatus, updatedAt: post.updatedAt })
await loadPosts()
} catch (error) {
console.error('Failed to toggle publish status:', error)
}
} }
function handleDeletePost(event: CustomEvent<{ post: Post }>) { function handleEdit(event: CustomEvent<{ post: AdminPost }>) {
goto(`/admin/posts/${event.detail.post.id}/edit`)
}
function handleTogglePublish(event: CustomEvent<{ post: AdminPost }>) {
const post = event.detail.post
if (!toggleForm || !toggleIdField || !toggleStatusField || !toggleUpdatedAtField) {
return
}
toggleIdField.value = String(post.id)
toggleStatusField.value = post.status === 'published' ? 'draft' : 'published'
toggleUpdatedAtField.value = post.updatedAt
toggleForm.requestSubmit()
}
function handleDelete(event: CustomEvent<{ post: AdminPost }>) {
postToDelete = event.detail.post postToDelete = event.detail.post
showDeleteConfirmation = true showDeleteConfirmation = true
} }
async function confirmDelete() { function confirmDelete() {
if (!postToDelete) return if (!postToDelete || !deleteForm || !deleteIdField) return
try { deleteIdField.value = String(postToDelete.id)
await api.delete(`/api/posts/${postToDelete.id}`) showDeleteConfirmation = false
showDeleteConfirmation = false deleteForm.requestSubmit()
postToDelete = null postToDelete = null
await loadPosts() }
} catch (error) {
console.error('Failed to delete post:', error) function cancelDelete() {
} showDeleteConfirmation = false
postToDelete = null
} }
</script> </script>
@ -228,84 +159,72 @@ import { api } from '$lib/admin/api'
<AdminPage> <AdminPage>
<AdminHeader title="Universe" slot="header"> <AdminHeader title="Universe" slot="header">
{#snippet actions()} {#snippet actions()}
<Button variant="primary" buttonSize="large" onclick={handleNewEssay}>New Essay</Button> <Button variant="primary" buttonSize="large" onclick={handleNewEssay}>
New Essay
</Button>
{/snippet} {/snippet}
</AdminHeader> </AdminHeader>
{#if error} {#if showInlineComposer}
<div class="error-message">{error}</div> <div class="composer-section">
<InlineComposerModal
isOpen={true}
initialMode="page"
initialPostType="post"
closeOnSave={false}
on:saved={handleComposerSaved}
/>
</div>
{/if}
<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>
{#if actionError}
<div class="error-message">{actionError}</div>
{/if}
{#if filteredPosts.length === 0}
<div class="empty-state">
<div class="empty-icon">📝</div>
<h3>No posts found</h3>
<p>
{#if selectedTypeFilter === 'all' && selectedStatusFilter === 'all'}
Create your first post to get started!
{:else}
No posts found matching the current filters. Try adjusting your filters or create a new
post.
{/if}
</p>
</div>
{:else} {:else}
<!-- Inline Composer --> <div class="posts-list">
{#if showInlineComposer} {#each filteredPosts as post (post.id)}
<div class="composer-section"> <PostListItem
<InlineComposerModal {post}
isOpen={true} on:edit={handleEdit}
initialMode="page" on:togglePublish={handleTogglePublish}
initialPostType="post" on:delete={handleDelete}
closeOnSave={false}
on:saved={handleComposerSaved}
/> />
</div> {/each}
{/if} </div>
<!-- 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>
<!-- Posts List -->
{#if isLoading}
<div class="loading-container">
<LoadingSpinner />
</div>
{:else if filteredPosts.length === 0}
<div class="empty-state">
<div class="empty-icon">📝</div>
<h3>No posts found</h3>
<p>
{#if selectedTypeFilter === 'all' && selectedStatusFilter === 'all'}
Create your first post to get started!
{:else}
No posts found matching the current filters. Try adjusting your filters or create a new
post.
{/if}
</p>
</div>
{:else}
<div class="posts-list">
{#each filteredPosts as post}
<PostListItem
{post}
on:togglePublish={handleTogglePublish}
on:delete={handleDeletePost}
/>
{/each}
</div>
{/if}
{/if} {/if}
</AdminPage> </AdminPage>
@ -315,12 +234,19 @@ import { api } from '$lib/admin/api'
message="Are you sure you want to delete this post? This action cannot be undone." message="Are you sure you want to delete this post? This action cannot be undone."
confirmText="Delete Post" confirmText="Delete Post"
onConfirm={confirmDelete} onConfirm={confirmDelete}
onCancel={() => { onCancel={cancelDelete}
showDeleteConfirmation = false
postToDelete = null
}}
/> />
<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"> <style lang="scss">
@import '$styles/variables.scss'; @import '$styles/variables.scss';
@ -334,11 +260,9 @@ import { api } from '$lib/admin/api'
margin-bottom: $unit-4x; margin-bottom: $unit-4x;
} }
.loading-container { .composer-section {
display: flex; margin-bottom: $unit-4x;
justify-content: center; padding: 0 $unit;
align-items: center;
min-height: 300px;
} }
.empty-state { .empty-state {
@ -370,8 +294,7 @@ import { api } from '$lib/admin/api'
gap: $unit-2x; gap: $unit-2x;
} }
.composer-section { .hidden-form {
margin-bottom: $unit-4x; display: none;
padding: 0 $unit;
} }
</style> </style>