jedmund-svelte/src/routes/admin/posts/+page.svelte
Justin Edmund c6ce13a530 Simplify posts
We had a lot of unnecessary complexity here due to post types that never ended up getting used.

We also made the post slug field reactive and bound to the title field.

We also fixed filters on the Universe admin page so we can filter by unpublished posts too (WIP)

We also fixed the hover state of BackButton
2025-06-11 00:53:54 -07:00

260 lines
5.7 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte'
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'
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 UniverseComposer from '$lib/components/admin/UniverseComposer.svelte'
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
}
let posts = $state<Post[]>([])
let filteredPosts = $state<Post[]>([])
let isLoading = $state(true)
let error = $state('')
let total = $state(0)
let postTypeCounts = $state<Record<string, number>>({})
// Filter state
let selectedFilter = $state<string>('all')
// Composer state
let showInlineComposer = $state(true)
// Create filter options
const filterOptions = $derived([
{ value: 'all', label: 'All posts' },
{ value: 'post', label: 'Posts' },
{ value: 'essay', label: 'Essays' }
])
const postTypeIcons: Record<string, string> = {
post: '💭',
essay: '📝',
// Legacy types for backward compatibility
blog: '📝',
microblog: '💭',
link: '🔗',
photo: '📷',
album: '🖼️'
}
const postTypeLabels: Record<string, string> = {
post: 'Post',
essay: 'Essay',
// Legacy types for backward compatibility
blog: 'Essay',
microblog: 'Post',
link: 'Post',
photo: 'Post',
album: 'Album'
}
onMount(async () => {
await loadPosts()
})
async function loadPosts() {
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
const response = await fetch('/api/posts', {
headers: { Authorization: `Basic ${auth}` }
})
if (!response.ok) {
if (response.status === 401) {
goto('/admin/login')
return
}
throw new Error('Failed to load posts')
}
const data = await response.json()
posts = data.posts || []
total = data.pagination?.total || posts.length
// Calculate post type counts and normalize types
const counts: Record<string, number> = {
all: posts.length,
post: 0,
essay: 0
}
posts.forEach((post) => {
// Normalize legacy types to simplified types
if (post.postType === 'blog') {
counts.essay = (counts.essay || 0) + 1
} else if (['microblog', 'link', 'photo'].includes(post.postType)) {
counts.post = (counts.post || 0) + 1
} else {
counts[post.postType] = (counts[post.postType] || 0) + 1
}
})
postTypeCounts = counts
// Apply initial filter
applyFilter()
} catch (err) {
error = 'Failed to load posts'
console.error(err)
} finally {
isLoading = false
}
}
function applyFilter() {
if (selectedFilter === 'all') {
filteredPosts = posts
} else if (selectedFilter === 'post') {
filteredPosts = posts.filter((post) => ['post', 'microblog'].includes(post.postType))
} else if (selectedFilter === 'essay') {
filteredPosts = posts.filter((post) => ['essay', 'blog'].includes(post.postType))
} else {
filteredPosts = posts.filter((post) => post.postType === selectedFilter)
}
}
function handleFilterChange() {
applyFilter()
}
function handleComposerSaved() {
// Reload posts when a new post is created
loadPosts()
}
</script>
<AdminPage>
<AdminHeader title="Universe" slot="header" />
{#if error}
<div class="error-message">{error}</div>
{:else}
<!-- Inline Composer -->
{#if showInlineComposer}
<div class="composer-section">
<UniverseComposer
isOpen={true}
initialMode="page"
initialPostType="post"
closeOnSave={false}
on:saved={handleComposerSaved}
/>
</div>
{/if}
<!-- Filters -->
<AdminFilters>
{#snippet left()}
<Select
bind:value={selectedFilter}
options={filterOptions}
size="small"
variant="minimal"
onchange={handleFilterChange}
/>
{/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 selectedFilter === 'all'}
Create your first post to get started!
{:else}
No {selectedFilter}s found. Try a different filter or create a new {selectedFilter}.
{/if}
</p>
</div>
{:else}
<div class="posts-list">
{#each filteredPosts as post}
<PostListItem {post} />
{/each}
</div>
{/if}
{/if}
</AdminPage>
<style lang="scss">
@import '$styles/variables.scss';
.error-message {
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;
margin-bottom: $unit-4x;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
.empty-state {
text-align: center;
padding: $unit-8x $unit-4x;
color: $grey-40;
.empty-icon {
font-size: 3rem;
margin-bottom: $unit-3x;
}
h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 $unit-2x;
color: $grey-20;
}
p {
margin: 0;
line-height: 1.5;
}
}
.posts-list {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.composer-section {
margin-bottom: $unit-4x;
padding: 0 $unit;
}
</style>