Add link posts, fix layout

This commit is contained in:
Justin Edmund 2025-05-26 12:34:59 -07:00
parent 4a24fbd3b7
commit 49bde18f27
17 changed files with 469 additions and 19 deletions

View file

@ -16,8 +16,7 @@
.site-header {
display: flex;
justify-content: center;
padding: $unit-4x 0;
margin-bottom: $unit-2x;
padding: $unit-5x 0;
}
.header-content {

View file

@ -0,0 +1,248 @@
<script lang="ts">
import { onMount } from 'svelte'
import type { Post } from '$lib/posts'
let {
link
}: {
link: Post['link']
} = $props()
let metadata = $state<{
url: string
title?: string
description?: string
image?: string
favicon?: string
siteName?: string
} | null>(null)
let loading = $state(true)
let error = $state(false)
const getDomain = (url: string) => {
try {
const urlObj = new URL(url)
return urlObj.hostname.replace('www.', '')
} catch {
return ''
}
}
const handleClick = () => {
const url = typeof link === 'string' ? link : link?.url
if (url) {
window.open(url, '_blank', 'noopener,noreferrer')
}
}
onMount(async () => {
// If link is just a string URL, fetch metadata
if (typeof link === 'string') {
try {
const response = await fetch(`/api/og-metadata?url=${encodeURIComponent(link)}`)
if (response.ok) {
metadata = await response.json()
} else {
error = true
}
} catch (err) {
console.error('Failed to fetch metadata:', err)
error = true
}
} else if (link) {
// Use provided metadata
metadata = link
}
loading = false
})
</script>
{#if loading}
<div class="link-card loading">
<div class="link-content">
<div class="skeleton skeleton-meta"></div>
<div class="skeleton skeleton-title"></div>
<div class="skeleton skeleton-description"></div>
</div>
</div>
{:else if error}
<button class="link-card error" onclick={handleClick}>
<div class="link-content">
<p class="error-message">Unable to load preview</p>
<p class="link-url">{typeof link === 'string' ? link : link?.url}</p>
</div>
</button>
{:else if metadata}
<button class="link-card" onclick={handleClick}>
{#if metadata.image}
<div class="link-image">
<img src={metadata.image} alt={metadata.title || 'Link preview'} />
</div>
{/if}
<div class="link-content">
<div class="link-meta">
{#if metadata.favicon}
<img src={metadata.favicon} alt="" class="favicon" />
{/if}
<span class="domain">{metadata.siteName || getDomain(metadata.url)}</span>
</div>
{#if metadata.title}
<h3 class="link-title">{metadata.title}</h3>
{/if}
{#if metadata.description}
<p class="link-description">{metadata.description}</p>
{/if}
</div>
</button>
{/if}
<style lang="scss">
.link-card {
display: flex;
flex-direction: column;
background: $grey-90;
border-radius: $image-corner-radius;
overflow: hidden;
border: 1px solid $grey-80;
padding: 0;
width: 100%;
text-align: left;
cursor: pointer;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
transition: border-color 0.2s ease;
&:hover {
border-color: $grey-50;
}
&:focus {
outline: 2px solid $red-60;
outline-offset: 2px;
}
// Loading state
&.loading {
cursor: default;
&:hover {
border-color: $grey-80;
}
}
// Error state
&.error {
.link-content {
text-align: center;
}
}
}
.link-image {
width: 100%;
aspect-ratio: 2 / 1;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.link-content {
padding: $unit-3x;
display: flex;
flex-direction: column;
gap: $unit;
}
.link-meta {
display: flex;
align-items: center;
gap: $unit;
font-size: 0.875rem;
color: $grey-40;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
.favicon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.domain {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: $grey-00;
line-height: 1.3;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.link-description {
margin: 0;
font-size: 0.875rem;
color: $grey-40;
line-height: 1.4;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.skeleton {
background: $grey-80;
border-radius: 4px;
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-meta {
height: 1rem;
width: 40%;
}
.skeleton-title {
height: 1.3rem;
width: 85%;
}
.skeleton-description {
height: 3rem;
width: 100%;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.error-message {
margin: 0 0 $unit;
color: $grey-40;
font-size: 0.875rem;
}
.link-url {
margin: 0;
color: $red-60;
font-size: 0.875rem;
word-break: break-all;
}
</style>

View file

@ -22,6 +22,10 @@
padding: $unit-5x;
max-width: 784px;
&:first-child {
margin-top: 0;
}
&.no-horizontal-padding {
padding-left: 0;
padding-right: 0;

View file

@ -1,6 +1,7 @@
<script lang="ts">
import type { Post } from '$lib/posts'
import ImagePost from './ImagePost.svelte'
import LinkCard from './LinkCard.svelte'
let { post }: { post: Post } = $props()
@ -30,12 +31,18 @@
</div>
{/if}
{#if post.type === 'link' && post.link}
<div class="post-link-preview">
<LinkCard link={post.link} />
</div>
{/if}
<div class="post-body">
{@html post.content}
</div>
<footer class="post-footer">
<a href="/blog" class="back-link">← Back to all posts</a>
<a href="/universe" class="back-link">← Back to all posts</a>
</footer>
</article>
@ -55,6 +62,13 @@
margin-bottom: $unit-4x;
}
}
&.link {
.post-link-preview {
margin-bottom: $unit-4x;
max-width: 600px;
}
}
}
.post-header {

View file

@ -1,6 +1,8 @@
<script lang="ts">
import type { Post } from '$lib/posts'
import ImagePost from './ImagePost.svelte'
import LinkCard from './LinkCard.svelte'
import UniverseIcon from '$icons/universe.svg'
let { post }: { post: Post } = $props()
@ -18,7 +20,7 @@
<div class="post-content">
{#if post.title}
<h2 class="post-title">
<a href="/blog/{post.slug}" class="post-title-link">{post.title}</a>
<a href="/universe/{post.slug}" class="post-title-link">{post.title}</a>
</h2>
{/if}
{#if post.type === 'image' && post.images}
@ -26,21 +28,30 @@
<ImagePost images={post.images} alt={post.title || 'Post image'} />
</div>
{/if}
{#if post.type === 'link' && post.link}
<div class="post-link">
<LinkCard link={post.link} />
</div>
{/if}
<div class="post-text">
{#if post.excerpt}
<p class="post-excerpt">{post.excerpt}</p>
{/if}
<a href="/blog/{post.slug}" class="post-date-link">
<time class="post-date" datetime={post.date}>
{formatDate(post.date)}
</time>
</a>
<div class="post-footer">
<a href="/universe/{post.slug}" class="post-date-link">
<time class="post-date" datetime={post.date}>
{formatDate(post.date)}
</time>
</a>
<UniverseIcon class="universe-icon" />
</div>
</div>
</div>
</article>
<style lang="scss">
.post-item {
width: 100%;
max-width: 700px;
margin: 0 auto;
@ -49,12 +60,18 @@
font-size: 1rem;
}
}
&.image {
.post-image {
margin-bottom: $unit-2x;
}
}
&.link {
.post-link {
margin-bottom: $unit-2x;
}
}
}
.post-content {
@ -62,7 +79,7 @@
background: $grey-100;
border-radius: $card-corner-radius;
}
.post-text {
display: flex;
flex-direction: column;
@ -74,12 +91,12 @@
font-size: 1.2rem;
font-weight: 600;
}
.post-title-link {
color: $red-60;
text-decoration: none;
transition: all 0.2s ease;
&:hover {
text-decoration: underline;
text-decoration-style: wavy;
@ -97,11 +114,11 @@
-webkit-line-clamp: 3;
overflow: hidden;
}
.post-date-link {
text-decoration: none;
transition: all 0.2s ease;
&:hover {
.post-date {
color: $red-60;
@ -119,4 +136,16 @@
font-weight: 400;
transition: all 0.2s ease;
}
.post-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
:global(.universe-icon) {
width: 16px;
height: 16px;
fill: $grey-40;
}
</style>

View file

@ -11,7 +11,7 @@
<nav class="segmented-controller">
<Pill icon={WorkIcon} text="Work" href="/" active={currentPath === '/'} variant="work" />
<Pill icon={LabsIcon} text="Labs" href="#" active={false} variant="default" />
<Pill icon={UniverseIcon} text="Universe" href="/blog" active={currentPath.startsWith('/blog')} variant="universe" />
<Pill icon={UniverseIcon} text="Universe" href="/universe" active={currentPath.startsWith('/universe')} variant="universe" />
</nav>
<style lang="scss">

View file

@ -5,13 +5,21 @@ import { marked } from 'marked'
export interface Post {
title?: string
type: 'note' | 'article' | 'image'
type: 'note' | 'article' | 'image' | 'link'
date: string
slug: string
published: boolean
content: string
excerpt?: string
images?: string[]
link?: {
url: string
title?: string
description?: string
image?: string
favicon?: string
siteName?: string
} | string
}
const postsDirectory = path.join(process.cwd(), 'src/lib/posts')

View file

@ -0,0 +1,9 @@
---
type: "link"
date: "2024-01-22T09:00:00Z"
slug: "auto-metadata-link"
published: true
link: "https://github.com/sveltejs/kit"
---
Check out the SvelteKit repository - the framework that powers this blog!

View file

@ -0,0 +1,14 @@
---
title: "Interesting Read"
type: "link"
date: "2024-01-20T10:00:00Z"
slug: "interesting-article"
published: true
link:
url: "https://example.com/article"
title: "The Future of Web Development"
description: "An in-depth look at emerging trends and technologies shaping the future of web development."
siteName: "Example Blog"
---
This article provides great insights into where web development is heading. The discussion about WebAssembly and edge computing is particularly fascinating.

View file

@ -0,0 +1,9 @@
---
type: 'link'
date: '2024-01-21T14:00:00Z'
slug: 'typographica-announcement'
published: true
link: 'https://typographica.org/on-typography/now-open-the-typographica-library/'
---
Excited about the new Typographica release! The performance improvements and enhanced TypeScript support are game-changers for building modern web applications.

View file

@ -29,6 +29,11 @@ user-scalable=no"
font-size: 16px;
width: 100%;
}
:global(body) {
margin: 0;
padding: 0;
}
@include breakpoint('phone') {
:global(html) {

View file

@ -46,7 +46,7 @@
> in 2011 with a Bachelors of Arts in Communication Design.
</p>
<p>
I occasionally write about design and development on my <a href="/blog">blog</a>.
I occasionally write about design and development on my <a href="/universe">blog</a>.
</p>
</section>
</Page>

View file

@ -0,0 +1,111 @@
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
export const GET: RequestHandler = async ({ url }) => {
const targetUrl = url.searchParams.get('url')
if (!targetUrl) {
return json({ error: 'URL parameter is required' }, { status: 400 })
}
try {
// Fetch the HTML content
const response = await fetch(targetUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; OpenGraphBot/1.0)'
}
})
if (!response.ok) {
throw new Error(`Failed to fetch URL: ${response.status}`)
}
const html = await response.text()
// Parse OpenGraph tags
const ogData = {
url: targetUrl,
title: extractMetaContent(html, 'og:title') || extractTitle(html),
description: extractMetaContent(html, 'og:description') || extractMetaContent(html, 'description'),
image: extractMetaContent(html, 'og:image'),
siteName: extractMetaContent(html, 'og:site_name'),
favicon: extractFavicon(targetUrl, html)
}
return json(ogData)
} catch (error) {
console.error('Error fetching OpenGraph data:', error)
return json({
error: 'Failed to fetch metadata',
url: targetUrl
}, { status: 500 })
}
}
function extractMetaContent(html: string, property: string): string | null {
// Try property attribute first (for og: tags)
const propertyMatch = html.match(
new RegExp(`<meta[^>]*property=["']${property}["'][^>]*content=["']([^"']+)["']`, 'i')
)
if (propertyMatch) return propertyMatch[1]
// Try name attribute (for description)
const nameMatch = html.match(
new RegExp(`<meta[^>]*name=["']${property}["'][^>]*content=["']([^"']+)["']`, 'i')
)
if (nameMatch) return nameMatch[1]
// Try content first pattern
const contentFirstMatch = html.match(
new RegExp(`<meta[^>]*content=["']([^"']+)["'][^>]*(?:property|name)=["']${property}["']`, 'i')
)
if (contentFirstMatch) return contentFirstMatch[1]
return null
}
function extractTitle(html: string): string | null {
const match = html.match(/<title[^>]*>([^<]+)<\/title>/i)
return match ? match[1].trim() : null
}
function extractFavicon(baseUrl: string, html: string): string | null {
// Special case for GitHub
if (baseUrl.includes('github.com')) {
return 'https://github.githubassets.com/favicons/favicon.svg'
}
// Try various favicon patterns
const patterns = [
/<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']/i,
/<link[^>]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']/i,
/<link[^>]*rel=["']apple-touch-icon["'][^>]*href=["']([^"']+)["']/i
]
for (const pattern of patterns) {
const match = html.match(pattern)
if (match) {
const favicon = match[1]
// Convert relative URLs to absolute
if (favicon.startsWith('http')) {
return favicon
} else if (favicon.startsWith('//')) {
return 'https:' + favicon
} else if (favicon.startsWith('/')) {
const url = new URL(baseUrl)
return `${url.protocol}//${url.host}${favicon}`
} else {
const url = new URL(baseUrl)
return `${url.protocol}//${url.host}/${favicon}`
}
}
}
// Default favicon path
try {
const url = new URL(baseUrl)
return `${url.protocol}//${url.host}/favicon.ico`
} catch {
return null
}
}

View file

@ -18,7 +18,7 @@
<style lang="scss">
.blog-container {
max-width: 784px;
margin: $unit-6x auto;
margin: 0 auto;
padding: 0 $unit-5x;
@include breakpoint('phone') {