Add sorts in admin interface
This commit is contained in:
parent
f753d5fb8b
commit
81af86ae7f
5 changed files with 469 additions and 47 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
|
import { createEventDispatcher, onMount } from 'svelte'
|
||||||
import AdminByline from './AdminByline.svelte'
|
import AdminByline from './AdminByline.svelte'
|
||||||
|
|
||||||
interface Post {
|
interface Post {
|
||||||
|
|
@ -23,15 +24,59 @@
|
||||||
|
|
||||||
let { post }: Props = $props()
|
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> = {
|
const postTypeLabels: Record<string, string> = {
|
||||||
post: 'Post',
|
post: 'Post',
|
||||||
essay: 'Essay'
|
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`)
|
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 {
|
function getPostSnippet(post: Post): string {
|
||||||
// Try excerpt first
|
// Try excerpt first
|
||||||
if (post.excerpt) {
|
if (post.excerpt) {
|
||||||
|
|
@ -95,23 +140,52 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="post-item" onclick={handlePostClick}>
|
<article class="post-item" onclick={handlePostClick}>
|
||||||
{#if post.title}
|
<div class="post-main">
|
||||||
<h3 class="post-title">{post.title}</h3>
|
{#if post.title}
|
||||||
{/if}
|
<h3 class="post-title">{post.title}</h3>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="post-content">
|
<div class="post-content">
|
||||||
<p class="post-preview">{getPostSnippet(post)}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<AdminByline
|
<div class="dropdown-container">
|
||||||
sections={[
|
<button class="action-button" onclick={handleToggleDropdown} aria-label="Post actions">
|
||||||
postTypeLabels[post.postType] || post.postType,
|
<svg
|
||||||
post.status === 'published' ? 'Published' : 'Draft',
|
width="20"
|
||||||
post.status === 'published' && post.publishedAt
|
height="20"
|
||||||
? `published ${formatDate(post.publishedAt)}`
|
viewBox="0 0 20 20"
|
||||||
: `created ${formatDate(post.createdAt)}`
|
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>
|
</article>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
@ -123,7 +197,8 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
gap: $unit-2x;
|
gap: $unit-2x;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
@ -131,6 +206,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.post-title {
|
.post-title {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -163,6 +246,70 @@
|
||||||
overflow: hidden;
|
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
|
// Responsive adjustments
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.post-item {
|
.post-item {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
let photographyFilter = $state<string>('all')
|
let photographyFilter = $state<string>('all')
|
||||||
|
let sortBy = $state<string>('newest')
|
||||||
|
|
||||||
// Filter options
|
// Filter options
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
|
|
@ -57,6 +58,17 @@
|
||||||
{ value: 'false', label: 'Regular albums' }
|
{ 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 () => {
|
onMount(async () => {
|
||||||
await loadAlbums()
|
await loadAlbums()
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
|
|
@ -103,8 +115,8 @@
|
||||||
}
|
}
|
||||||
albumTypeCounts = counts
|
albumTypeCounts = counts
|
||||||
|
|
||||||
// Apply initial filter
|
// Apply initial filter and sort
|
||||||
applyFilter()
|
applyFilterAndSort()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = 'Failed to load albums'
|
error = 'Failed to load albums'
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|
@ -113,14 +125,62 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilter() {
|
function applyFilterAndSort() {
|
||||||
if (photographyFilter === 'all') {
|
let filtered = [...albums]
|
||||||
filteredAlbums = albums
|
|
||||||
} else if (photographyFilter === 'true') {
|
// Apply filter
|
||||||
filteredAlbums = albums.filter((album) => album.isPhotography === true)
|
if (photographyFilter === 'true') {
|
||||||
|
filtered = filtered.filter((album) => album.isPhotography === true)
|
||||||
} else if (photographyFilter === 'false') {
|
} 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 }>) {
|
function handleToggleDropdown(event: CustomEvent<{ albumId: number; event: MouseEvent }>) {
|
||||||
|
|
@ -199,7 +259,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFilterChange() {
|
function handleFilterChange() {
|
||||||
applyFilter()
|
applyFilterAndSort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortChange() {
|
||||||
|
applyFilterAndSort()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewAlbum() {
|
function handleNewAlbum() {
|
||||||
|
|
@ -223,11 +287,20 @@
|
||||||
<Select
|
<Select
|
||||||
bind:value={photographyFilter}
|
bind:value={photographyFilter}
|
||||||
options={filterOptions}
|
options={filterOptions}
|
||||||
buttonSize="small"
|
size="small"
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
onchange={handleFilterChange}
|
onchange={handleFilterChange}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
{#snippet right()}
|
||||||
|
<Select
|
||||||
|
bind:value={sortBy}
|
||||||
|
options={sortOptions}
|
||||||
|
size="small"
|
||||||
|
variant="minimal"
|
||||||
|
onchange={handleSortChange}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
</AdminFilters>
|
</AdminFilters>
|
||||||
|
|
||||||
<!-- Albums List -->
|
<!-- Albums List -->
|
||||||
|
|
@ -253,10 +326,10 @@
|
||||||
<AlbumListItem
|
<AlbumListItem
|
||||||
{album}
|
{album}
|
||||||
isDropdownActive={activeDropdown === album.id}
|
isDropdownActive={activeDropdown === album.id}
|
||||||
ontoggleDropdown={handleToggleDropdown}
|
on:toggleDropdown={handleToggleDropdown}
|
||||||
onedit={handleEdit}
|
on:edit={handleEdit}
|
||||||
ontogglePublish={handleTogglePublish}
|
on:togglePublish={handleTogglePublish}
|
||||||
ondelete={handleDelete}
|
on:delete={handleDelete}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -290,7 +363,7 @@
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: 3px solid $grey-80;
|
border: 3px solid $grey-80;
|
||||||
border-top-color: $primary-color;
|
border-top-color: $grey-40;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin: 0 auto $unit-2x;
|
margin: 0 auto $unit-2x;
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
|
|
|
||||||
|
|
@ -339,14 +339,14 @@
|
||||||
<Select
|
<Select
|
||||||
bind:value={filterType}
|
bind:value={filterType}
|
||||||
options={typeFilterOptions}
|
options={typeFilterOptions}
|
||||||
buttonSize="small"
|
size="small"
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
onchange={handleFilterChange}
|
onchange={handleFilterChange}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
bind:value={photographyFilter}
|
bind:value={photographyFilter}
|
||||||
options={photographyFilterOptions}
|
options={photographyFilterOptions}
|
||||||
buttonSize="small"
|
size="small"
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
onchange={handleFilterChange}
|
onchange={handleFilterChange}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||||
import Select from '$lib/components/admin/Select.svelte'
|
import Select from '$lib/components/admin/Select.svelte'
|
||||||
import UniverseComposer from '$lib/components/admin/UniverseComposer.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 {
|
interface Post {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -35,11 +37,16 @@
|
||||||
// Filter state
|
// 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')
|
||||||
|
|
||||||
// Composer state
|
// Composer state
|
||||||
let showInlineComposer = $state(true)
|
let showInlineComposer = $state(true)
|
||||||
let isInteractingWithFilters = $state(false)
|
let isInteractingWithFilters = $state(false)
|
||||||
|
|
||||||
|
// Delete confirmation state
|
||||||
|
let showDeleteConfirmation = $state(false)
|
||||||
|
let postToDelete = $state<Post | null>(null)
|
||||||
|
|
||||||
// Create filter options
|
// Create filter options
|
||||||
const typeFilterOptions = $derived([
|
const typeFilterOptions = $derived([
|
||||||
{ value: 'all', label: 'All posts' },
|
{ value: 'all', label: 'All posts' },
|
||||||
|
|
@ -53,6 +60,15 @@
|
||||||
{ value: 'draft', label: 'Draft' }
|
{ 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> = {
|
const postTypeIcons: Record<string, string> = {
|
||||||
post: '💭',
|
post: '💭',
|
||||||
essay: '📝'
|
essay: '📝'
|
||||||
|
|
@ -115,8 +131,8 @@
|
||||||
}
|
}
|
||||||
statusCounts = statusCountsTemp
|
statusCounts = statusCountsTemp
|
||||||
|
|
||||||
// Apply initial filter
|
// Apply initial filter and sort
|
||||||
applyFilter()
|
applyFilterAndSort()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = 'Failed to load posts'
|
error = 'Failed to load posts'
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|
@ -125,8 +141,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilter() {
|
function applyFilterAndSort() {
|
||||||
let filtered = posts
|
let filtered = [...posts]
|
||||||
|
|
||||||
// Apply type filter
|
// Apply type filter
|
||||||
if (selectedTypeFilter !== 'all') {
|
if (selectedTypeFilter !== 'all') {
|
||||||
|
|
@ -138,25 +154,126 @@
|
||||||
filtered = filtered.filter((post) => post.status === selectedStatusFilter)
|
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
|
filteredPosts = filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTypeFilterChange() {
|
function handleTypeFilterChange() {
|
||||||
applyFilter()
|
applyFilterAndSort()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStatusFilterChange() {
|
function handleStatusFilterChange() {
|
||||||
applyFilter()
|
applyFilterAndSort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortChange() {
|
||||||
|
applyFilterAndSort()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleComposerSaved() {
|
function handleComposerSaved() {
|
||||||
// Reload posts when a new post is created
|
// Reload posts when a new post is created
|
||||||
loadPosts()
|
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>
|
</script>
|
||||||
|
|
||||||
<AdminPage>
|
<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}
|
{#if error}
|
||||||
<div class="error-message">{error}</div>
|
<div class="error-message">{error}</div>
|
||||||
|
|
@ -192,6 +309,15 @@
|
||||||
onchange={handleStatusFilterChange}
|
onchange={handleStatusFilterChange}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
{#snippet right()}
|
||||||
|
<Select
|
||||||
|
bind:value={sortBy}
|
||||||
|
options={sortOptions}
|
||||||
|
size="small"
|
||||||
|
variant="minimal"
|
||||||
|
onchange={handleSortChange}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
</AdminFilters>
|
</AdminFilters>
|
||||||
|
|
||||||
<!-- Posts List -->
|
<!-- Posts List -->
|
||||||
|
|
@ -215,13 +341,29 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="posts-list">
|
<div class="posts-list">
|
||||||
{#each filteredPosts as post}
|
{#each filteredPosts as post}
|
||||||
<PostListItem {post} />
|
<PostListItem
|
||||||
|
{post}
|
||||||
|
on:togglePublish={handleTogglePublish}
|
||||||
|
on:delete={handleDeletePost}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</AdminPage>
|
</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">
|
<style lang="scss">
|
||||||
@import '$styles/variables.scss';
|
@import '$styles/variables.scss';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
// Filter state
|
// 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')
|
||||||
|
|
||||||
// Create filter options
|
// Create filter options
|
||||||
const typeFilterOptions = $derived([
|
const typeFilterOptions = $derived([
|
||||||
|
|
@ -50,6 +51,17 @@
|
||||||
{ value: 'draft', label: 'Draft' }
|
{ 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 () => {
|
onMount(async () => {
|
||||||
await loadProjects()
|
await loadProjects()
|
||||||
// Handle clicks outside dropdowns
|
// Handle clicks outside dropdowns
|
||||||
|
|
@ -96,8 +108,8 @@
|
||||||
}
|
}
|
||||||
statusCounts = counts
|
statusCounts = counts
|
||||||
|
|
||||||
// Apply initial filter
|
// Apply initial filter and sort
|
||||||
applyFilter()
|
applyFilterAndSort()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = 'Failed to load projects'
|
error = 'Failed to load projects'
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|
@ -166,8 +178,8 @@
|
||||||
projectToDelete = null
|
projectToDelete = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilter() {
|
function applyFilterAndSort() {
|
||||||
let filtered = projects
|
let filtered = [...projects]
|
||||||
|
|
||||||
// Apply status filter
|
// Apply status filter
|
||||||
if (selectedStatusFilter !== 'all') {
|
if (selectedStatusFilter !== 'all') {
|
||||||
|
|
@ -179,15 +191,54 @@
|
||||||
filtered = filtered.filter((project) => project.projectType === selectedTypeFilter)
|
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
|
filteredProjects = filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStatusFilterChange() {
|
function handleStatusFilterChange() {
|
||||||
applyFilter()
|
applyFilterAndSort()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTypeFilterChange() {
|
function handleTypeFilterChange() {
|
||||||
applyFilter()
|
applyFilterAndSort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortChange() {
|
||||||
|
applyFilterAndSort()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -219,6 +270,15 @@
|
||||||
onchange={handleStatusFilterChange}
|
onchange={handleStatusFilterChange}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
{#snippet right()}
|
||||||
|
<Select
|
||||||
|
bind:value={sortBy}
|
||||||
|
options={sortOptions}
|
||||||
|
size="small"
|
||||||
|
variant="minimal"
|
||||||
|
onchange={handleSortChange}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
</AdminFilters>
|
</AdminFilters>
|
||||||
|
|
||||||
<!-- Projects List -->
|
<!-- Projects List -->
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue