Universe card styling
Fine tuning styles
This commit is contained in:
parent
46aa8f1155
commit
e029c6b61d
9 changed files with 394 additions and 459 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -137,8 +43,8 @@
|
||||||
<p class="album-description">{post.album.description}</p>
|
<p class="album-description">{post.album.description}</p>
|
||||||
{/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,
|
||||||
|
|
@ -152,8 +58,8 @@
|
||||||
<!-- Regular attachments -->
|
<!-- Regular attachments -->
|
||||||
<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;
|
||||||
|
|
@ -419,4 +314,4 @@
|
||||||
text-underline-offset: 0.15em;
|
text-underline-offset: 0.15em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -33,31 +33,32 @@
|
||||||
|
|
||||||
// Calculate columns based on breakpoints
|
// Calculate columns based on breakpoints
|
||||||
const columnsPerRow = $derived(windowWidth <= 400 ? 3 : windowWidth <= 600 ? 4 : 6)
|
const columnsPerRow = $derived(windowWidth <= 400 ? 3 : windowWidth <= 600 ? 4 : 6)
|
||||||
|
|
||||||
// Make maxThumbnails responsive - use fewer thumbnails on smaller screens
|
// Make maxThumbnails responsive - use fewer thumbnails on smaller screens
|
||||||
const responsiveMaxThumbnails = $derived(
|
const responsiveMaxThumbnails = $derived(
|
||||||
maxThumbnails ? (windowWidth <= 400 ? 3 : windowWidth <= 600 ? 4 : maxThumbnails) : undefined
|
maxThumbnails ? (windowWidth <= 400 ? 3 : windowWidth <= 600 ? 4 : maxThumbnails) : undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
const showMoreThumbnail = $derived(
|
const showMoreThumbnail = $derived(
|
||||||
responsiveMaxThumbnails && totalCount && totalCount > responsiveMaxThumbnails - 1
|
responsiveMaxThumbnails && totalCount && totalCount > responsiveMaxThumbnails - 1
|
||||||
)
|
)
|
||||||
|
|
||||||
// Determine how many thumbnails to show
|
// Determine how many thumbnails to show
|
||||||
const displayItems = $derived(
|
const displayItems = $derived(
|
||||||
!responsiveMaxThumbnails || !showMoreThumbnail
|
!responsiveMaxThumbnails || !showMoreThumbnail
|
||||||
? items
|
? items
|
||||||
: items.slice(0, responsiveMaxThumbnails - 1) // Show actual thumbnails, leave last slot for "+N"
|
: items.slice(0, responsiveMaxThumbnails - 1) // Show actual thumbnails, leave last slot for "+N"
|
||||||
)
|
)
|
||||||
|
|
||||||
const remainingCount = $derived(
|
const remainingCount = $derived(
|
||||||
showMoreThumbnail ? (totalCount || items.length) - (responsiveMaxThumbnails - 1) : 0
|
showMoreThumbnail ? (totalCount || items.length) - (responsiveMaxThumbnails - 1) : 0
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -105,16 +106,16 @@
|
||||||
<div class="slideshow">
|
<div class="slideshow">
|
||||||
<TiltCard>
|
<TiltCard>
|
||||||
<div class="main-image image-container" onclick={() => openLightbox()}>
|
<div class="main-image image-container" onclick={() => openLightbox()}>
|
||||||
<img
|
<img
|
||||||
src={items[selectedIndex].url}
|
src={items[selectedIndex].url}
|
||||||
alt={items[selectedIndex].alt || `${alt} ${selectedIndex + 1}`}
|
alt={items[selectedIndex].alt || `${alt} ${selectedIndex + 1}`}
|
||||||
/>
|
/>
|
||||||
{#if items[selectedIndex].caption}
|
{#if items[selectedIndex].caption}
|
||||||
<div class="image-caption">{items[selectedIndex].caption}</div>
|
<div class="image-caption">{items[selectedIndex].caption}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</TiltCard>
|
</TiltCard>
|
||||||
|
|
||||||
{#if showThumbnails}
|
{#if showThumbnails}
|
||||||
<div class="thumbnails">
|
<div class="thumbnails">
|
||||||
{#each Array(totalSlots) as _, index}
|
{#each Array(totalSlots) as _, index}
|
||||||
|
|
@ -125,9 +126,9 @@
|
||||||
onclick={() => selectImage(index)}
|
onclick={() => selectImage(index)}
|
||||||
aria-label="View image {index + 1}"
|
aria-label="View image {index + 1}"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={displayItems[index].thumbnailUrl || displayItems[index].url}
|
src={displayItems[index].thumbnailUrl || displayItems[index].url}
|
||||||
alt="{displayItems[index].alt || alt} thumbnail {index + 1}"
|
alt="{displayItems[index].alt || alt} thumbnail {index + 1}"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{:else if index === displayItems.length && showMoreThumbnail}
|
{:else if index === displayItems.length && showMoreThumbnail}
|
||||||
|
|
@ -137,14 +138,14 @@
|
||||||
aria-label="View all {totalCount || items.length} photos"
|
aria-label="View all {totalCount || items.length} photos"
|
||||||
>
|
>
|
||||||
{#if items[displayItems.length]}
|
{#if items[displayItems.length]}
|
||||||
<img
|
<img
|
||||||
src={items[displayItems.length].thumbnailUrl || items[displayItems.length].url}
|
src={items[displayItems.length].thumbnailUrl || items[displayItems.length].url}
|
||||||
alt="View all photos"
|
alt="View all photos"
|
||||||
class="blurred-bg"
|
class="blurred-bg"
|
||||||
/>
|
/>
|
||||||
{:else if items[items.length - 1]}
|
{:else if items[items.length - 1]}
|
||||||
<img
|
<img
|
||||||
src={items[items.length - 1].thumbnailUrl || items[items.length - 1].url}
|
src={items[items.length - 1].thumbnailUrl || items[items.length - 1].url}
|
||||||
alt="View all photos"
|
alt="View all photos"
|
||||||
class="blurred-bg"
|
class="blurred-bg"
|
||||||
/>
|
/>
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -325,4 +350,4 @@
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
.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>
|
|
||||||
162
src/lib/components/UniverseCard.svelte
Normal file
162
src/lib/components/UniverseCard.svelte
Normal 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>
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
89
src/lib/utils/content.ts
Normal 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
8
src/lib/utils/date.ts
Normal 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'
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue