Fix and style permalinks for posts
This commit is contained in:
parent
4a0137d036
commit
f07438ce92
5 changed files with 472 additions and 38 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() + '...'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue