better cards
This commit is contained in:
@@ -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",
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
|
||||
63
site/src/components/StandardCard.astro
Normal file
63
site/src/components/StandardCard.astro
Normal 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>
|
||||
@@ -9,6 +9,7 @@ export type ContentItem = {
|
||||
source: ContentSource;
|
||||
url: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
publishedAt: string; // ISO-8601
|
||||
thumbnailUrl?: string;
|
||||
metrics?: ContentMetrics;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user