Fix styling of embeds in public site

This commit is contained in:
Justin Edmund 2025-06-13 18:34:59 -04:00
parent fe30f9e9b2
commit a468668858
8 changed files with 268 additions and 31 deletions

View file

@ -318,24 +318,26 @@
:global(.url-embed-link) {
display: flex;
background: $grey-95;
flex-direction: column;
background: $grey-97;
border-radius: $card-corner-radius;
overflow: hidden;
border: 1px solid $grey-85;
border: 1px solid $grey-80;
text-decoration: none;
transition: all 0.2s ease;
width: 100%;
&:hover {
border-color: $grey-60;
border-color: $grey-80;
transform: translateY(-1px);
text-decoration: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: 0 0px 8px rgba(0, 0, 0, 0.08);
}
}
:global(.url-embed-image) {
flex-shrink: 0;
width: 200px;
width: 100%;
aspect-ratio: 2 / 1;
overflow: hidden;
background: $grey-90;
}
@ -348,7 +350,7 @@
:global(.url-embed-text) {
flex: 1;
padding: $unit-3x;
padding: $unit-2x $unit-3x $unit-3x;
display: flex;
flex-direction: column;
gap: $unit;
@ -358,8 +360,8 @@
:global(.url-embed-meta) {
display: flex;
align-items: center;
gap: $unit;
font-size: 0.75rem;
gap: $unit-half;
font-size: 0.8125rem;
color: $grey-40;
}
@ -373,11 +375,12 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-transform: lowercase;
}
:global(.url-embed-title) {
margin: 0;
font-size: 1rem;
font-size: 1.125rem;
font-weight: 600;
color: $grey-10;
line-height: 1.3;
@ -389,12 +392,12 @@
:global(.url-embed-description) {
margin: 0;
font-size: 0.875rem;
font-size: 0.9375rem;
color: $grey-30;
line-height: 1.4;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
overflow: hidden;
}
@ -421,17 +424,5 @@
height: 100%;
border: none;
}
// Mobile styles for URL embeds
@media (max-width: 640px) {
:global(.url-embed-link) {
flex-direction: column;
}
:global(.url-embed-image) {
width: 100%;
height: 200px;
}
}
}
</style>

View file

