better cards

This commit is contained in:
2026-02-10 02:34:25 -05:00
parent b63c62a732
commit 03df2b3a6c
24 changed files with 669 additions and 127 deletions

View File

@@ -1,5 +1,6 @@
---
import type { WordpressPost } from "../lib/content/types";
import StandardCard from "./StandardCard.astro";
type Props = {
post: WordpressPost;
@@ -10,26 +11,34 @@ type Props = {
const { post, placement, targetId } = Astro.props;
function truncate(s: string, n: number) {
if (!s) return "";
const t = s.trim();
const t = (s || "").trim();
if (!t) return "";
if (t.length <= n) return t;
return `${t.slice(0, Math.max(0, n - 1)).trimEnd()}…`;
}
const d = new Date(post.publishedAt);
const dateLabel = Number.isFinite(d.valueOf())
? d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
: "";
---
<a
class="blog-card"
<StandardCard
href={`/blog/post/${post.slug}`}
data-umami-event="click"
data-umami-event-target_id={targetId}
data-umami-event-placement={placement}
data-umami-event-target_url={`/blog/post/${post.slug}`}
data-umami-event-title={truncate(post.title || "", 160)}
data-umami-event-type="blog_post"
>
{post.featuredImageUrl ? <img src={post.featuredImageUrl} alt="" loading="lazy" /> : null}
<div class="blog-card-body">
<h3 class="blog-card-title">{post.title}</h3>
<p class="blog-card-excerpt">{truncate(post.excerpt || "", 180)}</p>
</div>
</a>
title={post.title}
summary={post.excerpt}
imageUrl={post.featuredImageUrl}
dateLabel={dateLabel}
viewsLabel={undefined}
sourceLabel="blog"
isExternal={false}
linkAttrs={{
"data-umami-event": "click",
"data-umami-event-target_id": targetId,
"data-umami-event-placement": placement,
"data-umami-event-target_url": `/blog/post/${post.slug}`,
"data-umami-event-title": truncate(post.title, 160),
"data-umami-event-type": "blog_post",
}}
/>

View File

@@ -1,5 +1,6 @@
---
import type { ContentItem } from "../lib/content/types";
import StandardCard from "./StandardCard.astro";
type Props = {
item: ContentItem;
@@ -36,40 +37,24 @@ const umamiType =
const umamiTitle = umamiType ? truncate(item.title, 160) : undefined;
---
<a
class="card"
<StandardCard
href={item.url}
target="_blank"
rel="noopener noreferrer"
data-umami-event="outbound_click"
data-umami-event-target_id={targetId}
data-umami-event-placement={placement}
data-umami-event-target_url={item.url}
data-umami-event-title={umamiTitle}
data-umami-event-type={umamiType}
data-umami-event-domain={domain || "unknown"}
data-umami-event-source={item.source}
data-umami-event-ui_placement="content_card"
>
<div class="card-media">
{
item.thumbnailUrl ? (
<img src={item.thumbnailUrl} alt="" loading="lazy" />
) : (
<div class="card-placeholder" />
)
}
</div>
<div class="card-body">
<div class="card-meta">
<span class={`pill pill-${item.source}`}>{item.source}</span>
{dateLabel ? <span class="muted">{dateLabel}</span> : null}
{
item.metrics?.views !== undefined ? (
<span class="muted">{item.metrics.views.toLocaleString()} views</span>
) : null
}
</div>
<h3 class="card-title">{item.title}</h3>
</div>
</a>
title={item.title}
summary={item.summary}
imageUrl={item.thumbnailUrl}
dateLabel={dateLabel}
viewsLabel={item.metrics?.views !== undefined ? `${item.metrics.views.toLocaleString()} views` : undefined}
sourceLabel={item.source}
isExternal={true}
linkAttrs={{
"data-umami-event": "outbound_click",
"data-umami-event-target_id": targetId,
"data-umami-event-placement": placement,
"data-umami-event-target_url": item.url,
"data-umami-event-title": umamiTitle,
"data-umami-event-type": umamiType,
"data-umami-event-domain": domain || "unknown",
"data-umami-event-source": item.source,
"data-umami-event-ui_placement": "content_card",
}}
/>

View File

@@ -0,0 +1,63 @@
---
type Props = {
href: string;
title: string;
summary?: string;
imageUrl?: string;
dateLabel?: string;
viewsLabel?: string;
sourceLabel: string;
isExternal?: boolean;
linkAttrs?: Record<string, any>;
};
const {
href,
title,
summary,
imageUrl,
dateLabel,
viewsLabel,
sourceLabel,
isExternal,
linkAttrs,
} = Astro.props;
function truncate(s: string, n: number) {
const t = (s || "").trim();
if (!t) return "";
if (t.length <= n) return t;
// ASCII ellipsis to avoid encoding issues in generated HTML.
return `${t.slice(0, Math.max(0, n - 3)).trimEnd()}...`;
}
const summaryText = truncate(summary || "", 180);
---
<a
class="card"
href={href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
{...(linkAttrs || {})}
>
<div class="card-media">
{imageUrl ? <img src={imageUrl} alt="" loading="lazy" /> : <div class="card-placeholder" />}
</div>
<div class="card-body">
<div class="card-content">
<h3 class="card-title">{title}</h3>
{summaryText ? <p class="card-summary">{summaryText}</p> : null}
</div>
<div class="card-footer">
<span class="muted card-date">{dateLabel || ""}</span>
<span class="muted card-views" aria-hidden={viewsLabel ? undefined : "true"}>
{viewsLabel || ""}
</span>
<span class={`pill pill-${sourceLabel}`}>{sourceLabel}</span>
</div>
</div>
</a>

View File

@@ -9,6 +9,7 @@ export type ContentItem = {
source: ContentSource;
url: string;
title: string;
summary?: string;
publishedAt: string; // ISO-8601
thumbnailUrl?: string;
metrics?: ContentMetrics;

View File

@@ -8,16 +8,40 @@ export async function fetchPodcastRss(rssUrl: string, limit = 50): Promise<Conte
return normalizePodcastFeedItems(feed.items || [], limit);
}
function stripHtml(s: string) {
return (s || "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function truncate(s: string, n: number) {
const t = stripHtml(s);
if (!t) return "";
if (t.length <= n) return t;
return `${t.slice(0, Math.max(0, n - 1)).trimEnd()}`;
}
export function normalizePodcastFeedItems(items: any[], limit: number): ContentItem[] {
const out = (items || []).slice(0, limit).map((it) => {
const url = it.link || "";
const id = (it.guid || it.id || url).toString();
const publishedAt = (it.isoDate || it.pubDate || new Date(0).toISOString()).toString();
const summary = truncate(
(it.contentSnippet ||
it.summary ||
it.content ||
it["content:encoded"] ||
it.itunes?.subtitle ||
"").toString(),
240,
);
return {
id,
source: "podcast" as const,
url,
title: (it.title || "").toString(),
summary: summary || undefined,
publishedAt: new Date(publishedAt).toISOString(),
thumbnailUrl: (it.itunes?.image || undefined) as string | undefined,
};

View File

@@ -6,11 +6,26 @@ type YoutubeApiVideo = {
id: string;
url: string;
title: string;
summary?: string;
publishedAt: string;
thumbnailUrl?: string;
views?: number;
};
function stripHtml(s: string) {
return (s || "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function truncate(s: string, n: number) {
const t = stripHtml(s);
if (!t) return "";
if (t.length <= n) return t;
return `${t.slice(0, Math.max(0, n - 1)).trimEnd()}`;
}
export async function fetchYoutubeViaRss(channelId: string, limit = 20): Promise<ContentItem[]> {
const feedUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${encodeURIComponent(channelId)}`;
const parser = new Parser();
@@ -32,11 +47,16 @@ export function normalizeYoutubeRssFeedItems(items: any[], limit: number): Conte
const url = it.link || "";
const id = (it.id || url).toString();
const publishedAt = (it.isoDate || it.pubDate || new Date(0).toISOString()).toString();
const summary = truncate(
(it.contentSnippet || it.summary || it.content || it["content:encoded"] || "").toString(),
240,
);
return {
id,
source: "youtube" as const,
url,
title: (it.title || "").toString(),
summary: summary || undefined,
publishedAt: new Date(publishedAt).toISOString(),
thumbnailUrl: (it.enclosure?.url || undefined) as string | undefined,
};
@@ -47,7 +67,12 @@ export function normalizeYoutubeRssFeedItems(items: any[], limit: number): Conte
export function normalizeYoutubeApiVideos(
items: Array<{
id: string;
snippet: { title: string; publishedAt: string; thumbnails?: Record<string, { url: string }> };
snippet: {
title: string;
description?: string;
publishedAt: string;
thumbnails?: Record<string, { url: string }>;
};
statistics?: { viewCount?: string };
}>,
): ContentItem[] {
@@ -55,6 +80,7 @@ export function normalizeYoutubeApiVideos(
id: v.id,
url: `https://www.youtube.com/watch?v=${encodeURIComponent(v.id)}`,
title: v.snippet.title,
summary: v.snippet.description ? truncate(v.snippet.description, 240) : undefined,
publishedAt: new Date(v.snippet.publishedAt).toISOString(),
thumbnailUrl: v.snippet.thumbnails?.high?.url || v.snippet.thumbnails?.default?.url,
views: v.statistics?.viewCount ? Number(v.statistics.viewCount) : undefined,
@@ -65,6 +91,7 @@ export function normalizeYoutubeApiVideos(
source: "youtube",
url: v.url,
title: v.title,
summary: v.summary,
publishedAt: v.publishedAt,
thumbnailUrl: v.thumbnailUrl,
metrics: v.views !== undefined ? { views: v.views } : undefined,