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-00: #333333;
|
||||
|
||||
$red-90: #ff9d8f;
|
||||
$red-80: #ff6a54;
|
||||
$red-60: #e33d3d;
|
||||
$red-50: #d33;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
gap: $unit-3x;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
|
|
|
|||
|
|
@ -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
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