Universe card styling

Fine tuning styles
This commit is contained in:
Justin Edmund 2025-06-02 12:23:30 -07:00
parent 46aa8f1155
commit e029c6b61d
9 changed files with 394 additions and 459 deletions

View file

@ -81,6 +81,7 @@ $grey-20: #666666;
$grey-10: #4d4d4d;
$grey-00: #333333;
$red-90: #ff9d8f;
$red-80: #ff6a54;
$red-60: #e33d3d;
$red-50: #d33;

View file

@ -1,111 +1,17 @@
<script lang="ts">
import LinkCard from './LinkCard.svelte'
import Slideshow from './Slideshow.svelte'
import { formatDate } from '$lib/utils/date'
import { renderEdraContent } from '$lib/utils/content'
let { post }: { post: any } = $props()
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
}
const getPostTypeLabel = (postType: string) => {
switch (postType) {
case 'post':
return 'Post'
case 'essay':
return 'Essay'
default:
return 'Post'
}
}
// Render Edra/BlockNote JSON content to HTML
const renderEdraContent = (content: any): string => {
if (!content) return ''
// Handle both { blocks: [...] } and { content: [...] } formats
const blocks = content.blocks || content.content || []
if (!Array.isArray(blocks)) return ''
const renderBlock = (block: any): string => {
switch (block.type) {
case 'heading':
const level = block.attrs?.level || block.level || 1
const headingText = block.content || block.text || ''
return `<h${level}>${headingText}</h${level}>`
case 'paragraph':
const paragraphText = block.content || block.text || ''
if (!paragraphText) return '<p><br></p>'
return `<p>${paragraphText}</p>`
case 'bulletList':
case 'ul':
const listItems = (block.content || [])
.map((item: any) => {
const itemText = item.content || item.text || ''
return `<li>${itemText}</li>`
})
.join('')
return `<ul>${listItems}</ul>`
case 'orderedList':
case 'ol':
const orderedItems = (block.content || [])
.map((item: any) => {
const itemText = item.content || item.text || ''
return `<li>${itemText}</li>`
})
.join('')
return `<ol>${orderedItems}</ol>`
case 'blockquote':
const quoteText = block.content || block.text || ''
return `<blockquote><p>${quoteText}</p></blockquote>`
case 'codeBlock':
case 'code':
const codeText = block.content || block.text || ''
const language = block.attrs?.language || block.language || ''
return `<pre><code class="language-${language}">${codeText}</code></pre>`
case 'image':
const src = block.attrs?.src || block.src || ''
const alt = block.attrs?.alt || block.alt || ''
const caption = block.attrs?.caption || block.caption || ''
return `<figure><img src="${src}" alt="${alt}" />${caption ? `<figcaption>${caption}</figcaption>` : ''}</figure>`
case 'hr':
case 'horizontalRule':
return '<hr>'
default:
// For simple text content
const text = block.content || block.text || ''
if (text) {
return `<p>${text}</p>`
}
return ''
}
}
return blocks.map(renderBlock).join('')
}
const renderedContent = $derived(post.content ? renderEdraContent(post.content) : '')
</script>
<article class="post-content {post.postType}">
<header class="post-header">
<div class="post-meta">
<span class="post-type-badge">
{getPostTypeLabel(post.postType)}
</span>
<time class="post-date" datetime={post.publishedAt}>
{formatDate(post.publishedAt)}
</time>
@ -137,8 +43,8 @@
<p class="album-description">{post.album.description}</p>
{/if}
</div>
<Slideshow
items={post.album.photos.map(photo => ({
<Slideshow
items={post.album.photos.map((photo) => ({
url: photo.url,
thumbnailUrl: photo.thumbnailUrl,
caption: photo.caption,
@ -152,8 +58,8 @@
<!-- Regular attachments -->
<div class="post-attachments">
<h3>Photos</h3>
<Slideshow
items={post.attachments.map(attachment => ({
<Slideshow
items={post.attachments.map((attachment) => ({
url: attachment.url,
thumbnailUrl: attachment.thumbnailUrl,
caption: attachment.caption,
@ -216,17 +122,6 @@
margin-bottom: $unit-3x;
}
.post-type-badge {
background: $blue-60;
color: white;
padding: $unit-half $unit-2x;
border-radius: 50px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.post-date {
font-size: 0.9rem;
color: $grey-40;
@ -419,4 +314,4 @@
text-underline-offset: 0.15em;
}
}
</style>
</style>

View file

@ -33,31 +33,32 @@
// Calculate columns based on breakpoints
const columnsPerRow = $derived(windowWidth <= 400 ? 3 : windowWidth <= 600 ? 4 : 6)
// Make maxThumbnails responsive - use fewer thumbnails on smaller screens
const responsiveMaxThumbnails = $derived(
maxThumbnails ? (windowWidth <= 400 ? 3 : windowWidth <= 600 ? 4 : maxThumbnails) : undefined
)
const showMoreThumbnail = $derived(
responsiveMaxThumbnails && totalCount && totalCount > responsiveMaxThumbnails - 1
)
// Determine how many thumbnails to show
const displayItems = $derived(
!responsiveMaxThumbnails || !showMoreThumbnail
? items
: items.slice(0, responsiveMaxThumbnails - 1) // Show actual thumbnails, leave last slot for "+N"
)
const remainingCount = $derived(
showMoreThumbnail ? (totalCount || items.length) - (responsiveMaxThumbnails - 1) : 0
)
const totalSlots = $derived(
responsiveMaxThumbnails
? responsiveMaxThumbnails
: Math.ceil((displayItems.length + (showMoreThumbnail ? 1 : 0)) / columnsPerRow) * columnsPerRow
: Math.ceil((displayItems.length + (showMoreThumbnail ? 1 : 0)) / columnsPerRow) *
columnsPerRow
)
// Convert items to image URLs for lightbox
@ -105,16 +106,16 @@
<div class="slideshow">
<TiltCard>
<div class="main-image image-container" onclick={() => openLightbox()}>
<img
src={items[selectedIndex].url}
alt={items[selectedIndex].alt || `${alt} ${selectedIndex + 1}`}
<img
src={items[selectedIndex].url}
alt={items[selectedIndex].alt || `${alt} ${selectedIndex + 1}`}
/>
{#if items[selectedIndex].caption}
<div class="image-caption">{items[selectedIndex].caption}</div>
{/if}
</div>
</TiltCard>
{#if showThumbnails}
<div class="thumbnails">
{#each Array(totalSlots) as _, index}
@ -125,9 +126,9 @@
onclick={() => selectImage(index)}
aria-label="View image {index + 1}"
>
<img
src={displayItems[index].thumbnailUrl || displayItems[index].url}
alt="{displayItems[index].alt || alt} thumbnail {index + 1}"
<img
src={displayItems[index].thumbnailUrl || displayItems[index].url}
alt="{displayItems[index].alt || alt} thumbnail {index + 1}"
/>
</button>
{:else if index === displayItems.length && showMoreThumbnail}
@ -137,14 +138,14 @@
aria-label="View all {totalCount || items.length} photos"
>
{#if items[displayItems.length]}
<img
src={items[displayItems.length].thumbnailUrl || items[displayItems.length].url}
<img
src={items[displayItems.length].thumbnailUrl || items[displayItems.length].url}
alt="View all photos"
class="blurred-bg"
/>
{:else if items[items.length - 1]}
<img
src={items[items.length - 1].thumbnailUrl || items[items.length - 1].url}
<img
src={items[items.length - 1].thumbnailUrl || items[items.length - 1].url}
alt="View all photos"
class="blurred-bg"
/>
@ -162,7 +163,7 @@
</div>
{/if}
<Lightbox images={lightboxImages} bind:selectedIndex bind:isOpen={lightboxOpen} alt={alt} />
<Lightbox images={lightboxImages} bind:selectedIndex bind:isOpen={lightboxOpen} {alt} />
<style lang="scss">
.image-container {
@ -267,6 +268,18 @@
transform: scale(0.98);
}
&:focus-visible {
outline: none;
&::before {
border-color: $red-90;
}
&::after {
border-color: $grey-100;
}
}
&.active {
&::before {
border-color: $red-60;
@ -315,6 +328,18 @@
background: rgba(0, 0, 0, 0.7);
}
}
&:focus-visible {
outline: none;
&::before {
border-color: $red-90;
}
.show-more-overlay {
box-shadow: inset 0 0 0 3px rgba($red-90, 0.5);
}
}
}
img {
@ -325,4 +350,4 @@
z-index: 1;
}
}
</style>
</style>

View file

@ -1,177 +1,79 @@
<script lang="ts">
import UniverseIcon from '$icons/universe.svg'
import UniverseCard from './UniverseCard.svelte'
import Slideshow from './Slideshow.svelte'
import type { UniverseItem } from '../../routes/api/universe/+server'
let { album }: { album: UniverseItem } = $props()
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
// Convert photos to slideshow items
const slideshowItems = $derived(
album.photos && album.photos.length > 0
? album.photos.map(photo => ({
url: photo.url,
thumbnailUrl: photo.thumbnailUrl,
caption: photo.caption,
alt: photo.caption || album.title
}))
: album.coverPhoto
? [{
url: album.coverPhoto.url,
thumbnailUrl: album.coverPhoto.thumbnailUrl,
caption: album.coverPhoto.caption,
alt: album.coverPhoto.caption || album.title
}]
album.photos && album.photos.length > 0
? album.photos.map((photo) => ({
url: photo.url,
thumbnailUrl: photo.thumbnailUrl,
caption: photo.caption,
alt: photo.caption || album.title
}))
: album.coverPhoto
? [
{
url: album.coverPhoto.url,
thumbnailUrl: album.coverPhoto.thumbnailUrl,
caption: album.coverPhoto.caption,
alt: album.coverPhoto.caption || album.title
}
]
: []
)
</script>
<article class="universe-album-card">
<div class="card-content">
<div class="card-header">
<div class="album-type-badge">Album</div>
<time class="album-date" datetime={album.publishedAt}>
{formatDate(album.publishedAt)}
</time>
<UniverseCard item={album} type="album">
{#if slideshowItems.length > 0}
<div class="album-slideshow">
<Slideshow
items={slideshowItems}
alt={album.title}
aspectRatio="3/2"
showThumbnails={slideshowItems.length > 1}
maxThumbnails={6}
totalCount={album.photosCount}
showMoreLink="/photos/{album.slug}"
/>
</div>
{/if}
{#if slideshowItems.length > 0}
<div class="album-slideshow">
<Slideshow
items={slideshowItems}
alt={album.title}
aspectRatio="3/2"
showThumbnails={slideshowItems.length > 1}
maxThumbnails={6}
totalCount={album.photosCount}
showMoreLink="/photos/{album.slug}"
/>
</div>
<div class="album-info">
<h2 class="card-title">
<a href="/photos/{album.slug}" class="card-title-link" onclick={(e) => e.preventDefault()} tabindex="-1">{album.title}</a>
</h2>
{#if album.description}
<p class="album-description">{album.description}</p>
{/if}
<div class="album-info">
<h2 class="album-title">
<a href="/photos/{album.slug}" class="album-title-link">{album.title}</a>
</h2>
{#if album.location || album.date}
<div class="album-meta">
{#if album.date}
<span class="album-meta-item">📅 {formatDate(album.date)}</span>
{/if}
{#if album.location}
<span class="album-meta-item">📍 {album.location}</span>
{/if}
</div>
{/if}
{#if album.description}
<p class="album-description">{album.description}</p>
{/if}
</div>
<div class="card-footer">
<a href="/photos/{album.slug}" class="view-album"> View album → </a>
<UniverseIcon class="universe-icon" />
</div>
</div>
</article>
</UniverseCard>
<style lang="scss">
.universe-album-card {
width: 100%;
max-width: 700px;
margin: 0 auto;
}
.card-content {
padding: $unit-4x;
background: $grey-100;
border-radius: $card-corner-radius;
border: 1px solid $grey-95;
transition: all 0.2s ease;
&:hover {
border-color: $grey-85;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $unit-3x;
}
.album-type-badge {
background: #22c55e;
color: white;
padding: $unit-half $unit-2x;
border-radius: 50px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.album-date {
font-size: 0.875rem;
color: $grey-40;
font-weight: 400;
}
.album-slideshow {
position: relative;
width: 100%;
margin-bottom: $unit-3x;
margin-bottom: $unit-2x;
}
.album-info {
margin-bottom: $unit-3x;
margin-bottom: 0;
}
.album-title {
.card-title {
margin: 0 0 $unit-2x;
font-size: 1.375rem;
font-weight: 600;
line-height: 1.3;
}
.album-title-link {
color: $grey-10;
.card-title-link {
text-decoration: none;
transition: all 0.2s ease;
&:hover {
color: #22c55e;
text-decoration: underline;
text-decoration-style: wavy;
text-underline-offset: 0.15em;
}
}
.album-meta {
display: flex;
flex-wrap: wrap;
gap: $unit-2x;
margin-bottom: $unit-2x;
.album-meta-item {
font-size: 0.875rem;
color: $grey-40;
display: flex;
align-items: center;
gap: $unit-half;
}
}
.album-description {
@ -184,32 +86,4 @@
-webkit-line-clamp: 3;
overflow: hidden;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: $unit-2x;
border-top: 1px solid $grey-90;
}
.view-album {
color: #22c55e;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
&:hover {
text-decoration: underline;
text-decoration-style: wavy;
text-underline-offset: 0.15em;
}
}
:global(.universe-icon) {
width: 16px;
height: 16px;
fill: $grey-40;
}
</style>
</style>

View file

@ -0,0 +1,162 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import UniverseIcon from '$icons/universe.svg'
import { formatDate } from '$lib/utils/date'
import { goto } from '$app/navigation'
interface UniverseItem {
slug: string
publishedAt: string
[key: string]: any
}
let {
item,
type = 'post',
children
}: {
item: UniverseItem
type?: 'post' | 'album'
children?: Snippet
} = $props()
const href = $derived(type === 'album' ? `/photos/${item.slug}` : `/universe/${item.slug}`)
const handleCardClick = (event: MouseEvent) => {
// Check if the click is on an interactive element
const target = event.target as HTMLElement
const isInteractive =
target.closest('a') ||
target.closest('button') ||
target.closest('.slideshow') ||
target.closest('.album-slideshow') ||
target.tagName === 'A' ||
target.tagName === 'BUTTON'
if (!isInteractive) {
goto(href)
}
}
</script>
<article class="universe-card universe-card--{type}">
<div
class="card-content"
onclick={handleCardClick}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && handleCardClick(e)}
>
{@render children?.()}
<div class="card-footer">
<a {href} class="card-link" tabindex="-1">
<time class="card-date" datetime={item.publishedAt}>
{formatDate(item.publishedAt)}
</time>
</a>
<UniverseIcon class="universe-icon" />
</div>
</div>
</article>
<style lang="scss">
.universe-card {
width: 100%;
max-width: 700px;
margin: 0 auto;
}
.card-content {
padding: $unit-4x;
background: $grey-100;
border-radius: $card-corner-radius;
border: 1px solid $grey-95;
transition: all 0.2s ease;
cursor: pointer;
outline: none;
&:hover {
border-color: $grey-85;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
&:focus-visible {
outline: 2px solid $red-60;
outline-offset: 2px;
}
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: $unit-2x;
padding-top: $unit-2x;
}
.card-link {
text-decoration: none;
color: inherit;
}
.card-date {
color: $grey-40;
font-size: 0.875rem;
font-weight: 400;
transition: color 0.2s ease;
}
:global(.universe-icon) {
width: 16px;
height: 16px;
fill: $grey-40;
transition: all 0.2s ease;
}
.universe-card--post {
.card-content:hover {
.card-date {
color: $red-60;
}
:global(.universe-icon) {
fill: $red-60;
transform: rotate(15deg);
}
:global(.card-title-link) {
color: $red-60;
}
}
:global(.card-title-link) {
color: $grey-10;
text-decoration: none;
transition: all 0.2s ease;
}
}
.universe-card--album {
.card-content:hover {
.card-date {
color: $red-60;
}
:global(.universe-icon) {
fill: $red-60;
transform: rotate(15deg);
}
:global(.card-title-link) {
color: $red-60;
}
}
:global(.card-title-link) {
color: $grey-10;
text-decoration: none;
transition: all 0.2s ease;
}
}
</style>

View file

@ -26,7 +26,7 @@
.universe-feed {
display: flex;
flex-direction: column;
gap: $unit-4x;
gap: $unit-3x;
}
.empty-state {

View file

@ -1,161 +1,64 @@
<script lang="ts">
import UniverseIcon from '$icons/universe.svg'
import UniverseCard from './UniverseCard.svelte'
import { getContentExcerpt } from '$lib/utils/content'
import type { UniverseItem } from '../../routes/api/universe/+server'
let { post }: { post: UniverseItem } = $props()
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
const getPostTypeLabel = (postType: string) => {
switch (postType) {
case 'post':
return 'Post'
case 'essay':
return 'Essay'
default:
return 'Post'
}
}
// Extract text content from Edra JSON for excerpt
const getContentExcerpt = (content: any, maxLength = 200): string => {
if (!content || !content.content) return ''
const extractText = (node: any): string => {
if (node.text) return node.text
if (node.content && Array.isArray(node.content)) {
return node.content.map(extractText).join(' ')
}
return ''
}
const text = content.content.map(extractText).join(' ').trim()
if (text.length <= maxLength) return text
return text.substring(0, maxLength).trim() + '...'
}
</script>
<article class="universe-post-card">
<div class="card-content">
<div class="card-header">
<div class="post-type-badge">
{getPostTypeLabel(post.postType || 'post')}
</div>
<time class="post-date" datetime={post.publishedAt}>
{formatDate(post.publishedAt)}
</time>
</div>
<UniverseCard item={post} type="post">
{#if post.title}
<h2 class="card-title">
<a href="/universe/{post.slug}" class="card-title-link" onclick={(e) => e.preventDefault()} tabindex="-1">{post.title}</a>
</h2>
{/if}
{#if post.title}
<h2 class="post-title">
<a href="/universe/{post.slug}" class="post-title-link">{post.title}</a>
</h2>
{/if}
{#if post.linkUrl}
<!-- Link post type -->
<div class="link-preview">
<a href={post.linkUrl} target="_blank" rel="noopener noreferrer" class="link-url">
{post.linkUrl}
</a>
{#if post.linkDescription}
<p class="link-description">{post.linkDescription}</p>
{/if}
</div>
{/if}
<div class="post-excerpt">
{#if post.excerpt}
<p>{post.excerpt}</p>
{:else if post.content}
<p>{getContentExcerpt(post.content)}</p>
{#if post.linkUrl}
<!-- Link post type -->
<div class="link-preview">
<a href={post.linkUrl} target="_blank" rel="noopener noreferrer" class="link-url">
{post.linkUrl}
</a>
{#if post.linkDescription}
<p class="link-description">{post.linkDescription}</p>
{/if}
</div>
{/if}
{#if post.attachments && Array.isArray(post.attachments) && post.attachments.length > 0}
<div class="attachments">
<div class="attachment-count">
📎 {post.attachments.length} attachment{post.attachments.length > 1 ? 's' : ''}
</div>
</div>
<div class="post-excerpt">
{#if post.excerpt}
<p>{post.excerpt}</p>
{:else if post.content}
<p>{getContentExcerpt(post.content)}</p>
{/if}
<div class="card-footer">
<a href="/universe/{post.slug}" class="read-more"> Read more → </a>
<UniverseIcon class="universe-icon" />
</div>
</div>
</article>
{#if post.postType === 'essay'}
<p>
<a href="/universe/{post.slug}" class="read-more" onclick={(e) => e.preventDefault()} tabindex="-1">Continue reading</a>
</p>
{/if}
{#if post.attachments && Array.isArray(post.attachments) && post.attachments.length > 0}
<div class="attachments">
<div class="attachment-count">
📎 {post.attachments.length} attachment{post.attachments.length > 1 ? 's' : ''}
</div>
</div>
{/if}
</UniverseCard>
<style lang="scss">
.universe-post-card {
width: 100%;
max-width: 700px;
margin: 0 auto;
}
.card-content {
padding: $unit-4x;
background: $grey-100;
border-radius: $card-corner-radius;
border: 1px solid $grey-95;
transition: all 0.2s ease;
&:hover {
border-color: $grey-85;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $unit-2x;
}
.post-type-badge {
background: $blue-60;
color: white;
padding: $unit-half $unit-2x;
border-radius: 50px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.post-date {
font-size: 0.875rem;
color: $grey-40;
font-weight: 400;
}
.post-title {
.card-title {
margin: 0 0 $unit-3x;
font-size: 1.375rem;
font-weight: 600;
line-height: 1.3;
}
.post-title-link {
color: $grey-10;
.card-title-link {
text-decoration: none;
transition: all 0.2s ease;
&:hover {
color: $red-60;
text-decoration: underline;
text-decoration-style: wavy;
text-underline-offset: 0.15em;
}
}
.link-preview {
@ -187,8 +90,6 @@
}
.post-excerpt {
margin-bottom: $unit-3x;
p {
margin: 0;
color: $grey-20;
@ -215,31 +116,11 @@
}
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: $unit-2x;
border-top: 1px solid $grey-90;
}
.read-more {
color: $red-60;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
&:hover {
text-decoration: underline;
text-decoration-style: wavy;
text-underline-offset: 0.15em;
}
}
:global(.universe-icon) {
width: 16px;
height: 16px;
fill: $grey-40;
}
</style>

89
src/lib/utils/content.ts Normal file
View file

@ -0,0 +1,89 @@
// Render Edra/BlockNote JSON content to HTML
export const renderEdraContent = (content: any): string => {
if (!content) return ''
// Handle both { blocks: [...] } and { content: [...] } formats
const blocks = content.blocks || content.content || []
if (!Array.isArray(blocks)) return ''
const renderBlock = (block: any): string => {
switch (block.type) {
case 'heading':
const level = block.attrs?.level || block.level || 1
const headingText = block.content || block.text || ''
return `<h${level}>${headingText}</h${level}>`
case 'paragraph':
const paragraphText = block.content || block.text || ''
if (!paragraphText) return '<p><br></p>'
return `<p>${paragraphText}</p>`
case 'bulletList':
case 'ul':
const listItems = (block.content || [])
.map((item: any) => {
const itemText = item.content || item.text || ''
return `<li>${itemText}</li>`
})
.join('')
return `<ul>${listItems}</ul>`
case 'orderedList':
case 'ol':
const orderedItems = (block.content || [])
.map((item: any) => {
const itemText = item.content || item.text || ''
return `<li>${itemText}</li>`
})
.join('')
return `<ol>${orderedItems}</ol>`
case 'blockquote':
const quoteText = block.content || block.text || ''
return `<blockquote><p>${quoteText}</p></blockquote>`
case 'codeBlock':
case 'code':
const codeText = block.content || block.text || ''
const language = block.attrs?.language || block.language || ''
return `<pre><code class="language-${language}">${codeText}</code></pre>`
case 'image':
const src = block.attrs?.src || block.src || ''
const alt = block.attrs?.alt || block.alt || ''
const caption = block.attrs?.caption || block.caption || ''
return `<figure><img src="${src}" alt="${alt}" />${caption ? `<figcaption>${caption}</figcaption>` : ''}</figure>`
case 'hr':
case 'horizontalRule':
return '<hr>'
default:
// For simple text content
const text = block.content || block.text || ''
if (text) {
return `<p>${text}</p>`
}
return ''
}
}
return blocks.map(renderBlock).join('')
}
// Extract text content from Edra JSON for excerpt
export const getContentExcerpt = (content: any, maxLength = 200): string => {
if (!content || !content.content) return ''
const extractText = (node: any): string => {
if (node.text) return node.text
if (node.content && Array.isArray(node.content)) {
return node.content.map(extractText).join(' ')
}
return ''
}
const text = content.content.map(extractText).join(' ').trim()
if (text.length <= maxLength) return text
return text.substring(0, maxLength).trim() + '...'
}

8
src/lib/utils/date.ts Normal file
View file

@ -0,0 +1,8 @@
export function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}