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 Slideshow from './Slideshow.svelte'
|
||||||
import { formatDate } from '$lib/utils/date'
|
import { formatDate } from '$lib/utils/date'
|
||||||
import { renderEdraContent } from '$lib/utils/content'
|
import { renderEdraContent } from '$lib/utils/content'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
let { post }: { post: any } = $props()
|
let { post }: { post: any } = $props()
|
||||||
|
|
||||||
|
|
@ -12,9 +13,11 @@
|
||||||
<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">
|
||||||
<time class="post-date" datetime={post.publishedAt}>
|
<a href="/universe/{post.slug}" class="post-date-link">
|
||||||
{formatDate(post.publishedAt)}
|
<time class="post-date" datetime={post.publishedAt}>
|
||||||
</time>
|
{formatDate(post.publishedAt)}
|
||||||
|
</time>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if post.title}
|
{#if post.title}
|
||||||
|
|
@ -78,13 +81,27 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<footer class="post-footer">
|
<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>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.post-content {
|
.post-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
max-width: 784px;
|
max-width: 784px;
|
||||||
|
gap: $unit-3x;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 $unit-3x;
|
padding: 0 $unit-3x;
|
||||||
|
|
||||||
|
|
@ -108,20 +125,33 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-header {
|
.post-header {
|
||||||
margin-bottom: $unit-5x;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-meta {
|
.post-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $unit-2x;
|
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 {
|
.post-date {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-title {
|
.post-title {
|
||||||
|
|
@ -204,6 +234,10 @@
|
||||||
margin: 0 0 $unit-3x;
|
margin: 0 0 $unit-3x;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
:global(ul),
|
:global(ul),
|
||||||
:global(ol) {
|
:global(ol) {
|
||||||
margin: 0 0 $unit-3x;
|
margin: 0 0 $unit-3x;
|
||||||
|
|
@ -293,21 +327,38 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-footer {
|
.post-footer {
|
||||||
margin-top: $unit-6x;
|
padding-bottom: $unit-2x;
|
||||||
padding-top: $unit-4x;
|
|
||||||
border-top: 1px solid $grey-85;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link {
|
.back-button {
|
||||||
color: $red-60;
|
color: $red-60;
|
||||||
text-decoration: none;
|
background-color: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
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 {
|
&:hover:not(:disabled) {
|
||||||
text-decoration: underline;
|
.back-arrow {
|
||||||
text-decoration-style: wavy;
|
transform: translateX(-3px);
|
||||||
text-underline-offset: 0.15em;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&: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">
|
<UniverseCard item={post} type="post">
|
||||||
{#if post.title}
|
{#if post.title}
|
||||||
<h2 class="card-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>
|
</h2>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
|
|
||||||
{#if post.postType === 'essay' && isContentTruncated}
|
{#if post.postType === 'essay' && isContentTruncated}
|
||||||
<p>
|
<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>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
export const renderEdraContent = (content: any): string => {
|
export const renderEdraContent = (content: any): string => {
|
||||||
if (!content) return ''
|
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
|
// Handle both { blocks: [...] } and { content: [...] } formats
|
||||||
const blocks = content.blocks || content.content || []
|
const blocks = content.blocks || content.content || []
|
||||||
if (!Array.isArray(blocks)) return ''
|
if (!Array.isArray(blocks)) return ''
|
||||||
|
|
@ -79,9 +84,152 @@ export const renderEdraContent = (content: any): string => {
|
||||||
return blocks.map(renderBlock).join('')
|
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
|
// Extract text content from Edra JSON for excerpt
|
||||||
export const getContentExcerpt = (content: any, maxLength = 200): string => {
|
export const getContentExcerpt = (content: any, maxLength = 200): string => {
|
||||||
if (!content) return ''
|
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
|
// Handle both { blocks: [...] } and { content: [...] } formats
|
||||||
const blocks = content.blocks || content.content || []
|
const blocks = content.blocks || content.content || []
|
||||||
|
|
@ -105,3 +253,22 @@ export const getContentExcerpt = (content: any, maxLength = 200): string => {
|
||||||
if (text.length <= maxLength) return text
|
if (text.length <= maxLength) return text
|
||||||
return text.substring(0, maxLength).trim() + '...'
|
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])
|
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 () => {
|
onMount(async () => {
|
||||||
// Wait a tick to ensure page params are loaded
|
// Wait a tick to ensure page params are loaded
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
@ -71,7 +244,16 @@
|
||||||
status = post.status || 'draft'
|
status = post.status || 'draft'
|
||||||
slug = post.slug || ''
|
slug = post.slug || ''
|
||||||
excerpt = post.excerpt || ''
|
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 || []
|
tags = post.tags || []
|
||||||
} else {
|
} else {
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
|
|
@ -109,12 +291,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
saving = true
|
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 = {
|
const postData = {
|
||||||
title: config?.showTitle ? title : null,
|
title: config?.showTitle ? title : null,
|
||||||
slug,
|
slug,
|
||||||
type: postType,
|
type: postType,
|
||||||
status: publishStatus || status,
|
status: publishStatus || status,
|
||||||
content: config?.showContent ? content : null,
|
content: config?.showContent ? saveContent : null,
|
||||||
excerpt: postType === 'essay' ? excerpt : undefined,
|
excerpt: postType === 'essay' ? excerpt : undefined,
|
||||||
link_url: undefined,
|
link_url: undefined,
|
||||||
linkDescription: undefined,
|
linkDescription: undefined,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import Page from '$components/Page.svelte'
|
import Page from '$components/Page.svelte'
|
||||||
import DynamicPostContent from '$components/DynamicPostContent.svelte'
|
import DynamicPostContent from '$components/DynamicPostContent.svelte'
|
||||||
import { getContentExcerpt } from '$lib/utils/content'
|
import { getContentExcerpt } from '$lib/utils/content'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props()
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
@ -10,24 +11,20 @@
|
||||||
const error = $derived(data.error)
|
const error = $derived(data.error)
|
||||||
const pageTitle = $derived(post?.title || 'Post')
|
const pageTitle = $derived(post?.title || 'Post')
|
||||||
const description = $derived(
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
{#if post}
|
{#if post}
|
||||||
<title>{pageTitle} - jedmund</title>
|
<title>{pageTitle} - jedmund</title>
|
||||||
<meta
|
<meta name="description" content={description} />
|
||||||
name="description"
|
|
||||||
content={description}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Open Graph meta tags -->
|
<!-- Open Graph meta tags -->
|
||||||
<meta property="og:title" content={pageTitle} />
|
<meta property="og:title" content={pageTitle} />
|
||||||
<meta
|
<meta property="og:description" content={description} />
|
||||||
property="og:description"
|
|
||||||
content={description}
|
|
||||||
/>
|
|
||||||
<meta property="og:type" content="article" />
|
<meta property="og:type" content="article" />
|
||||||
{#if post.attachments && post.attachments.length > 0}
|
{#if post.attachments && post.attachments.length > 0}
|
||||||
<meta property="og:image" content={post.attachments[0].url} />
|
<meta property="og:image" content={post.attachments[0].url} />
|
||||||
|
|
@ -47,7 +44,18 @@
|
||||||
<div class="error-content">
|
<div class="error-content">
|
||||||
<h1>Post Not Found</h1>
|
<h1>Post Not Found</h1>
|
||||||
<p>{error || "The post you're looking for doesn't exist."}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
@ -83,16 +91,35 @@
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link {
|
.back-button {
|
||||||
color: $red-60;
|
color: $red-60;
|
||||||
text-decoration: none;
|
background-color: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
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 {
|
&:hover:not(:disabled) {
|
||||||
text-decoration: underline;
|
.back-arrow {
|
||||||
text-decoration-style: wavy;
|
transform: translateX(-3px);
|
||||||
text-underline-offset: 0.15em;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&: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