This commit is contained in:
58
site/src/components/ContentCard.astro
Normal file
58
site/src/components/ContentCard.astro
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
import type { ContentItem } from "../lib/content/types";
|
||||
|
||||
type Props = {
|
||||
item: ContentItem;
|
||||
placement: string;
|
||||
};
|
||||
|
||||
const { item, placement } = Astro.props;
|
||||
const d = new Date(item.publishedAt);
|
||||
const dateLabel = Number.isFinite(d.valueOf())
|
||||
? d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
|
||||
: "";
|
||||
|
||||
const targetId = `card.${placement}.${item.source}.${item.id}`;
|
||||
let domain = "";
|
||||
try {
|
||||
domain = new URL(item.url).hostname;
|
||||
} catch {
|
||||
domain = "";
|
||||
}
|
||||
---
|
||||
|
||||
<a
|
||||
class="card"
|
||||
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-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>
|
||||
50
site/src/components/CtaLink.astro
Normal file
50
site/src/components/CtaLink.astro
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
import { withUtm } from "../lib/url";
|
||||
|
||||
type Platform = "youtube" | "instagram" | "podcast";
|
||||
|
||||
type Props = {
|
||||
platform: Platform;
|
||||
placement: string;
|
||||
url: string;
|
||||
label: string;
|
||||
id?: string;
|
||||
campaign?: string;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
const { platform, placement, url, label, id, campaign, class: className } = Astro.props;
|
||||
|
||||
const trackedUrl = withUtm(url, {
|
||||
utm_source: "website",
|
||||
utm_medium: "cta",
|
||||
utm_campaign: campaign || "social-acquisition",
|
||||
utm_content: `${platform}:${placement}`,
|
||||
});
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
const fallbackId = `cta.${placement}.${platform}.${slugify(label) || "action"}`;
|
||||
const targetId = id || fallbackId;
|
||||
---
|
||||
|
||||
<a
|
||||
class={`cta ${className || ""}`}
|
||||
href={trackedUrl}
|
||||
rel="me noopener noreferrer"
|
||||
target="_blank"
|
||||
data-umami-event="cta_click"
|
||||
data-umami-event-target_id={targetId}
|
||||
data-umami-event-placement={placement}
|
||||
data-umami-event-target_url={url}
|
||||
data-umami-event-platform={platform}
|
||||
data-umami-event-target={url}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
33
site/src/components/InstagramEmbed.astro
Normal file
33
site/src/components/InstagramEmbed.astro
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type Props = {
|
||||
url: string;
|
||||
placement?: string;
|
||||
};
|
||||
|
||||
const { url, placement } = Astro.props;
|
||||
const p = placement || "instagram_embed";
|
||||
const targetId = `ig.${p}.${url}`;
|
||||
let domain = "";
|
||||
try {
|
||||
domain = new URL(url).hostname;
|
||||
} catch {
|
||||
domain = "";
|
||||
}
|
||||
---
|
||||
|
||||
<blockquote class="instagram-media" data-instgrm-permalink={url} data-instgrm-version="14">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="outbound_click"
|
||||
data-umami-event-target_id={targetId}
|
||||
data-umami-event-placement={p}
|
||||
data-umami-event-target_url={url}
|
||||
data-umami-event-domain={domain || "unknown"}
|
||||
data-umami-event-source="instagram"
|
||||
data-umami-event-ui_placement="instagram_embed"
|
||||
>
|
||||
View on Instagram
|
||||
</a>
|
||||
</blockquote>
|
||||
11
site/src/env.d.ts
vendored
Normal file
11
site/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_SITE_URL?: string;
|
||||
readonly PUBLIC_UMAMI_SCRIPT_URL?: string;
|
||||
readonly PUBLIC_UMAMI_WEBSITE_ID?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
102
site/src/layouts/BaseLayout.astro
Normal file
102
site/src/layouts/BaseLayout.astro
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
import { getPublicConfig } from "../lib/config";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
canonicalPath: string;
|
||||
ogImageUrl?: string;
|
||||
};
|
||||
|
||||
const { title, description, canonicalPath, ogImageUrl } = Astro.props;
|
||||
const cfg = getPublicConfig();
|
||||
|
||||
const siteUrl = (cfg.siteUrl || "http://localhost:4321").replace(/\/$/, "");
|
||||
const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath : `/${canonicalPath}`}`;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
{ogImageUrl ? <meta property="og:image" content={ogImageUrl} /> : null}
|
||||
|
||||
<meta name="twitter:card" content={ogImageUrl ? "summary_large_image" : "summary"} />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
{ogImageUrl ? <meta name="twitter:image" content={ogImageUrl} /> : null}
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
|
||||
<link rel="stylesheet" href="/styles/global.css" />
|
||||
|
||||
{
|
||||
cfg.umami ? (
|
||||
<script async defer data-website-id={cfg.umami.websiteId} src={cfg.umami.scriptUrl} />
|
||||
) : null
|
||||
}
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a
|
||||
class="brand"
|
||||
href="/"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="nav.brand"
|
||||
data-umami-event-placement="nav"
|
||||
data-umami-event-target_url="/"
|
||||
>
|
||||
SanthoshJ
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a
|
||||
href="/videos"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="nav.videos"
|
||||
data-umami-event-placement="nav"
|
||||
data-umami-event-target_url="/videos"
|
||||
>
|
||||
Videos
|
||||
</a>
|
||||
<a
|
||||
href="/podcast"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="nav.podcast"
|
||||
data-umami-event-placement="nav"
|
||||
data-umami-event-target_url="/podcast"
|
||||
>
|
||||
Podcast
|
||||
</a>
|
||||
<a
|
||||
href="/about"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="nav.about"
|
||||
data-umami-event-placement="nav"
|
||||
data-umami-event-target_url="/about"
|
||||
>
|
||||
About
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p class="muted">© {new Date().getFullYear()} SanthoshJ</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
35
site/src/lib/config.ts
Normal file
35
site/src/lib/config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
type PublicConfig = {
|
||||
siteUrl?: string;
|
||||
umami?: {
|
||||
scriptUrl: string;
|
||||
websiteId: string;
|
||||
};
|
||||
};
|
||||
|
||||
type IngestConfig = {
|
||||
youtubeChannelId?: string;
|
||||
youtubeApiKey?: string;
|
||||
podcastRssUrl?: string;
|
||||
instagramPostUrlsFile: string;
|
||||
};
|
||||
|
||||
export function getPublicConfig(): PublicConfig {
|
||||
const siteUrl = import.meta.env.PUBLIC_SITE_URL;
|
||||
const scriptUrl = import.meta.env.PUBLIC_UMAMI_SCRIPT_URL;
|
||||
const websiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID;
|
||||
|
||||
return {
|
||||
siteUrl,
|
||||
umami: scriptUrl && websiteId ? { scriptUrl, websiteId } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Ingestion scripts run under Node (not inside Astro runtime).
|
||||
export function getIngestConfigFromEnv(env: NodeJS.ProcessEnv): IngestConfig {
|
||||
return {
|
||||
youtubeChannelId: env.YOUTUBE_CHANNEL_ID,
|
||||
youtubeApiKey: env.YOUTUBE_API_KEY,
|
||||
podcastRssUrl: env.PODCAST_RSS_URL,
|
||||
instagramPostUrlsFile: env.INSTAGRAM_POST_URLS_FILE || "content/instagram-posts.json",
|
||||
};
|
||||
}
|
||||
26
site/src/lib/content/cache.ts
Normal file
26
site/src/lib/content/cache.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ContentCache } from "./types";
|
||||
|
||||
const DEFAULT_CACHE: ContentCache = { generatedAt: new Date(0).toISOString(), items: [] };
|
||||
|
||||
function getCachePath() {
|
||||
// Read from the repo-local content cache (populated by scripts/fetch-content.ts).
|
||||
return path.join(process.cwd(), "content", "cache", "content.json");
|
||||
}
|
||||
|
||||
export async function readContentCache(): Promise<ContentCache> {
|
||||
const cachePath = getCachePath();
|
||||
try {
|
||||
const raw = await fs.readFile(cachePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as ContentCache;
|
||||
if (!parsed || !Array.isArray(parsed.items) || typeof parsed.generatedAt !== "string") {
|
||||
return DEFAULT_CACHE;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
// Cache missing is normal for first run.
|
||||
return DEFAULT_CACHE;
|
||||
}
|
||||
}
|
||||
19
site/src/lib/content/curation.ts
Normal file
19
site/src/lib/content/curation.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
type FeaturedVideosFile = {
|
||||
videoIds: string[];
|
||||
};
|
||||
|
||||
export async function readFeaturedVideoIds(): Promise<string[]> {
|
||||
const p = path.join(process.cwd(), "content", "featured-videos.json");
|
||||
try {
|
||||
const raw = await fs.readFile(p, "utf8");
|
||||
const parsed = JSON.parse(raw) as FeaturedVideosFile;
|
||||
return Array.isArray(parsed.videoIds)
|
||||
? parsed.videoIds.filter((x) => typeof x === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
45
site/src/lib/content/selectors.ts
Normal file
45
site/src/lib/content/selectors.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ContentCache, ContentItem, ContentSource } from "./types";
|
||||
|
||||
export function newestItems(
|
||||
cache: ContentCache,
|
||||
limit: number,
|
||||
sources?: ContentSource[],
|
||||
): ContentItem[] {
|
||||
const items = sources ? cache.items.filter((i) => sources.includes(i.source)) : cache.items;
|
||||
return [...items]
|
||||
.sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt))
|
||||
.slice(0, Math.max(0, limit));
|
||||
}
|
||||
|
||||
export function youtubeVideos(cache: ContentCache): ContentItem[] {
|
||||
return cache.items.filter((i) => i.source === "youtube");
|
||||
}
|
||||
|
||||
export function podcastEpisodes(cache: ContentCache): ContentItem[] {
|
||||
return cache.items.filter((i) => i.source === "podcast");
|
||||
}
|
||||
|
||||
export function instagramPosts(cache: ContentCache): ContentItem[] {
|
||||
return cache.items.filter((i) => i.source === "instagram");
|
||||
}
|
||||
|
||||
export function highPerformingYoutubeVideos(
|
||||
cache: ContentCache,
|
||||
limit: number,
|
||||
curatedIds: string[],
|
||||
): ContentItem[] {
|
||||
const videos = youtubeVideos(cache);
|
||||
|
||||
// If we have a curated list, it wins (keeps the page stable even without metrics).
|
||||
if (curatedIds.length > 0) {
|
||||
const byId = new Map(videos.map((v) => [v.id, v]));
|
||||
return curatedIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter(Boolean)
|
||||
.slice(0, Math.max(0, limit)) as ContentItem[];
|
||||
}
|
||||
|
||||
// Otherwise rank by views where possible.
|
||||
const ranked = [...videos].sort((a, b) => (b.metrics?.views || 0) - (a.metrics?.views || 0));
|
||||
return ranked.slice(0, Math.max(0, limit));
|
||||
}
|
||||
20
site/src/lib/content/types.ts
Normal file
20
site/src/lib/content/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type ContentSource = "youtube" | "instagram" | "podcast";
|
||||
|
||||
export type ContentMetrics = {
|
||||
views?: number;
|
||||
};
|
||||
|
||||
export type ContentItem = {
|
||||
id: string;
|
||||
source: ContentSource;
|
||||
url: string;
|
||||
title: string;
|
||||
publishedAt: string; // ISO-8601
|
||||
thumbnailUrl?: string;
|
||||
metrics?: ContentMetrics;
|
||||
};
|
||||
|
||||
export type ContentCache = {
|
||||
generatedAt: string; // ISO-8601
|
||||
items: ContentItem[];
|
||||
};
|
||||
32
site/src/lib/ingest/instagram.ts
Normal file
32
site/src/lib/ingest/instagram.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
import type { ContentItem } from "../content/types";
|
||||
|
||||
type InstagramPostsFile = {
|
||||
posts: Array<{
|
||||
url: string;
|
||||
title?: string;
|
||||
publishedAt?: string;
|
||||
thumbnailUrl?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function readInstagramEmbedPosts(filePath: string): Promise<ContentItem[]> {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as InstagramPostsFile;
|
||||
const now = new Date().toISOString();
|
||||
const posts = Array.isArray(parsed.posts) ? parsed.posts : [];
|
||||
|
||||
return posts
|
||||
.filter((p) => typeof p.url === "string" && p.url.length > 0)
|
||||
.map((p) => ({
|
||||
id: p.url,
|
||||
source: "instagram" as const,
|
||||
url: p.url,
|
||||
title: p.title || "Instagram post",
|
||||
// If the user doesn't provide a publish date, we still generate valid ISO-8601.
|
||||
// It won't be accurate, but it keeps the system functional until real ingestion is added.
|
||||
publishedAt: p.publishedAt ? new Date(p.publishedAt).toISOString() : now,
|
||||
thumbnailUrl: p.thumbnailUrl,
|
||||
}));
|
||||
}
|
||||
27
site/src/lib/ingest/podcast.ts
Normal file
27
site/src/lib/ingest/podcast.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import Parser from "rss-parser";
|
||||
|
||||
import type { ContentItem } from "../content/types";
|
||||
|
||||
export async function fetchPodcastRss(rssUrl: string, limit = 50): Promise<ContentItem[]> {
|
||||
const parser = new Parser();
|
||||
const feed = await parser.parseURL(rssUrl);
|
||||
return normalizePodcastFeedItems(feed.items || [], limit);
|
||||
}
|
||||
|
||||
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();
|
||||
return {
|
||||
id,
|
||||
source: "podcast" as const,
|
||||
url,
|
||||
title: (it.title || "").toString(),
|
||||
publishedAt: new Date(publishedAt).toISOString(),
|
||||
thumbnailUrl: (it.itunes?.image || undefined) as string | undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return out.filter((x) => x.url && x.title);
|
||||
}
|
||||
6
site/src/lib/ingest/types.ts
Normal file
6
site/src/lib/ingest/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { ContentItem, ContentSource } from "../content/types";
|
||||
|
||||
export type IngestResult = {
|
||||
source: ContentSource;
|
||||
items: ContentItem[];
|
||||
};
|
||||
112
site/src/lib/ingest/youtube.ts
Normal file
112
site/src/lib/ingest/youtube.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import Parser from "rss-parser";
|
||||
|
||||
import type { ContentItem } from "../content/types";
|
||||
|
||||
type YoutubeApiVideo = {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
publishedAt: string;
|
||||
thumbnailUrl?: string;
|
||||
views?: number;
|
||||
};
|
||||
|
||||
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();
|
||||
const feed = await parser.parseURL(feedUrl);
|
||||
|
||||
return normalizeYoutubeRssFeedItems(feed.items || [], limit);
|
||||
}
|
||||
|
||||
async function youtubeApiGetJson<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`YouTube API request failed: ${res.status} ${res.statusText}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export function normalizeYoutubeRssFeedItems(items: any[], limit: number): ContentItem[] {
|
||||
return (items || [])
|
||||
.slice(0, limit)
|
||||
.map((it) => {
|
||||
const url = it.link || "";
|
||||
const id = (it.id || url).toString();
|
||||
const publishedAt = (it.isoDate || it.pubDate || new Date(0).toISOString()).toString();
|
||||
return {
|
||||
id,
|
||||
source: "youtube" as const,
|
||||
url,
|
||||
title: (it.title || "").toString(),
|
||||
publishedAt: new Date(publishedAt).toISOString(),
|
||||
thumbnailUrl: (it.enclosure?.url || undefined) as string | undefined,
|
||||
};
|
||||
})
|
||||
.filter((x) => x.url && x.title);
|
||||
}
|
||||
|
||||
export function normalizeYoutubeApiVideos(
|
||||
items: Array<{
|
||||
id: string;
|
||||
snippet: { title: string; publishedAt: string; thumbnails?: Record<string, { url: string }> };
|
||||
statistics?: { viewCount?: string };
|
||||
}>,
|
||||
): ContentItem[] {
|
||||
const normalized: YoutubeApiVideo[] = (items || []).map((v) => ({
|
||||
id: v.id,
|
||||
url: `https://www.youtube.com/watch?v=${encodeURIComponent(v.id)}`,
|
||||
title: v.snippet.title,
|
||||
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,
|
||||
}));
|
||||
|
||||
return normalized.map<ContentItem>((v) => ({
|
||||
id: v.id,
|
||||
source: "youtube",
|
||||
url: v.url,
|
||||
title: v.title,
|
||||
publishedAt: v.publishedAt,
|
||||
thumbnailUrl: v.thumbnailUrl,
|
||||
metrics: v.views !== undefined ? { views: v.views } : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchYoutubeViaApi(
|
||||
channelId: string,
|
||||
apiKey: string,
|
||||
limit = 20,
|
||||
): Promise<ContentItem[]> {
|
||||
// 1) Get latest video IDs from channel.
|
||||
const searchUrl =
|
||||
"https://www.googleapis.com/youtube/v3/search" +
|
||||
`?part=snippet&channelId=${encodeURIComponent(channelId)}` +
|
||||
`&maxResults=${encodeURIComponent(String(limit))}` +
|
||||
`&order=date&type=video&key=${encodeURIComponent(apiKey)}`;
|
||||
|
||||
const search = await youtubeApiGetJson<{
|
||||
items: Array<{
|
||||
id: { videoId: string };
|
||||
snippet: { title: string; publishedAt: string; thumbnails?: any };
|
||||
}>;
|
||||
}>(searchUrl);
|
||||
|
||||
const videoIds = (search.items || []).map((x) => x.id.videoId).filter(Boolean);
|
||||
if (videoIds.length === 0) return [];
|
||||
|
||||
// 2) Fetch statistics.
|
||||
const videosUrl =
|
||||
"https://www.googleapis.com/youtube/v3/videos" +
|
||||
`?part=snippet,statistics&maxResults=${encodeURIComponent(String(videoIds.length))}` +
|
||||
`&id=${encodeURIComponent(videoIds.join(","))}` +
|
||||
`&key=${encodeURIComponent(apiKey)}`;
|
||||
|
||||
const videos = await youtubeApiGetJson<{
|
||||
items: Array<{
|
||||
id: string;
|
||||
snippet: { title: string; publishedAt: string; thumbnails?: Record<string, { url: string }> };
|
||||
statistics?: { viewCount?: string };
|
||||
}>;
|
||||
}>(videosUrl);
|
||||
|
||||
return normalizeYoutubeApiVideos(videos.items || []);
|
||||
}
|
||||
5
site/src/lib/links.ts
Normal file
5
site/src/lib/links.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const LINKS = {
|
||||
youtubeChannel: "https://www.youtube.com/santhoshj",
|
||||
instagramProfile: "https://www.instagram.com/santhoshjanan/",
|
||||
podcast: "https://podcasters.spotify.com/pod/show/irregularmind", // default; override in CTA props if needed
|
||||
};
|
||||
10
site/src/lib/url.ts
Normal file
10
site/src/lib/url.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function withUtm(
|
||||
url: string,
|
||||
utm: Partial<Record<"utm_source" | "utm_medium" | "utm_campaign" | "utm_content", string>>,
|
||||
): string {
|
||||
const u = new URL(url);
|
||||
for (const [k, v] of Object.entries(utm)) {
|
||||
if (v) u.searchParams.set(k, v);
|
||||
}
|
||||
return u.toString();
|
||||
}
|
||||
34
site/src/pages/about.astro
Normal file
34
site/src/pages/about.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import CtaLink from "../components/CtaLink.astro";
|
||||
import { LINKS } from "../lib/links";
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="About | SanthoshJ"
|
||||
description="About SanthoshJ and where to follow."
|
||||
canonicalPath="/about"
|
||||
>
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>About</h2>
|
||||
<span class="muted">Tech, streaming, movies, travel</span>
|
||||
</div>
|
||||
<div class="empty">
|
||||
<p style="margin-top: 0;">
|
||||
This is a lightweight site that aggregates my content so it can be discovered via search and
|
||||
shared cleanly.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<CtaLink platform="youtube" placement="about" url={LINKS.youtubeChannel} label="YouTube" />
|
||||
<CtaLink
|
||||
platform="instagram"
|
||||
placement="about"
|
||||
url={LINKS.instagramProfile}
|
||||
label="Instagram"
|
||||
/>
|
||||
<CtaLink platform="podcast" placement="about" url={LINKS.podcast} label="Podcast" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
238
site/src/pages/index.astro
Normal file
238
site/src/pages/index.astro
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import CtaLink from "../components/CtaLink.astro";
|
||||
import ContentCard from "../components/ContentCard.astro";
|
||||
import InstagramEmbed from "../components/InstagramEmbed.astro";
|
||||
import { readContentCache } from "../lib/content/cache";
|
||||
import {
|
||||
newestItems,
|
||||
highPerformingYoutubeVideos,
|
||||
instagramPosts,
|
||||
podcastEpisodes,
|
||||
} from "../lib/content/selectors";
|
||||
import { readFeaturedVideoIds } from "../lib/content/curation";
|
||||
import { LINKS } from "../lib/links";
|
||||
|
||||
const cache = await readContentCache();
|
||||
const featuredIds = await readFeaturedVideoIds();
|
||||
|
||||
const newest = newestItems(cache, 9);
|
||||
const highPerf = highPerformingYoutubeVideos(cache, 6, featuredIds);
|
||||
const ig = instagramPosts(cache).slice(0, 6);
|
||||
const pods = podcastEpisodes(cache)
|
||||
.slice(0, 6)
|
||||
.sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt));
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="SanthoshJ | Tech, streaming, movies, travel"
|
||||
description="A fast, SEO-first home for videos, movie posts, and the Irregular Mind podcast."
|
||||
canonicalPath="/"
|
||||
>
|
||||
<section class="hero">
|
||||
<div>
|
||||
<h1>Fast content. Clear next actions.</h1>
|
||||
<p>
|
||||
I post about technology, game streaming, movies, and travel. This site collects the best of
|
||||
it and points you to the platform you prefer.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<CtaLink
|
||||
platform="youtube"
|
||||
placement="hero"
|
||||
url={LINKS.youtubeChannel}
|
||||
label="Subscribe on YouTube"
|
||||
class="primary"
|
||||
/>
|
||||
<CtaLink
|
||||
platform="instagram"
|
||||
placement="hero"
|
||||
url={LINKS.instagramProfile}
|
||||
label="Follow on Instagram"
|
||||
/>
|
||||
<CtaLink
|
||||
platform="podcast"
|
||||
placement="hero"
|
||||
url={LINKS.podcast}
|
||||
label="Listen to the podcast"
|
||||
/>
|
||||
</div>
|
||||
<p class="muted" style="margin-top: 14px;">
|
||||
Last updated: {new Date(cache.generatedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="empty">
|
||||
<strong>Goal:</strong> 10% month-over-month growth in followers and engagement.
|
||||
<br />
|
||||
<span class="muted"
|
||||
>This site is the SEO landing surface that turns search traffic into followers.</span
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Newest</h2>
|
||||
<a
|
||||
class="muted"
|
||||
href="/videos"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="section_header.newest.browse_all"
|
||||
data-umami-event-placement="section_header"
|
||||
data-umami-event-target_url="/videos"
|
||||
>
|
||||
Browse all →
|
||||
</a>
|
||||
</div>
|
||||
{
|
||||
newest.length > 0 ? (
|
||||
<div class="grid">
|
||||
{newest.map((item) => (
|
||||
<ContentCard item={item} placement="home.newest" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty">
|
||||
No content cache yet. Run <code>npm run fetch-content</code> in <code>site/</code>.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>High-performing videos</h2>
|
||||
<a
|
||||
class="muted"
|
||||
href="/videos"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="section_header.high_performing.videos"
|
||||
data-umami-event-placement="section_header"
|
||||
data-umami-event-target_url="/videos"
|
||||
>
|
||||
Videos →
|
||||
</a>
|
||||
</div>
|
||||
{
|
||||
highPerf.length > 0 ? (
|
||||
<div class="grid">
|
||||
{highPerf.map((item) => (
|
||||
<ContentCard item={item} placement="home.high_performing" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty">
|
||||
No video stats found yet. Add <code>YOUTUBE_API_KEY</code> or curate{" "}
|
||||
<code>content/featured-videos.json</code>.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Instagram</h2>
|
||||
<a
|
||||
class="muted"
|
||||
href={LINKS.instagramProfile}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="outbound_click"
|
||||
data-umami-event-target_id="section_header.instagram.profile"
|
||||
data-umami-event-placement="section_header"
|
||||
data-umami-event-target_url={LINKS.instagramProfile}
|
||||
data-umami-event-domain="www.instagram.com"
|
||||
data-umami-event-source="instagram"
|
||||
data-umami-event-ui_placement="section_header"
|
||||
>
|
||||
Profile →
|
||||
</a>
|
||||
</div>
|
||||
{
|
||||
ig.length > 0 ? (
|
||||
<div class="grid">
|
||||
{ig.map((item) => (
|
||||
<InstagramEmbed url={item.url} placement="home.instagram" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty">
|
||||
Add post URLs to <code>content/instagram-posts.json</code> to show Instagram here.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
{
|
||||
ig.length > 0 ? (
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{}}
|
||||
set:html={`
|
||||
(function(){
|
||||
var s = document.createElement('script');
|
||||
s.async = true;
|
||||
s.defer = true;
|
||||
s.src = 'https://www.instagram.com/embed.js';
|
||||
s.onload = function(){
|
||||
try { window.instgrm && window.instgrm.Embeds && window.instgrm.Embeds.process(); } catch(e) {}
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
`}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Podcast: Irregular Mind</h2>
|
||||
<a
|
||||
class="muted"
|
||||
href="/podcast"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="section_header.podcast.episodes"
|
||||
data-umami-event-placement="section_header"
|
||||
data-umami-event-target_url="/podcast"
|
||||
>
|
||||
Episodes →
|
||||
</a>
|
||||
</div>
|
||||
{
|
||||
pods.length > 0 ? (
|
||||
<div class="grid">
|
||||
{pods.map((item) => (
|
||||
<ContentCard item={item} placement="home.podcast" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty">
|
||||
Set <code>PODCAST_RSS_URL</code> and run <code>npm run fetch-content</code> to populate
|
||||
episodes.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Follow</h2>
|
||||
<span class="muted">Pick your platform</span>
|
||||
</div>
|
||||
<div class="cta-row">
|
||||
<CtaLink
|
||||
platform="youtube"
|
||||
placement="footer_cta"
|
||||
url={LINKS.youtubeChannel}
|
||||
label="YouTube"
|
||||
/>
|
||||
<CtaLink
|
||||
platform="instagram"
|
||||
placement="footer_cta"
|
||||
url={LINKS.instagramProfile}
|
||||
label="Instagram"
|
||||
/>
|
||||
<CtaLink platform="podcast" placement="footer_cta" url={LINKS.podcast} label="Podcast" />
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
38
site/src/pages/podcast.astro
Normal file
38
site/src/pages/podcast.astro
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import ContentCard from "../components/ContentCard.astro";
|
||||
import { readContentCache } from "../lib/content/cache";
|
||||
import { podcastEpisodes } from "../lib/content/selectors";
|
||||
|
||||
const cache = await readContentCache();
|
||||
const episodes = podcastEpisodes(cache).sort(
|
||||
(a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt),
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Podcast | Irregular Mind"
|
||||
description="Episodes from the Irregular Mind podcast."
|
||||
canonicalPath="/podcast"
|
||||
>
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Irregular Mind</h2>
|
||||
<span class="muted">{episodes.length} episodes</span>
|
||||
</div>
|
||||
{
|
||||
episodes.length > 0 ? (
|
||||
<div class="grid">
|
||||
{episodes.map((item) => (
|
||||
<ContentCard item={item} placement="podcast.list" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty">
|
||||
No episodes yet. Set <code>PODCAST_RSS_URL</code> and run{" "}
|
||||
<code>npm run fetch-content</code>.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
</BaseLayout>
|
||||
74
site/src/pages/podcast/[id].astro
Normal file
74
site/src/pages/podcast/[id].astro
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
import BaseLayout from "../../layouts/BaseLayout.astro";
|
||||
import { readContentCache } from "../../lib/content/cache";
|
||||
import { podcastEpisodes } from "../../lib/content/selectors";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const cache = await readContentCache();
|
||||
const eps = podcastEpisodes(cache);
|
||||
return eps.map((e) => ({ params: { id: e.id }, props: { episode: e } }));
|
||||
}
|
||||
|
||||
const { episode } = Astro.props;
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "PodcastEpisode",
|
||||
name: episode.title,
|
||||
datePublished: episode.publishedAt,
|
||||
url: episode.url,
|
||||
};
|
||||
|
||||
let episodeDomain = "";
|
||||
try {
|
||||
episodeDomain = new URL(episode.url).hostname;
|
||||
} catch {
|
||||
episodeDomain = "";
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${episode.title} | Irregular Mind`}
|
||||
description={`Listen: ${episode.title}`}
|
||||
canonicalPath={`/podcast/${encodeURIComponent(episode.id)}`}
|
||||
ogImageUrl={episode.thumbnailUrl}
|
||||
>
|
||||
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>{episode.title}</h2>
|
||||
<a
|
||||
class="muted"
|
||||
href="/podcast"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="podcast_detail.back_to_podcast"
|
||||
data-umami-event-placement="section_header"
|
||||
data-umami-event-target_url="/podcast"
|
||||
>
|
||||
Back to podcast →
|
||||
</a>
|
||||
</div>
|
||||
<div class="empty">
|
||||
<p style="margin-top: 0;">
|
||||
This page exists for SEO and sharing. Listening happens on your platform.
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
class="cta primary"
|
||||
href={episode.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="outbound_click"
|
||||
data-umami-event-target_id={`podcast_detail.open.${episode.id}`}
|
||||
data-umami-event-placement="podcast_detail"
|
||||
data-umami-event-target_url={episode.url}
|
||||
data-umami-event-domain={episodeDomain || "unknown"}
|
||||
data-umami-event-source="podcast"
|
||||
data-umami-event-ui_placement="podcast_detail"
|
||||
>
|
||||
Open episode
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
38
site/src/pages/videos.astro
Normal file
38
site/src/pages/videos.astro
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import ContentCard from "../components/ContentCard.astro";
|
||||
import { readContentCache } from "../lib/content/cache";
|
||||
import { youtubeVideos } from "../lib/content/selectors";
|
||||
|
||||
const cache = await readContentCache();
|
||||
const videos = youtubeVideos(cache).sort(
|
||||
(a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt),
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Videos | SanthoshJ"
|
||||
description="Latest and featured YouTube videos."
|
||||
canonicalPath="/videos"
|
||||
>
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Videos</h2>
|
||||
<span class="muted">{videos.length} items</span>
|
||||
</div>
|
||||
{
|
||||
videos.length > 0 ? (
|
||||
<div class="grid">
|
||||
{videos.map((item) => (
|
||||
<ContentCard item={item} placement="videos.list" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty">
|
||||
No videos yet. Configure <code>YOUTUBE_CHANNEL_ID</code> and run{" "}
|
||||
<code>npm run fetch-content</code>.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
</BaseLayout>
|
||||
75
site/src/pages/videos/[id].astro
Normal file
75
site/src/pages/videos/[id].astro
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
import BaseLayout from "../../layouts/BaseLayout.astro";
|
||||
import { readContentCache } from "../../lib/content/cache";
|
||||
import { youtubeVideos } from "../../lib/content/selectors";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const cache = await readContentCache();
|
||||
const videos = youtubeVideos(cache);
|
||||
return videos.map((v) => ({ params: { id: v.id }, props: { video: v } }));
|
||||
}
|
||||
|
||||
const { video } = Astro.props;
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoObject",
|
||||
name: video.title,
|
||||
uploadDate: video.publishedAt,
|
||||
thumbnailUrl: video.thumbnailUrl ? [video.thumbnailUrl] : undefined,
|
||||
url: video.url,
|
||||
};
|
||||
|
||||
let videoDomain = "";
|
||||
try {
|
||||
videoDomain = new URL(video.url).hostname;
|
||||
} catch {
|
||||
videoDomain = "";
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${video.title} | SanthoshJ`}
|
||||
description={`Watch: ${video.title}`}
|
||||
canonicalPath={`/videos/${encodeURIComponent(video.id)}`}
|
||||
ogImageUrl={video.thumbnailUrl}
|
||||
>
|
||||
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>{video.title}</h2>
|
||||
<a
|
||||
class="muted"
|
||||
href="/videos"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="video_detail.back_to_videos"
|
||||
data-umami-event-placement="section_header"
|
||||
data-umami-event-target_url="/videos"
|
||||
>
|
||||
Back to videos →
|
||||
</a>
|
||||
</div>
|
||||
<div class="empty">
|
||||
<p style="margin-top: 0;">
|
||||
This page exists for SEO and sharing. The canonical watch page is YouTube.
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
class="cta primary"
|
||||
href={video.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="outbound_click"
|
||||
data-umami-event-target_id={`video_detail.watch.${video.id}`}
|
||||
data-umami-event-placement="video_detail"
|
||||
data-umami-event-target_url={video.url}
|
||||
data-umami-event-domain={videoDomain || "unknown"}
|
||||
data-umami-event-source="youtube"
|
||||
data-umami-event-ui_placement="video_detail"
|
||||
>
|
||||
Watch on YouTube
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
Reference in New Issue
Block a user