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-10: #4d4d4d;
$grey-00: #333333; $grey-00: #333333;
$red-90: #ff9d8f;
$red-80: #ff6a54; $red-80: #ff6a54;
$red-60: #e33d3d; $red-60: #e33d3d;
$red-50: #d33; $red-50: #d33;

View file

@ -1,111 +1,17 @@
<script lang="ts"> <script lang="ts">
import LinkCard from './LinkCard.svelte' import LinkCard from './LinkCard.svelte'
import Slideshow from './Slideshow.svelte' import Slideshow from './Slideshow.svelte'
import { formatDate } from '$lib/utils/date'
import { renderEdraContent } from '$lib/utils/content'
let { post }: { post: any } = $props() 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) : '') const renderedContent = $derived(post.content ? renderEdraContent(post.content) : '')
</script> </script>
<article class="post-content {post.postType}"> <article class="post-content {post.postType}">
<header class="post-header"> <header class="post-header">
<div class="post-meta"> <div class="post-meta">
<span class="post-type-badge">
{getPostTypeLabel(post.postType)}
</span>
<time class="post-date" datetime={post.publishedAt}> <time class="post-date" datetime={post.publishedAt}>
{formatDate(post.publishedAt)} {formatDate(post.publishedAt)}
</time> </time>
@ -138,7 +44,7 @@
{/if} {/if}
</div> </div>
<Slideshow <Slideshow
items={post.album.photos.map(photo => ({ items={post.album.photos.map((photo) => ({
url: photo.url, url: photo.url,
thumbnailUrl: photo.thumbnailUrl, thumbnailUrl: photo.thumbnailUrl,
caption: photo.caption, caption: photo.caption,
@ -153,7 +59,7 @@
<div class="post-attachments"> <div class="post-attachments">
<h3>Photos</h3> <h3>Photos</h3>
<Slideshow <Slideshow
items={post.attachments.map(attachment => ({ items={post.attachments.map((attachment) => ({
url: attachment.url, url: attachment.url,
thumbnailUrl: attachment.thumbnailUrl, thumbnailUrl: attachment.thumbnailUrl,
caption: attachment.caption, caption: attachment.caption,
@ -216,17 +122,6 @@
margin-bottom: $unit-3x; 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 { .post-date {
font-size: 0.9rem; font-size: 0.9rem;
color: $grey-40; color: $grey-40;

View file

@ -57,7 +57,8 @@
const totalSlots = $derived( const totalSlots = $derived(
responsiveMaxThumbnails responsiveMaxThumbnails
? 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 // Convert items to image URLs for lightbox
@ -162,7 +163,7 @@
</div> </div>
{/if} {/if}
<Lightbox images={lightboxImages} bind:selectedIndex bind:isOpen={lightboxOpen} alt={alt} /> <Lightbox images={lightboxImages} bind:selectedIndex bind:isOpen={lightboxOpen} {alt} />
<style lang="scss"> <style lang="scss">
.image-container { .image-container {
@ -267,6 +268,18 @@
transform: scale(0.98); transform: scale(0.98);
} }
&:focus-visible {
outline: none;
&::before {
border-color: $red-90;
}
&::after {
border-color: $grey-100;
}
}
&.active { &.active {
&::before { &::before {
border-color: $red-60; border-color: $red-60;
@ -315,6 +328,18 @@
background: rgba(0, 0, 0, 0.7); 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 { img {

View file

@ -1,177 +1,79 @@
<script lang="ts"> <script lang="ts">
import UniverseIcon from '$icons/universe.svg' import UniverseCard from './UniverseCard.svelte'
import Slideshow from './Slideshow.svelte' import Slideshow from './Slideshow.svelte'
import type { UniverseItem } from '../../routes/api/universe/+server' import type { UniverseItem } from '../../routes/api/universe/+server'
let { album }: { album: UniverseItem } = $props() 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 // Convert photos to slideshow items
const slideshowItems = $derived( const slideshowItems = $derived(
album.photos && album.photos.length > 0 album.photos && album.photos.length > 0
? album.photos.map(photo => ({ ? album.photos.map((photo) => ({
url: photo.url, url: photo.url,
thumbnailUrl: photo.thumbnailUrl, thumbnailUrl: photo.thumbnailUrl,
caption: photo.caption, caption: photo.caption,
alt: photo.caption || album.title alt: photo.caption || album.title
})) }))
: album.coverPhoto : album.coverPhoto
? [{ ? [
url: album.coverPhoto.url, {
thumbnailUrl: album.coverPhoto.thumbnailUrl, url: album.coverPhoto.url,
caption: album.coverPhoto.caption, thumbnailUrl: album.coverPhoto.thumbnailUrl,
alt: album.coverPhoto.caption || album.title caption: album.coverPhoto.caption,
}] alt: album.coverPhoto.caption || album.title
}
]
: [] : []
) )
</script> </script>
<article class="universe-album-card"> <UniverseCard item={album} type="album">
<div class="card-content"> {#if slideshowItems.length > 0}
<div class="card-header"> <div class="album-slideshow">
<div class="album-type-badge">Album</div> <Slideshow
<time class="album-date" datetime={album.publishedAt}> items={slideshowItems}
{formatDate(album.publishedAt)} alt={album.title}
</time> aspectRatio="3/2"
showThumbnails={slideshowItems.length > 1}
maxThumbnails={6}
totalCount={album.photosCount}
showMoreLink="/photos/{album.slug}"
/>
</div> </div>
{/if}
{#if slideshowItems.length > 0} <div class="album-info">
<div class="album-slideshow"> <h2 class="card-title">
<Slideshow <a href="/photos/{album.slug}" class="card-title-link" onclick={(e) => e.preventDefault()} tabindex="-1">{album.title}</a>
items={slideshowItems} </h2>
alt={album.title}
aspectRatio="3/2" {#if album.description}
showThumbnails={slideshowItems.length > 1} <p class="album-description">{album.description}</p>
maxThumbnails={6}
totalCount={album.photosCount}
showMoreLink="/photos/{album.slug}"
/>
</div>
{/if} {/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> </div>
</article> </UniverseCard>
<style lang="scss"> <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 { .album-slideshow {
position: relative; position: relative;
width: 100%; width: 100%;
margin-bottom: $unit-3x; margin-bottom: $unit-2x;
} }
.album-info { .album-info {
margin-bottom: $unit-3x; margin-bottom: 0;
} }
.album-title { .card-title {
margin: 0 0 $unit-2x; margin: 0 0 $unit-2x;
font-size: 1.375rem; font-size: 1.375rem;
font-weight: 600; font-weight: 600;
line-height: 1.3; line-height: 1.3;
} }
.album-title-link { .card-title-link {
color: $grey-10;
text-decoration: none; text-decoration: none;
transition: all 0.2s ease; 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 { .album-description {
@ -184,32 +86,4 @@
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
overflow: hidden; 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 { .universe-feed {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $unit-4x; gap: $unit-3x;
} }
.empty-state { .empty-state {

View file

@ -1,161 +1,64 @@
<script lang="ts"> <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' import type { UniverseItem } from '../../routes/api/universe/+server'
let { post }: { post: UniverseItem } = $props() 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> </script>
<article class="universe-post-card"> <UniverseCard item={post} type="post">
<div class="card-content"> {#if post.title}
<div class="card-header"> <h2 class="card-title">
<div class="post-type-badge"> <a href="/universe/{post.slug}" class="card-title-link" onclick={(e) => e.preventDefault()} tabindex="-1">{post.title}</a>
{getPostTypeLabel(post.postType || 'post')} </h2>
</div> {/if}
<time class="post-date" datetime={post.publishedAt}>
{formatDate(post.publishedAt)}
</time>
</div>
{#if post.title} {#if post.linkUrl}
<h2 class="post-title"> <!-- Link post type -->
<a href="/universe/{post.slug}" class="post-title-link">{post.title}</a> <div class="link-preview">
</h2> <a href={post.linkUrl} target="_blank" rel="noopener noreferrer" class="link-url">
{/if} {post.linkUrl}
</a>
{#if post.linkUrl} {#if post.linkDescription}
<!-- Link post type --> <p class="link-description">{post.linkDescription}</p>
<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} {/if}
</div> </div>
{/if}
{#if post.attachments && Array.isArray(post.attachments) && post.attachments.length > 0} <div class="post-excerpt">
<div class="attachments"> {#if post.excerpt}
<div class="attachment-count"> <p>{post.excerpt}</p>
📎 {post.attachments.length} attachment{post.attachments.length > 1 ? 's' : ''} {:else if post.content}
</div> <p>{getContentExcerpt(post.content)}</p>
</div>
{/if} {/if}
<div class="card-footer">
<a href="/universe/{post.slug}" class="read-more"> Read more → </a>
<UniverseIcon class="universe-icon" />
</div>
</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"> <style lang="scss">
.universe-post-card { .card-title {
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 {
margin: 0 0 $unit-3x; margin: 0 0 $unit-3x;
font-size: 1.375rem; font-size: 1.375rem;
font-weight: 600; font-weight: 600;
line-height: 1.3; line-height: 1.3;
} }
.post-title-link { .card-title-link {
color: $grey-10;
text-decoration: none; text-decoration: none;
transition: all 0.2s ease; transition: all 0.2s ease;
&:hover {
color: $red-60;
text-decoration: underline;
text-decoration-style: wavy;
text-underline-offset: 0.15em;
}
} }
.link-preview { .link-preview {
@ -187,8 +90,6 @@
} }
.post-excerpt { .post-excerpt {
margin-bottom: $unit-3x;
p { p {
margin: 0; margin: 0;
color: $grey-20; 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 { .read-more {
color: $red-60; color: $red-60;
text-decoration: none; text-decoration: none;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
transition: all 0.2s ease; 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>

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'
})
}