Fix and style permalinks for posts

This commit is contained in:
Justin Edmund 2025-06-02 13:42:18 -07:00
parent 4a0137d036
commit f07438ce92
5 changed files with 472 additions and 38 deletions

View file

@ -3,6 +3,7 @@
import Slideshow from './Slideshow.svelte'
import { formatDate } from '$lib/utils/date'
import { renderEdraContent } from '$lib/utils/content'
import { goto } from '$app/navigation'
let { post }: { post: any } = $props()
@ -12,9 +13,11 @@
<article class="post-content {post.postType}">
<header class="post-header">
<div class="post-meta">
<time class="post-date" datetime={post.publishedAt}>
{formatDate(post.publishedAt)}
</time>
<a href="/universe/{post.slug}" class="post-date-link">
<time class="post-date" datetime={post.publishedAt}>
{formatDate(post.publishedAt)}
</time>
</a>
</div>
{#if post.title}
@ -78,13 +81,27 @@
{/if}
<footer class="post-footer">
<a href="/universe" class="back-link">← Back to Universe</a>
<button onclick={() => goto('/universe')} class="back-button">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="back-arrow">
<path
d="M15 8H3.5M3.5 8L8 3.5M3.5 8L8 12.5"
stroke="currentColor"
stroke-width="2.25"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Back to Universe
</button>
</footer>
</article>
<style lang="scss">
.post-content {
display: flex;
flex-direction: column;
max-width: 784px;
gap: $unit-3x;
margin: 0 auto;
padding: 0 $unit-3x;
@ -108,20 +125,33 @@
}
.post-header {
margin-bottom: $unit-5x;
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.post-meta {
display: flex;
align-items: center;
gap: $unit-2x;
margin-bottom: $unit-3x;
}
.post-date-link {
text-decoration: none;
transition: color 0.2s ease;
&:hover {
.post-date {
color: $red-60;
}
}
}
.post-date {
font-size: 0.9rem;
color: $grey-40;
font-weight: 400;
transition: color 0.2s ease;
}
.post-title {
@ -204,6 +234,10 @@
margin: 0 0 $unit-3x;
}
:global(p:last-child) {
margin-bottom: 0;
}
:global(ul),
:global(ol) {
margin: 0 0 $unit-3x;
@ -293,21 +327,38 @@
}
.post-footer {
margin-top: $unit-6x;
padding-top: $unit-4x;
border-top: 1px solid $grey-85;
padding-bottom: $unit-2x;
}
.back-link {
.back-button {
color: $red-60;
text-decoration: none;
background-color: transparent;
border: 1px solid transparent;
font: inherit;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: $unit;
border-radius: 24px;
outline: none;
&:hover {
text-decoration: underline;
text-decoration-style: wavy;
text-underline-offset: 0.15em;
&:hover:not(:disabled) {
.back-arrow {
transform: translateX(-3px);
}
}
&:focus-visible {
box-shadow: 0 0 0 3px rgba($red-60, 0.25);
}
.back-arrow {
flex-shrink: 0;
transition: transform 0.2s ease;
margin-left: -$unit-half;
}
}
</style>
</style>

View file

@ -19,7 +19,7 @@
<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>
<a href="/universe/{post.slug}" class="card-title-link" tabindex="-1">{post.title}</a>
</h2>
{/if}
@ -43,7 +43,7 @@
{#if post.postType === 'essay' && isContentTruncated}
<p>
<a href="/universe/{post.slug}" class="read-more" onclick={(e) => e.preventDefault()} tabindex="-1">Continue reading</a>
<a href="/universe/{post.slug}" class="read-more" tabindex="-1">Continue reading</a>
</p>
{/if}

View file

@ -2,6 +2,11 @@
export const renderEdraContent = (content: any): string => {
if (!content) return ''
// Handle Tiptap format first (has type: 'doc')
if (content.type === 'doc' && content.content) {
return renderTiptapContent(content)
}
// Handle both { blocks: [...] } and { content: [...] } formats
const blocks = content.blocks || content.content || []
if (!Array.isArray(blocks)) return ''
@ -79,9 +84,152 @@ export const renderEdraContent = (content: any): string => {
return blocks.map(renderBlock).join('')
}
// Render Tiptap JSON content to HTML
function renderTiptapContent(doc: any): string {
if (!doc || !doc.content) return ''
const renderNode = (node: any): string => {
switch (node.type) {
case 'paragraph': {
const content = renderInlineContent(node.content || [])
if (!content) return '<p><br></p>'
return `<p>${content}</p>`
}
case 'heading': {
const level = node.attrs?.level || 1
const content = renderInlineContent(node.content || [])
return `<h${level}>${content}</h${level}>`
}
case 'bulletList': {
const items = (node.content || [])
.map((item: any) => {
const itemContent = item.content?.map(renderNode).join('') || ''
return `<li>${itemContent}</li>`
})
.join('')
return `<ul>${items}</ul>`
}
case 'orderedList': {
const items = (node.content || [])
.map((item: any) => {
const itemContent = item.content?.map(renderNode).join('') || ''
return `<li>${itemContent}</li>`
})
.join('')
return `<ol>${items}</ol>`
}
case 'listItem': {
// List items are handled by their parent list
return node.content?.map(renderNode).join('') || ''
}
case 'blockquote': {
const content = node.content?.map(renderNode).join('') || ''
return `<blockquote>${content}</blockquote>`
}
case 'codeBlock': {
const language = node.attrs?.language || ''
const content = node.content?.[0]?.text || ''
return `<pre><code class="language-${language}">${escapeHtml(content)}</code></pre>`
}
case 'image': {
const src = node.attrs?.src || ''
const alt = node.attrs?.alt || ''
const title = node.attrs?.title || ''
const width = node.attrs?.width
const height = node.attrs?.height
const widthAttr = width ? ` width="${width}"` : ''
const heightAttr = height ? ` height="${height}"` : ''
return `<figure><img src="${src}" alt="${alt}"${widthAttr}${heightAttr} />${title ? `<figcaption>${title}</figcaption>` : ''}</figure>`
}
case 'horizontalRule': {
return '<hr>'
}
case 'hardBreak': {
return '<br>'
}
default: {
// For any unknown block types, try to render their content
if (node.content) {
return node.content.map(renderNode).join('')
}
return ''
}
}
}
// Render inline content (text nodes with marks)
const renderInlineContent = (content: any[]): string => {
return content.map((node: any) => {
if (node.type === 'text') {
let text = escapeHtml(node.text || '')
// Apply marks (bold, italic, etc.)
if (node.marks) {
node.marks.forEach((mark: any) => {
switch (mark.type) {
case 'bold':
text = `<strong>${text}</strong>`
break
case 'italic':
text = `<em>${text}</em>`
break
case 'underline':
text = `<u>${text}</u>`
break
case 'strike':
text = `<s>${text}</s>`
break
case 'code':
text = `<code>${text}</code>`
break
case 'link':
const href = mark.attrs?.href || '#'
const target = mark.attrs?.target || '_blank'
text = `<a href="${href}" target="${target}" rel="noopener noreferrer">${text}</a>`
break
case 'highlight':
text = `<mark>${text}</mark>`
break
}
})
}
return text
}
// Handle other inline nodes
return renderNode(node)
}).join('')
}
// Helper to escape HTML
const escapeHtml = (str: string): string => {
const div = document.createElement('div')
div.textContent = str
return div.innerHTML
}
return doc.content.map(renderNode).join('')
}
// Extract text content from Edra JSON for excerpt
export const getContentExcerpt = (content: any, maxLength = 200): string => {
if (!content) return ''
// Handle Tiptap format first (has type: 'doc')
if (content.type === 'doc' && content.content) {
return extractTiptapText(content, maxLength)
}
// Handle both { blocks: [...] } and { content: [...] } formats
const blocks = content.blocks || content.content || []
@ -105,3 +253,22 @@ export const getContentExcerpt = (content: any, maxLength = 200): string => {
if (text.length <= maxLength) return text
return text.substring(0, maxLength).trim() + '...'
}
// Extract text from Tiptap content
function extractTiptapText(doc: any, maxLength: number): string {
const extractFromNode = (node: any): string => {
if (node.type === 'text') {
return node.text || ''
}
if (node.content && Array.isArray(node.content)) {
return node.content.map(extractFromNode).join(' ')
}
return ''
}
const text = doc.content.map(extractFromNode).join(' ').trim()
if (text.length <= maxLength) return text
return text.substring(0, maxLength).trim() + '...'
}

View file

@ -35,6 +35,179 @@
let config = $derived(postTypeConfig[postType])
// Convert blocks format (from database) to Tiptap format
function convertBlocksToTiptap(blocksContent: any): JSONContent {
if (!blocksContent || !blocksContent.blocks) {
return { type: 'doc', content: [] }
}
const tiptapContent = blocksContent.blocks.map((block: any) => {
switch (block.type) {
case 'paragraph':
return {
type: 'paragraph',
content: block.content ? [{ type: 'text', text: block.content }] : []
}
case 'heading':
return {
type: 'heading',
attrs: { level: block.level || 1 },
content: block.content ? [{ type: 'text', text: block.content }] : []
}
case 'bulletList':
case 'ul':
return {
type: 'bulletList',
content: (block.content || []).map((item: any) => ({
type: 'listItem',
content: [{
type: 'paragraph',
content: [{ type: 'text', text: item.content || item }]
}]
}))
}
case 'orderedList':
case 'ol':
return {
type: 'orderedList',
content: (block.content || []).map((item: any) => ({
type: 'listItem',
content: [{
type: 'paragraph',
content: [{ type: 'text', text: item.content || item }]
}]
}))
}
case 'blockquote':
return {
type: 'blockquote',
content: [{
type: 'paragraph',
content: [{ type: 'text', text: block.content || '' }]
}]
}
case 'codeBlock':
case 'code':
return {
type: 'codeBlock',
attrs: { language: block.language || '' },
content: [{ type: 'text', text: block.content || '' }]
}
case 'image':
return {
type: 'image',
attrs: {
src: block.src || '',
alt: block.alt || '',
title: block.caption || ''
}
}
case 'hr':
case 'horizontalRule':
return { type: 'horizontalRule' }
default:
// Default to paragraph for unknown types
return {
type: 'paragraph',
content: block.content ? [{ type: 'text', text: block.content }] : []
}
}
})
return {
type: 'doc',
content: tiptapContent
}
}
// Convert Tiptap format back to blocks format for saving
function convertTiptapToBlocks(tiptapContent: JSONContent): any {
if (!tiptapContent || !tiptapContent.content) {
return { blocks: [] }
}
const blocks = tiptapContent.content.map((node: any) => {
switch (node.type) {
case 'paragraph':
const text = extractTextFromNode(node)
return text ? { type: 'paragraph', content: text } : null
case 'heading':
return {
type: 'heading',
level: node.attrs?.level || 1,
content: extractTextFromNode(node)
}
case 'bulletList':
return {
type: 'bulletList',
content: node.content?.map((item: any) => {
const itemText = extractTextFromNode(item.content?.[0])
return itemText
}).filter(Boolean) || []
}
case 'orderedList':
return {
type: 'orderedList',
content: node.content?.map((item: any) => {
const itemText = extractTextFromNode(item.content?.[0])
return itemText
}).filter(Boolean) || []
}
case 'blockquote':
return {
type: 'blockquote',
content: extractTextFromNode(node.content?.[0])
}
case 'codeBlock':
return {
type: 'codeBlock',
language: node.attrs?.language || '',
content: node.content?.[0]?.text || ''
}
case 'image':
return {
type: 'image',
src: node.attrs?.src || '',
alt: node.attrs?.alt || '',
caption: node.attrs?.title || ''
}
case 'horizontalRule':
return { type: 'hr' }
default:
// Skip unknown types
return null
}
}).filter(Boolean)
return { blocks }
}
// Helper function to extract text from a node
function extractTextFromNode(node: any): string {
if (!node) return ''
if (node.text) return node.text
if (node.content && Array.isArray(node.content)) {
return node.content.map((n: any) => extractTextFromNode(n)).join('')
}
return ''
}
onMount(async () => {
// Wait a tick to ensure page params are loaded
await new Promise((resolve) => setTimeout(resolve, 0))
@ -71,7 +244,16 @@
status = post.status || 'draft'
slug = post.slug || ''
excerpt = post.excerpt || ''
content = post.content || { type: 'doc', content: [] }
// Convert blocks format to Tiptap format if needed
if (post.content && post.content.blocks) {
content = convertBlocksToTiptap(post.content)
} else if (post.content && post.content.type === 'doc') {
content = post.content
} else {
content = { type: 'doc', content: [] }
}
tags = post.tags || []
} else {
if (response.status === 404) {
@ -109,12 +291,19 @@
}
saving = true
// Convert content to blocks format if it's in Tiptap format
let saveContent = content
if (config?.showContent && content && content.type === 'doc') {
saveContent = convertTiptapToBlocks(content)
}
const postData = {
title: config?.showTitle ? title : null,
slug,
type: postType,
status: publishStatus || status,
content: config?.showContent ? content : null,
content: config?.showContent ? saveContent : null,
excerpt: postType === 'essay' ? excerpt : undefined,
link_url: undefined,
linkDescription: undefined,

View file

@ -2,6 +2,7 @@
import Page from '$components/Page.svelte'
import DynamicPostContent from '$components/DynamicPostContent.svelte'
import { getContentExcerpt } from '$lib/utils/content'
import { goto } from '$app/navigation'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
@ -10,24 +11,20 @@
const error = $derived(data.error)
const pageTitle = $derived(post?.title || 'Post')
const description = $derived(
post?.content ? getContentExcerpt(post.content, 160) : `${post?.postType === 'essay' ? 'Essay' : 'Post'} by jedmund`
post?.content
? getContentExcerpt(post.content, 160)
: `${post?.postType === 'essay' ? 'Essay' : 'Post'} by jedmund`
)
</script>
<svelte:head>
{#if post}
<title>{pageTitle} - jedmund</title>
<meta
name="description"
content={description}
/>
<meta name="description" content={description} />
<!-- Open Graph meta tags -->
<meta property="og:title" content={pageTitle} />
<meta
property="og:description"
content={description}
/>
<meta property="og:description" content={description} />
<meta property="og:type" content="article" />
{#if post.attachments && post.attachments.length > 0}
<meta property="og:image" content={post.attachments[0].url} />
@ -47,7 +44,18 @@
<div class="error-content">
<h1>Post Not Found</h1>
<p>{error || "The post you're looking for doesn't exist."}</p>
<a href="/universe" class="back-link">← Back to Universe</a>
<button onclick={() => goto('/universe')} class="back-button">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="back-arrow">
<path
d="M15 8H3.5M3.5 8L8 3.5M3.5 8L8 12.5"
stroke="currentColor"
stroke-width="2.25"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Back to Universe
</button>
</div>
</div>
</Page>
@ -83,16 +91,35 @@
line-height: 1.5;
}
.back-link {
.back-button {
color: $red-60;
text-decoration: none;
background-color: transparent;
border: 1px solid transparent;
font: inherit;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: $unit;
border-radius: 24px;
outline: none;
&:hover {
text-decoration: underline;
text-decoration-style: wavy;
text-underline-offset: 0.15em;
&:hover:not(:disabled) {
.back-arrow {
transform: translateX(-3px);
}
}
&:focus-visible {
box-shadow: 0 0 0 3px rgba($red-60, 0.25);
}
.back-arrow {
flex-shrink: 0;
transition: transform 0.2s ease;
margin-left: -$unit-half;
}
}
}