319 lines
6.9 KiB
Svelte
319 lines
6.9 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation'
|
|
import { createEventDispatcher, onMount } from 'svelte'
|
|
import AdminByline from './AdminByline.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
|
|
}
|
|
|
|
interface Props {
|
|
post: Post
|
|
}
|
|
|
|
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(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) {
|
|
return post.excerpt.length > 150 ? post.excerpt.substring(0, 150) + '...' : post.excerpt
|
|
}
|
|
|
|
// Try to extract text from content JSON
|
|
if (post.content) {
|
|
let textContent = ''
|
|
|
|
if (typeof post.content === 'object' && post.content.content) {
|
|
// BlockNote/TipTap format
|
|
function extractText(node: any): string {
|
|
if (node.text) return node.text
|
|
if (node.content && Array.isArray(node.content)) {
|
|
return node.content.map(extractText).join(' ')
|
|
}
|
|
return ''
|
|
}
|
|
textContent = extractText(post.content)
|
|
} else if (typeof post.content === 'string') {
|
|
textContent = post.content
|
|
}
|
|
|
|
if (textContent) {
|
|
return textContent.length > 150 ? textContent.substring(0, 150) + '...' : textContent
|
|
}
|
|
}
|
|
|
|
// Fallback to link description for link posts
|
|
if (post.linkDescription) {
|
|
return post.linkDescription.length > 150
|
|
? post.linkDescription.substring(0, 150) + '...'
|
|
: post.linkDescription
|
|
}
|
|
|
|
// Default fallback
|
|
return `${postTypeLabels[post.postType] || post.postType} without content`
|
|
}
|
|
|
|
function formatDate(dateString: string): string {
|
|
const date = new Date(dateString)
|
|
const now = new Date()
|
|
const diffTime = now.getTime() - date.getTime()
|
|
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
|
|
|
|
if (diffDays === 0) {
|
|
return 'today'
|
|
} else if (diffDays === 1) {
|
|
return 'yesterday'
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays} ${diffDays === 1 ? 'day' : 'days'} ago`
|
|
} else {
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
|
|
})
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<article class="post-item" onclick={handlePostClick}>
|
|
<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>
|
|
|
|
<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 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">
|
|
.post-item {
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: $unit-2x;
|
|
padding: $unit-2x;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: flex-start;
|
|
gap: $unit-2x;
|
|
|
|
&:hover {
|
|
background: $grey-95;
|
|
}
|
|
}
|
|
|
|
.post-main {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: $unit-2x;
|
|
min-width: 0;
|
|
}
|
|
|
|
.post-title {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
color: $grey-10;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.post-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: $unit;
|
|
}
|
|
|
|
.post-link-url {
|
|
margin: 0;
|
|
font-size: 0.875rem;
|
|
color: $blue-60;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.post-preview {
|
|
margin: 0;
|
|
font-size: 0.925rem;
|
|
line-height: 1.5;
|
|
color: $grey-30;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
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-80;
|
|
margin: $unit-half 0;
|
|
}
|
|
|
|
// Responsive adjustments
|
|
@media (max-width: 768px) {
|
|
.post-item {
|
|
padding: $unit-2x;
|
|
}
|
|
}
|
|
</style>
|