Componentization and styling

This commit is contained in:
Justin Edmund 2025-06-02 03:43:13 -07:00
parent 5c32be88c5
commit cc09c9cd3f
8 changed files with 334 additions and 473 deletions

View 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">&middot;</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>

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { page } from '$app/stores'
import AvatarSimple from '$lib/components/AvatarSimple.svelte'
import DashboardIcon from '$icons/dashboard.svg?component'
import WorkIcon from '$icons/work.svg?component'
import UniverseIcon from '$icons/universe.svg?component'
import PhotosIcon from '$icons/photos.svg?component'
@ -15,7 +14,6 @@
}
const navItems: NavItem[] = [
{ text: 'Dashboard', href: '/admin', icon: DashboardIcon },
{ text: 'Projects', href: '/admin/projects', icon: WorkIcon },
{ text: 'Universe', href: '/admin/posts', icon: UniverseIcon },
{ text: 'Albums', href: '/admin/albums', icon: PhotosIcon },
@ -24,17 +22,15 @@
// Calculate active index based on current path
const activeIndex = $derived(
currentPath === '/admin'
currentPath.startsWith('/admin/projects')
? 0
: currentPath.startsWith('/admin/projects')
: currentPath.startsWith('/admin/posts')
? 1
: currentPath.startsWith('/admin/posts')
: currentPath.startsWith('/admin/albums')
? 2
: currentPath.startsWith('/admin/albums')
: currentPath.startsWith('/admin/media')
? 3
: currentPath.startsWith('/admin/media')
? 4
: -1
: -1
)
</script>

View file

@ -71,65 +71,47 @@
</Button>
{#if isOpen}
<div class="dropdown-menu">
<ul class="dropdown-menu">
{#each postTypes as type}
<Button
variant="ghost"
onclick={() => handleSelection(type.value)}
class="dropdown-item"
fullWidth
pill={false}
>
{#snippet icon()}
<div class="dropdown-icon">
{#if type.value === 'essay'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
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-width="1.5"
/>
<path d="M11 3V9H17" stroke="currentColor" stroke-width="1.5" />
<path
d="M7 13H13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M7 10H13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
{:else if type.value === 'post'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
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="currentColor"
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}
<li class="dropdown-item" onclick={() => handleSelection(type.value)}>
<div class="dropdown-icon">
{#if type.value === 'essay'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
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-width="1.5"
/>
<path d="M11 3V9H17" stroke="currentColor" stroke-width="1.5" />
<path
d="M7 13H13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M7 10H13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
{:else if type.value === 'post'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
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="currentColor"
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>
<span class="dropdown-label">{type.label}</span>
</Button>
</li>
{/each}
</div>
</ul>
{/if}
</div>
@ -163,17 +145,41 @@
border: 1px solid $grey-85;
border-radius: $unit-2x;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
min-width: 220px;
min-width: 140px;
z-index: 100;
overflow: hidden;
margin: 0;
padding: 0;
list-style: none;
}
// Override Button component styles for dropdown items
:global(.dropdown-item) {
justify-content: flex-start;
text-align: left;
.dropdown-item {
display: flex;
align-items: center;
gap: $unit;
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 {

View 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>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { createEventDispatcher } from 'svelte'
import AdminByline from './AdminByline.svelte'
interface Project {
id: number
@ -9,9 +10,11 @@
year: number
client: string | null
status: string
projectType: string
logoUrl: string | null
backgroundColor: string | null
highlightColor: string | null
publishedAt: string | null
createdAt: string
updatedAt: string
}
@ -36,11 +39,21 @@
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
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`
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)} days ago`
if (diffInSeconds < 31536000) return `${Math.floor(diffInSeconds / 2592000)} months ago`
return `${Math.floor(diffInSeconds / 31536000)} years ago`
const minutes = Math.floor(diffInSeconds / 60)
if (diffInSeconds < 3600) return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} 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() {
@ -79,13 +92,15 @@
<div class="project-info">
<h3 class="project-title">{project.title}</h3>
<div class="project-metadata">
<span class="status" class:published={project.status === 'published'}>
{project.status === 'published' ? 'Published' : 'Not published'}
</span>
<span class="separator">·</span>
<span class="updated">Last updated {formatRelativeTime(project.updatedAt)}</span>
</div>
<AdminByline
sections={[
project.projectType === 'work' ? 'Work' : 'Labs',
project.status === 'published' ? 'Published' : 'Draft',
project.status === 'published' && project.publishedAt
? `Published ${formatRelativeTime(project.publishedAt)}`
: `Created ${formatRelativeTime(project.createdAt)}`
]}
/>
</div>
<div class="dropdown-container">
@ -171,23 +186,6 @@
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 {
position: relative;

View file

@ -82,22 +82,22 @@
}
&:disabled {
background: $grey-95;
background: $grey-90;
}
}
// Minimal variant
&.select-minimal {
border: none;
background: transparent;
background: $grey-90;
font-weight: 500;
&:hover {
background: $grey-95;
background: $grey-80;
}
&:focus {
background: $grey-95;
background: $grey-80;
}
&:disabled {

View file

@ -4,6 +4,7 @@
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 PostDropdown from '$lib/components/admin/PostDropdown.svelte'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
import Select from '$lib/components/admin/Select.svelte'
@ -139,94 +140,6 @@
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>
<AdminPage>
@ -272,92 +185,7 @@
{:else}
<div class="posts-list">
{#each filteredPosts as post}
<article class="post-item" onclick={() => handlePostClick(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>
<PostListItem {post} />
{/each}
</div>
{/if}
@ -367,7 +195,6 @@
<style lang="scss">
@import '$styles/variables.scss';
.error-message {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
@ -411,202 +238,8 @@
.posts-list {
display: flex;
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;
}
.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>

View file

@ -17,8 +17,10 @@
client: string | null
status: string
projectType: string
logoUrl: string | null
backgroundColor: string | null
highlightColor: string | null
publishedAt: string | null
createdAt: string
updatedAt: string
}