Add link posts, fix layout
This commit is contained in:
parent
4a24fbd3b7
commit
49bde18f27
17 changed files with 469 additions and 19 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
248
src/lib/components/LinkCard.svelte
Normal file
248
src/lib/components/LinkCard.svelte
Normal 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>
|
||||
|
|
@ -22,6 +22,10 @@
|
|||
padding: $unit-5x;
|
||||
max-width: 784px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&.no-horizontal-padding {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
9
src/lib/posts/auto-metadata-link.md
Normal file
9
src/lib/posts/auto-metadata-link.md
Normal 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!
|
||||
14
src/lib/posts/interesting-article.md
Normal file
14
src/lib/posts/interesting-article.md
Normal 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.
|
||||
9
src/lib/posts/typographica-announcement.md
Normal file
9
src/lib/posts/typographica-announcement.md
Normal 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.
|
||||
|
|
@ -29,6 +29,11 @@ user-scalable=no"
|
|||
font-size: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
:global(html) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
111
src/routes/api/og-metadata/+server.ts
Normal file
111
src/routes/api/og-metadata/+server.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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') {
|
||||
Loading…
Reference in a new issue