Componentization and styling
This commit is contained in:
parent
5c32be88c5
commit
cc09c9cd3f
8 changed files with 334 additions and 473 deletions
43
src/lib/components/admin/AdminByline.svelte
Normal file
43
src/lib/components/admin/AdminByline.svelte
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
sections: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let { sections }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="admin-byline">
|
||||||
|
{#each sections as section, index}
|
||||||
|
<span class="byline-section">{section}</span>
|
||||||
|
{#if index < sections.length - 1}
|
||||||
|
<span class="separator">·</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.admin-byline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $grey-40;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.byline-section {
|
||||||
|
// Remove text-transform: capitalize to allow proper sentence case
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
color: $grey-40;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive adjustments
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-byline {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
gap: $unit-half;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
import AvatarSimple from '$lib/components/AvatarSimple.svelte'
|
import AvatarSimple from '$lib/components/AvatarSimple.svelte'
|
||||||
import DashboardIcon from '$icons/dashboard.svg?component'
|
|
||||||
import WorkIcon from '$icons/work.svg?component'
|
import WorkIcon from '$icons/work.svg?component'
|
||||||
import UniverseIcon from '$icons/universe.svg?component'
|
import UniverseIcon from '$icons/universe.svg?component'
|
||||||
import PhotosIcon from '$icons/photos.svg?component'
|
import PhotosIcon from '$icons/photos.svg?component'
|
||||||
|
|
@ -15,7 +14,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ text: 'Dashboard', href: '/admin', icon: DashboardIcon },
|
|
||||||
{ text: 'Projects', href: '/admin/projects', icon: WorkIcon },
|
{ text: 'Projects', href: '/admin/projects', icon: WorkIcon },
|
||||||
{ text: 'Universe', href: '/admin/posts', icon: UniverseIcon },
|
{ text: 'Universe', href: '/admin/posts', icon: UniverseIcon },
|
||||||
{ text: 'Albums', href: '/admin/albums', icon: PhotosIcon },
|
{ text: 'Albums', href: '/admin/albums', icon: PhotosIcon },
|
||||||
|
|
@ -24,17 +22,15 @@
|
||||||
|
|
||||||
// Calculate active index based on current path
|
// Calculate active index based on current path
|
||||||
const activeIndex = $derived(
|
const activeIndex = $derived(
|
||||||
currentPath === '/admin'
|
currentPath.startsWith('/admin/projects')
|
||||||
? 0
|
? 0
|
||||||
: currentPath.startsWith('/admin/projects')
|
: currentPath.startsWith('/admin/posts')
|
||||||
? 1
|
? 1
|
||||||
: currentPath.startsWith('/admin/posts')
|
: currentPath.startsWith('/admin/albums')
|
||||||
? 2
|
? 2
|
||||||
: currentPath.startsWith('/admin/albums')
|
: currentPath.startsWith('/admin/media')
|
||||||
? 3
|
? 3
|
||||||
: currentPath.startsWith('/admin/media')
|
: -1
|
||||||
? 4
|
|
||||||
: -1
|
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,65 +71,47 @@
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<div class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
{#each postTypes as type}
|
{#each postTypes as type}
|
||||||
<Button
|
<li class="dropdown-item" onclick={() => handleSelection(type.value)}>
|
||||||
variant="ghost"
|
<div class="dropdown-icon">
|
||||||
onclick={() => handleSelection(type.value)}
|
{#if type.value === 'essay'}
|
||||||
class="dropdown-item"
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
fullWidth
|
<path
|
||||||
pill={false}
|
d="M3 5C3 3.89543 3.89543 3 5 3H11L17 9V15C17 16.1046 16.1046 17 15 17H5C3.89543 17 3 16.1046 3 15V5Z"
|
||||||
>
|
stroke="currentColor"
|
||||||
{#snippet icon()}
|
stroke-width="1.5"
|
||||||
<div class="dropdown-icon">
|
/>
|
||||||
{#if type.value === 'essay'}
|
<path d="M11 3V9H17" stroke="currentColor" stroke-width="1.5" />
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
<path
|
||||||
<path
|
d="M7 13H13"
|
||||||
d="M3 5C3 3.89543 3.89543 3 5 3H11L17 9V15C17 16.1046 16.1046 17 15 17H5C3.89543 17 3 16.1046 3 15V5Z"
|
stroke="currentColor"
|
||||||
stroke="currentColor"
|
stroke-width="1.5"
|
||||||
stroke-width="1.5"
|
stroke-linecap="round"
|
||||||
/>
|
/>
|
||||||
<path d="M11 3V9H17" stroke="currentColor" stroke-width="1.5" />
|
<path
|
||||||
<path
|
d="M7 10H13"
|
||||||
d="M7 13H13"
|
stroke="currentColor"
|
||||||
stroke="currentColor"
|
stroke-width="1.5"
|
||||||
stroke-width="1.5"
|
stroke-linecap="round"
|
||||||
stroke-linecap="round"
|
/>
|
||||||
/>
|
</svg>
|
||||||
<path
|
{:else if type.value === 'post'}
|
||||||
d="M7 10H13"
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
stroke="currentColor"
|
<path
|
||||||
stroke-width="1.5"
|
d="M4 3C2.89543 3 2 3.89543 2 5V11C2 12.1046 2.89543 13 4 13H6L8 16V13H13C14.1046 13 15 12.1046 15 11V5C15 3.89543 14.1046 3 13 3H4Z"
|
||||||
stroke-linecap="round"
|
stroke="currentColor"
|
||||||
/>
|
stroke-width="1.5"
|
||||||
</svg>
|
/>
|
||||||
{:else if type.value === 'post'}
|
<path d="M5 7H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
<path d="M5 9H10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
<path
|
</svg>
|
||||||
d="M4 3C2.89543 3 2 3.89543 2 5V11C2 12.1046 2.89543 13 4 13H6L8 16V13H13C14.1046 13 15 12.1046 15 11V5C15 3.89543 14.1046 3 13 3H4Z"
|
{/if}
|
||||||
stroke="currentColor"
|
</div>
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M5 7H12"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M5 9H10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
<span class="dropdown-label">{type.label}</span>
|
<span class="dropdown-label">{type.label}</span>
|
||||||
</Button>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -163,17 +145,41 @@
|
||||||
border: 1px solid $grey-85;
|
border: 1px solid $grey-85;
|
||||||
border-radius: $unit-2x;
|
border-radius: $unit-2x;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
min-width: 220px;
|
min-width: 140px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override Button component styles for dropdown items
|
.dropdown-item {
|
||||||
:global(.dropdown-item) {
|
display: flex;
|
||||||
justify-content: flex-start;
|
align-items: center;
|
||||||
text-align: left;
|
gap: $unit;
|
||||||
padding: $unit-2x $unit-3x;
|
padding: $unit-2x $unit-3x;
|
||||||
border-radius: 0;
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $grey-95;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-radius: $unit-2x $unit-2x 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-radius: 0 0 $unit-2x $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:only-child {
|
||||||
|
border-radius: $unit-2x;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-icon {
|
.dropdown-icon {
|
||||||
|
|
|
||||||
183
src/lib/components/admin/PostListItem.svelte
Normal file
183
src/lib/components/admin/PostListItem.svelte
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
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
|
||||||
|
linkUrl: string | null
|
||||||
|
linkDescription: string | null
|
||||||
|
featuredImage: string | null
|
||||||
|
publishedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
post: Post
|
||||||
|
}
|
||||||
|
|
||||||
|
let { post }: Props = $props()
|
||||||
|
|
||||||
|
const postTypeLabels: Record<string, string> = {
|
||||||
|
post: 'Post',
|
||||||
|
essay: 'Essay',
|
||||||
|
// Legacy types for backward compatibility
|
||||||
|
blog: 'Essay',
|
||||||
|
microblog: 'Post',
|
||||||
|
link: 'Post',
|
||||||
|
photo: 'Post',
|
||||||
|
album: 'Album'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePostClick() {
|
||||||
|
goto(`/admin/posts/${post.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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}>
|
||||||
|
{#if post.title}
|
||||||
|
<h3 class="post-title">{post.title}</h3>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="post-content">
|
||||||
|
{#if post.linkUrl}
|
||||||
|
<p class="post-link-url">{post.linkUrl}</p>
|
||||||
|
{/if}
|
||||||
|
<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)}`
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</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: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $grey-95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive adjustments
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.post-item {
|
||||||
|
padding: $unit-2x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
import AdminByline from './AdminByline.svelte'
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -9,9 +10,11 @@
|
||||||
year: number
|
year: number
|
||||||
client: string | null
|
client: string | null
|
||||||
status: string
|
status: string
|
||||||
|
projectType: string
|
||||||
logoUrl: string | null
|
logoUrl: string | null
|
||||||
backgroundColor: string | null
|
backgroundColor: string | null
|
||||||
highlightColor: string | null
|
highlightColor: string | null
|
||||||
|
publishedAt: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
@ -36,11 +39,21 @@
|
||||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||||
|
|
||||||
if (diffInSeconds < 60) return 'just now'
|
if (diffInSeconds < 60) return 'just now'
|
||||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
|
|
||||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
|
const minutes = Math.floor(diffInSeconds / 60)
|
||||||
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)} days ago`
|
if (diffInSeconds < 3600) return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`
|
||||||
if (diffInSeconds < 31536000) return `${Math.floor(diffInSeconds / 2592000)} months ago`
|
|
||||||
return `${Math.floor(diffInSeconds / 31536000)} years ago`
|
const hours = Math.floor(diffInSeconds / 3600)
|
||||||
|
if (diffInSeconds < 86400) return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`
|
||||||
|
|
||||||
|
const days = Math.floor(diffInSeconds / 86400)
|
||||||
|
if (diffInSeconds < 2592000) return `${days} ${days === 1 ? 'day' : 'days'} ago`
|
||||||
|
|
||||||
|
const months = Math.floor(diffInSeconds / 2592000)
|
||||||
|
if (diffInSeconds < 31536000) return `${months} ${months === 1 ? 'month' : 'months'} ago`
|
||||||
|
|
||||||
|
const years = Math.floor(diffInSeconds / 31536000)
|
||||||
|
return `${years} ${years === 1 ? 'year' : 'years'} ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleProjectClick() {
|
function handleProjectClick() {
|
||||||
|
|
@ -79,13 +92,15 @@
|
||||||
|
|
||||||
<div class="project-info">
|
<div class="project-info">
|
||||||
<h3 class="project-title">{project.title}</h3>
|
<h3 class="project-title">{project.title}</h3>
|
||||||
<div class="project-metadata">
|
<AdminByline
|
||||||
<span class="status" class:published={project.status === 'published'}>
|
sections={[
|
||||||
{project.status === 'published' ? 'Published' : 'Not published'}
|
project.projectType === 'work' ? 'Work' : 'Labs',
|
||||||
</span>
|
project.status === 'published' ? 'Published' : 'Draft',
|
||||||
<span class="separator">·</span>
|
project.status === 'published' && project.publishedAt
|
||||||
<span class="updated">Last updated {formatRelativeTime(project.updatedAt)}</span>
|
? `Published ${formatRelativeTime(project.publishedAt)}`
|
||||||
</div>
|
: `Created ${formatRelativeTime(project.createdAt)}`
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dropdown-container">
|
<div class="dropdown-container">
|
||||||
|
|
@ -171,23 +186,6 @@
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-metadata {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: $grey-40;
|
|
||||||
|
|
||||||
.status {
|
|
||||||
&.published {
|
|
||||||
color: #22c55e; // Green color for published status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator {
|
|
||||||
color: $grey-60;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-container {
|
.dropdown-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
||||||
|
|
@ -82,22 +82,22 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
background: $grey-95;
|
background: $grey-90;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal variant
|
// Minimal variant
|
||||||
&.select-minimal {
|
&.select-minimal {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: $grey-90;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $grey-95;
|
background: $grey-80;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
background: $grey-95;
|
background: $grey-80;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
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 PostDropdown from '$lib/components/admin/PostDropdown.svelte'
|
import PostDropdown from '$lib/components/admin/PostDropdown.svelte'
|
||||||
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'
|
||||||
|
|
@ -139,94 +140,6 @@
|
||||||
applyFilter()
|
applyFilter()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePostClick(post: Post) {
|
|
||||||
goto(`/admin/posts/${post.id}/edit`)
|
|
||||||
}
|
|
||||||
|
|
||||||
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} days ago`
|
|
||||||
} else {
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayTitle(post: Post): string {
|
|
||||||
if (post.title) return post.title
|
|
||||||
|
|
||||||
// For posts without titles, create a meaningful display title
|
|
||||||
if (post.linkUrl) {
|
|
||||||
try {
|
|
||||||
const domain = new URL(post.linkUrl).hostname.replace('www.', '')
|
|
||||||
return `Link to ${domain}`
|
|
||||||
} catch {
|
|
||||||
return 'Link post'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const snippet = getPostSnippet(post)
|
|
||||||
if (
|
|
||||||
snippet &&
|
|
||||||
snippet !== `${postTypeLabels[post.postType] || post.postType} without content`
|
|
||||||
) {
|
|
||||||
return snippet.length > 50 ? snippet.substring(0, 50) + '...' : snippet
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${postTypeLabels[post.postType] || post.postType}`
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdminPage>
|
<AdminPage>
|
||||||
|
|
@ -272,92 +185,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="posts-list">
|
<div class="posts-list">
|
||||||
{#each filteredPosts as post}
|
{#each filteredPosts as post}
|
||||||
<article class="post-item" onclick={() => handlePostClick(post)}>
|
<PostListItem {post} />
|
||||||
<div class="post-header">
|
|
||||||
<div class="post-meta">
|
|
||||||
<span class="post-type">
|
|
||||||
{postTypeIcons[post.postType] || '📄'}
|
|
||||||
{postTypeLabels[post.postType] || post.postType}
|
|
||||||
</span>
|
|
||||||
<span class="post-date">{formatDate(post.updatedAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="post-status">
|
|
||||||
{#if post.status === 'published'}
|
|
||||||
<span class="status-badge published">Published</span>
|
|
||||||
{:else}
|
|
||||||
<span class="status-badge draft">Draft</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="post-content">
|
|
||||||
<h3 class="post-title">{getDisplayTitle(post)}</h3>
|
|
||||||
|
|
||||||
{#if post.linkUrl}
|
|
||||||
<div class="post-link">
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="link-url">{post.linkUrl}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<p class="post-snippet">{getPostSnippet(post)}</p>
|
|
||||||
|
|
||||||
{#if post.tags && post.tags.length > 0}
|
|
||||||
<div class="post-tags">
|
|
||||||
{#each post.tags.slice(0, 3) as tag}
|
|
||||||
<span class="tag">#{tag}</span>
|
|
||||||
{/each}
|
|
||||||
{#if post.tags.length > 3}
|
|
||||||
<span class="tag-more">+{post.tags.length - 3} more</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="post-footer">
|
|
||||||
<div class="post-actions">
|
|
||||||
<span class="edit-hint">Click to edit</span>
|
|
||||||
</div>
|
|
||||||
<div class="post-indicator">
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M9 18l6-6-6-6"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -367,7 +195,6 @@
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '$styles/variables.scss';
|
@import '$styles/variables.scss';
|
||||||
|
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
|
|
@ -411,202 +238,8 @@
|
||||||
.posts-list {
|
.posts-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit-3x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-item {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid $grey-85;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: $unit-4x;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-3x;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: $grey-70;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: $unit-2x;
|
gap: $unit-2x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-2x;
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: $unit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-type {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-half;
|
|
||||||
padding: $unit-half $unit;
|
|
||||||
background: $grey-95;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: $grey-30;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-date {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: $grey-50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-status {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
padding: $unit-half $unit-2x;
|
|
||||||
border-radius: 50px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
|
|
||||||
&.published {
|
|
||||||
background: rgba(34, 197, 94, 0.1);
|
|
||||||
color: #16a34a;
|
|
||||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.draft {
|
|
||||||
background: rgba(156, 163, 175, 0.1);
|
|
||||||
color: #6b7280;
|
|
||||||
border: 1px solid rgba(156, 163, 175, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-2x;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-title {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: $grey-10;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: $blue-60;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-url {
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-snippet {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.925rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: $grey-30;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-tags {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: $grey-50;
|
|
||||||
background: $grey-95;
|
|
||||||
padding: $unit-half $unit;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-more {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: $grey-50;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-actions {
|
|
||||||
.edit-hint {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: $grey-50;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-item:hover .edit-hint {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-indicator {
|
|
||||||
color: $grey-60;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-item:hover .post-indicator {
|
|
||||||
color: $grey-30;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responsive adjustments
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.post-item {
|
|
||||||
padding: $unit-3x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.post-item {
|
|
||||||
padding: $unit-3x $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-status {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@
|
||||||
client: string | null
|
client: string | null
|
||||||
status: string
|
status: string
|
||||||
projectType: string
|
projectType: string
|
||||||
|
logoUrl: string | null
|
||||||
backgroundColor: string | null
|
backgroundColor: string | null
|
||||||
highlightColor: string | null
|
highlightColor: string | null
|
||||||
|
publishedAt: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue