Add sorts in admin interface

This commit is contained in:
Justin Edmund 2025-06-13 14:50:22 -04:00
parent f753d5fb8b
commit 81af86ae7f
5 changed files with 469 additions and 47 deletions

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { createEventDispatcher, onMount } from 'svelte'
import AdminByline from './AdminByline.svelte'
interface Post {
@ -23,15 +24,59 @@
let { post }: Props = $props()
const dispatch = createEventDispatcher<{
edit: { post: Post }
togglePublish: { post: Post }
delete: { post: Post }
}>()
let isDropdownOpen = $state(false)
const postTypeLabels: Record<string, string> = {
post: 'Post',
essay: 'Essay'
}
function handlePostClick() {
function handlePostClick(event: MouseEvent) {
// Don't navigate if clicking on the dropdown button
if ((event.target as HTMLElement).closest('.dropdown-container')) {
return
}
goto(`/admin/posts/${post.id}/edit`)
}
function handleToggleDropdown(event: MouseEvent) {
event.stopPropagation()
isDropdownOpen = !isDropdownOpen
}
function handleEdit(event: MouseEvent) {
event.stopPropagation()
dispatch('edit', { post })
goto(`/admin/posts/${post.id}/edit`)
}
function handleTogglePublish(event: MouseEvent) {
event.stopPropagation()
dispatch('togglePublish', { post })
isDropdownOpen = false
}
function handleDelete(event: MouseEvent) {
event.stopPropagation()
dispatch('delete', { post })
isDropdownOpen = false
}
onMount(() => {
function handleCloseDropdowns() {
isDropdownOpen = false
}
document.addEventListener('closeDropdowns', handleCloseDropdowns)
return () => document.removeEventListener('closeDropdowns', handleCloseDropdowns)
})
function getPostSnippet(post: Post): string {
// Try excerpt first
if (post.excerpt) {
@ -95,23 +140,52 @@
</script>
<article class="post-item" onclick={handlePostClick}>
{#if post.title}
<h3 class="post-title">{post.title}</h3>
{/if}
<div class="post-main">
{#if post.title}
<h3 class="post-title">{post.title}</h3>
{/if}
<div class="post-content">
<p class="post-preview">{getPostSnippet(post)}</p>
<div class="post-content">
<p class="post-preview">{getPostSnippet(post)}</p>
</div>
<AdminByline
sections={[
postTypeLabels[post.postType] || post.postType,
post.status === 'published' ? 'Published' : 'Draft',
post.status === 'published' && post.publishedAt
? `published ${formatDate(post.publishedAt)}`
: `created ${formatDate(post.createdAt)}`
]}
/>
</div>
<AdminByline
sections={[
postTypeLabels[post.postType] || post.postType,
post.status === 'published' ? 'Published' : 'Draft',
post.status === 'published' && post.publishedAt
? `published ${formatDate(post.publishedAt)}`
: `created ${formatDate(post.createdAt)}`
]}
/>
<div class="dropdown-container">
<button class="action-button" onclick={handleToggleDropdown} aria-label="Post actions">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="10" cy="4" r="1.5" fill="currentColor" />
<circle cx="10" cy="10" r="1.5" fill="currentColor" />
<circle cx="10" cy="16" r="1.5" fill="currentColor" />
</svg>
</button>
{#if isDropdownOpen}
<div class="dropdown-menu">
<button class="dropdown-item" onclick={handleEdit}>Edit post</button>
<button class="dropdown-item" onclick={handleTogglePublish}>
{post.status === 'published' ? 'Unpublish' : 'Publish'} post
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item danger" onclick={handleDelete}>Delete post</button>
</div>
{/if}
</div>
</article>
<style lang="scss">
@ -123,7 +197,8 @@
cursor: pointer;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
flex-direction: row;
align-items: flex-start;
gap: $unit-2x;
&:hover {
@ -131,6 +206,14 @@
}
}
.post-main {
flex: 1;
display: flex;
flex-direction: column;
gap: $unit-2x;
min-width: 0;
}
.post-title {
font-size: 1rem;
font-weight: 600;
@ -163,6 +246,70 @@
overflow: hidden;
}
.dropdown-container {
position: relative;
flex-shrink: 0;
}
.action-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: transparent;
border: none;
border-radius: $unit;
cursor: pointer;
color: $grey-30;
transition: all 0.2s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: $unit-half;
background: white;
border: 1px solid $grey-85;
border-radius: $unit;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
min-width: 180px;
z-index: 10;
}
.dropdown-item {
width: 100%;
padding: $unit-2x $unit-3x;
background: none;
border: none;
text-align: left;
font-size: 0.875rem;
color: $grey-20;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: $grey-95;
}
&.danger {
color: $red-60;
}
}
.dropdown-divider {
height: 1px;
background-color: $grey-90;
margin: $unit-half 0;
}
// Responsive adjustments
@media (max-width: 768px) {
.post-item {

View file

@ -49,6 +49,7 @@
// Filter state
let photographyFilter = $state<string>('all')
let sortBy = $state<string>('newest')
// Filter options
const filterOptions = [
@ -57,6 +58,17 @@
{ value: 'false', label: 'Regular albums' }
]
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
@ -103,8 +115,8 @@
}
albumTypeCounts = counts
// Apply initial filter
applyFilter()
// Apply initial filter and sort
applyFilterAndSort()
} catch (err) {
error = 'Failed to load albums'
console.error(err)
@ -113,14 +125,62 @@
}
}
function applyFilter() {
if (photographyFilter === 'all') {
filteredAlbums = albums
} else if (photographyFilter === 'true') {
filteredAlbums = albums.filter((album) => album.isPhotography === true)
function applyFilterAndSort() {
let filtered = [...albums]
// Apply filter
if (photographyFilter === 'true') {
filtered = filtered.filter((album) => album.isPhotography === true)
} else if (photographyFilter === 'false') {
filteredAlbums = albums.filter((album) => album.isPhotography === false)
filtered = filtered.filter((album) => album.isPhotography === false)
}
// 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 }>) {
@ -199,7 +259,11 @@
}
function handleFilterChange() {
applyFilter()
applyFilterAndSort()
}
function handleSortChange() {
applyFilterAndSort()
}
function handleNewAlbum() {
@ -223,11 +287,20 @@
<Select
bind:value={photographyFilter}
options={filterOptions}
buttonSize="small"
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 -->
@ -253,10 +326,10 @@
<AlbumListItem
{album}
isDropdownActive={activeDropdown === album.id}
ontoggleDropdown={handleToggleDropdown}
onedit={handleEdit}
ontogglePublish={handleTogglePublish}
ondelete={handleDelete}
on:toggleDropdown={handleToggleDropdown}
on:edit={handleEdit}
on:togglePublish={handleTogglePublish}
on:delete={handleDelete}
/>
{/each}
</div>
@ -290,7 +363,7 @@
width: 32px;
height: 32px;
border: 3px solid $grey-80;
border-top-color: $primary-color;
border-top-color: $grey-40;
border-radius: 50%;
margin: 0 auto $unit-2x;
animation: spin 0.8s linear infinite;

View file

@ -339,14 +339,14 @@
<Select
bind:value={filterType}
options={typeFilterOptions}
buttonSize="small"
size="small"
variant="minimal"
onchange={handleFilterChange}
/>
<Select
bind:value={photographyFilter}
options={photographyFilterOptions}
buttonSize="small"
size="small"
variant="minimal"
onchange={handleFilterChange}
/>

View file

@ -8,6 +8,8 @@
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
import Select from '$lib/components/admin/Select.svelte'
import UniverseComposer from '$lib/components/admin/UniverseComposer.svelte'
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
import Button from '$lib/components/admin/Button.svelte'
interface Post {
id: number
@ -35,11 +37,16 @@
// Filter state
let selectedTypeFilter = $state<string>('all')
let selectedStatusFilter = $state<string>('all')
let sortBy = $state<string>('newest')
// Composer state
let showInlineComposer = $state(true)
let isInteractingWithFilters = $state(false)
// Delete confirmation state
let showDeleteConfirmation = $state(false)
let postToDelete = $state<Post | null>(null)
// Create filter options
const typeFilterOptions = $derived([
{ value: 'all', label: 'All posts' },
@ -53,6 +60,15 @@
{ value: 'draft', label: 'Draft' }
])
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: 'status-published', label: 'Published first' },
{ value: 'status-draft', label: 'Draft first' }
]
const postTypeIcons: Record<string, string> = {
post: '💭',
essay: '📝'
@ -115,8 +131,8 @@
}
statusCounts = statusCountsTemp
// Apply initial filter
applyFilter()
// Apply initial filter and sort
applyFilterAndSort()
} catch (err) {
error = 'Failed to load posts'
console.error(err)
@ -125,8 +141,8 @@
}
}
function applyFilter() {
let filtered = posts
function applyFilterAndSort() {
let filtered = [...posts]
// Apply type filter
if (selectedTypeFilter !== 'all') {
@ -138,25 +154,126 @@
filtered = filtered.filter((post) => post.status === selectedStatusFilter)
}
// 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 '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
}
filteredPosts = filtered
}
function handleTypeFilterChange() {
applyFilter()
applyFilterAndSort()
}
function handleStatusFilterChange() {
applyFilter()
applyFilterAndSort()
}
function handleSortChange() {
applyFilterAndSort()
}
function handleComposerSaved() {
// Reload posts when a new post is created
loadPosts()
}
function handleNewEssay() {
goto('/admin/posts/new?type=essay')
}
async function handleTogglePublish(event: CustomEvent<{ post: Post }>) {
const { post } = event.detail
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
const newStatus = post.status === 'published' ? 'draft' : 'published'
try {
const response = await fetch(`/api/posts/${post.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`
},
body: JSON.stringify({ status: newStatus })
})
if (response.ok) {
// Reload posts to refresh the list
await loadPosts()
}
} catch (error) {
console.error('Failed to toggle publish status:', error)
}
}
function handleDeletePost(event: CustomEvent<{ post: Post }>) {
postToDelete = event.detail.post
showDeleteConfirmation = true
}
async function confirmDelete() {
if (!postToDelete) return
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
try {
const response = await fetch(`/api/posts/${postToDelete.id}`, {
method: 'DELETE',
headers: { Authorization: `Basic ${auth}` }
})
if (response.ok) {
showDeleteConfirmation = false
postToDelete = null
// Reload posts to refresh the list
await loadPosts()
}
} catch (error) {
console.error('Failed to delete post:', error)
}
}
</script>
<AdminPage>
<AdminHeader title="Universe" slot="header" />
<AdminHeader title="Universe" slot="header">
{#snippet actions()}
<Button variant="primary" buttonSize="large" onclick={handleNewEssay}>New Essay</Button>
{/snippet}
</AdminHeader>
{#if error}
<div class="error-message">{error}</div>
@ -192,6 +309,15 @@
onchange={handleStatusFilterChange}
/>
{/snippet}
{#snippet right()}
<Select
bind:value={sortBy}
options={sortOptions}
size="small"
variant="minimal"
onchange={handleSortChange}
/>
{/snippet}
</AdminFilters>
<!-- Posts List -->
@ -215,13 +341,29 @@
{:else}
<div class="posts-list">
{#each filteredPosts as post}
<PostListItem {post} />
<PostListItem
{post}
on:togglePublish={handleTogglePublish}
on:delete={handleDeletePost}
/>
{/each}
</div>
{/if}
{/if}
</AdminPage>
<DeleteConfirmationModal
bind:isOpen={showDeleteConfirmation}
title="Delete Post?"
message="Are you sure you want to delete this post? This action cannot be undone."
confirmText="Delete Post"
onConfirm={confirmDelete}
onCancel={() => {
showDeleteConfirmation = false
postToDelete = null
}}
/>
<style lang="scss">
@import '$styles/variables.scss';

View file

@ -36,6 +36,7 @@
// Filter state
let selectedTypeFilter = $state<string>('all')
let selectedStatusFilter = $state<string>('all')
let sortBy = $state<string>('newest')
// Create filter options
const typeFilterOptions = $derived([
@ -50,6 +51,17 @@
{ value: 'draft', label: 'Draft' }
])
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: 'year-desc', label: 'Year (newest)' },
{ value: 'year-asc', label: 'Year (oldest)' },
{ value: 'status-published', label: 'Published first' },
{ value: 'status-draft', label: 'Draft first' }
]
onMount(async () => {
await loadProjects()
// Handle clicks outside dropdowns
@ -96,8 +108,8 @@
}
statusCounts = counts
// Apply initial filter
applyFilter()
// Apply initial filter and sort
applyFilterAndSort()
} catch (err) {
error = 'Failed to load projects'
console.error(err)
@ -166,8 +178,8 @@
projectToDelete = null
}
function applyFilter() {
let filtered = projects
function applyFilterAndSort() {
let filtered = [...projects]
// Apply status filter
if (selectedStatusFilter !== 'all') {
@ -179,15 +191,54 @@
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() {
applyFilter()
applyFilterAndSort()
}
function handleTypeFilterChange() {
applyFilter()
applyFilterAndSort()
}
function handleSortChange() {
applyFilterAndSort()
}
</script>
@ -219,6 +270,15 @@
onchange={handleStatusFilterChange}
/>
{/snippet}
{#snippet right()}
<Select
bind:value={sortBy}
options={sortOptions}
size="small"
variant="minimal"
onchange={handleSortChange}
/>
{/snippet}
</AdminFilters>
<!-- Projects List -->