@ -1,10 +1,15 @@
<script lang="ts">
import UniverseCard from './UniverseCard.svelte'
import { getContentExcerpt } from '$lib/utils/content'
import { extractEmbeds } from '$lib/utils/extractEmbeds'
import type { UniverseItem } from '../../routes/api/universe/+server'
let { post }: { post: UniverseItem } = $props()
// Extract embeds from content
const embeds = $derived(post.content ? extractEmbeds(post.content) : [])
const firstEmbed = $derived(embeds[0])
// Check if content is truncated
const isContentTruncated = $derived(() => {
if (post.content) {
@ -14,6 +19,16 @@
}
return false
})
// Helper to get domain from URL
const getDomain = (url: string) => {
try {
const urlObj = new URL(url)
return urlObj.hostname.replace('www.', '')
} catch {
return ''
}
}
</script>
<UniverseCard item={post} type="post">
@ -29,6 +44,46 @@
</div>
{/if}
{#if firstEmbed}
<div class="embed-preview">
{#if firstEmbed.type === 'youtube' && firstEmbed.videoId}
<div class="youtube-embed-preview">
<div class="youtube-player">
<iframe
src="https://www.youtube.com/embed/{firstEmbed.videoId}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
title="YouTube video player"
></iframe>
</div>
</div>
{:else}
<a href="/universe/{post.slug}" class="url-embed-preview" tabindex="-1">
{#if firstEmbed.image}
<div class="embed-image">
<img src={firstEmbed.image} alt={firstEmbed.title || 'Link preview'} />
</div>
{/if}
<div class="embed-text">
<div class="embed-meta">
{#if firstEmbed.favicon}
<img src={firstEmbed.favicon} alt="" class="embed-favicon" />
{/if}
<span class="embed-domain">{firstEmbed.siteName || getDomain(firstEmbed.url)}</span>
</div>
{#if firstEmbed.title}
<h3 class="embed-title">{firstEmbed.title}</h3>
{/if}
{#if firstEmbed.description}
<p class="embed-description">{firstEmbed.description}</p>
{/if}
</div>
</a>
{/if}
</div>
{/if}
{#if post.postType === 'essay' && isContentTruncated}
<p>
<a href="/universe/{post.slug}" class="read-more" tabindex="-1">Continue reading</a>
@ -60,7 +115,7 @@
.link-preview {
background: $grey-97;
border: 1px solid $grey-90;
border-radius: $unit;
border-radius: $card-corner-radius;
padding: $unit-2x;
margin-bottom: $unit-3x;
@ -119,4 +174,116 @@
font-weight: 500;
transition: all 0.2s ease;
}
// Embed preview styles
.embed-preview {
margin: $unit-2x 0;
}
.youtube-embed-preview {
.youtube-player {
position: relative;
width: 100%;
padding-bottom: 56%; // 16:9 aspect ratio
height: 0;
overflow: hidden;
background: $grey-95;
border-radius: $card-corner-radius;
border: 1px solid $grey-85;
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
border-radius: $unit;
}
}
}
.url-embed-preview {
display: flex;
flex-direction: column;
background: $grey-97;
border-radius: $card-corner-radius;
overflow: hidden;
border: 1px solid $grey-80;
text-decoration: none;
transition: all 0.2s ease;
width: 100%;
&:hover {
border-color: $grey-80;
transform: translateY(-1px);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.08);
}
.embed-image {
width: 100%;
aspect-ratio: 2 / 1;
overflow: hidden;
background: $grey-90;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.embed-text {
flex: 1;
padding: $unit-2x $unit-3x $unit-3x;
display: flex;
flex-direction: column;
gap: $unit;
min-width: 0;
}
.embed-meta {
display: flex;
align-items: center;
gap: $unit-half;
font-size: 0.8125rem;
color: $grey-40;
}
.embed-favicon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.embed-domain {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-transform: lowercase;
}
.embed-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: $grey-10;
line-height: 1.3;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.embed-description {
margin: 0;
font-size: 0.9375rem;
color: $grey-30;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
}
</style>

View file

@ -297,7 +297,7 @@
.dropdown-divider {
height: 1px;
background-color: $grey-90;
background-color: $grey-80;
margin: $unit-half 0;
}
</style>

View file

@ -123,7 +123,7 @@
.dropdown-divider {
height: 1px;
background-color: $grey-90;
background-color: $grey-80;
margin: $unit-half 0;
}
</style>

View file

@ -306,7 +306,7 @@
.dropdown-divider {
height: 1px;
background-color: $grey-90;
background-color: $grey-80;
margin: $unit-half 0;
}

View file

@ -256,7 +256,7 @@
.dropdown-divider {
height: 1px;
background-color: $grey-90;
background-color: $grey-80;
margin: $unit-half 0;
}
</style>

View file

@ -134,7 +134,7 @@
.dropdown-divider {
height: 1px;
background-color: $grey-90;
background-color: $grey-80;
margin: $unit-half 0;
}

View file

@ -0,0 +1,79 @@
// Extract URL embeds from Tiptap content
export interface ExtractedEmbed {
type: 'urlEmbed' | 'youtube'
url: string
title?: string
description?: string
image?: string
favicon?: string
siteName?: string
videoId?: string
}
export function extractEmbeds(content: any): ExtractedEmbed[] {
if (!content || !content.content) return []
const embeds: ExtractedEmbed[] = []
// Helper to extract YouTube video ID
const getYouTubeVideoId = (url: string): string | null => {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match && match[1]) {
return match[1]
}
}
return null
}
// Recursive function to find embed nodes
const findEmbeds = (node: any) => {
if (node.type === 'urlEmbed' && node.attrs?.url) {
const url = node.attrs.url
const isYouTube = /(?:youtube\.com|youtu\.be)/.test(url)
if (isYouTube) {
const videoId = getYouTubeVideoId(url)
if (videoId) {
embeds.push({
type: 'youtube',
url,
videoId,
title: node.attrs.title,
description: node.attrs.description,
image: node.attrs.image,
favicon: node.attrs.favicon,
siteName: node.attrs.siteName
})
}
} else {
embeds.push({
type: 'urlEmbed',
url,
title: node.attrs.title,
description: node.attrs.description,
image: node.attrs.image,
favicon: node.attrs.favicon,
siteName: node.attrs.siteName
})
}
}
// Recursively check child nodes
if (node.content && Array.isArray(node.content)) {
node.content.forEach(findEmbeds)
}
}
// Start searching from the root
if (content.content && Array.isArray(content.content)) {
content.content.forEach(findEmbeds)
}
return embeds
}