Initial commit - but way too late.
Some checks failed
ci / site (push) Has been cancelled

This commit is contained in:
2026-02-10 00:22:18 -05:00
commit af112a713c
173 changed files with 27667 additions and 0 deletions

View 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>

View 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>

View 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
View 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;
}

View 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
View 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",
};
}

View 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;
}
}

View 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 [];
}
}

View 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));
}

View 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[];
};

View 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,
}));
}

View 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);
}

View File

@@ -0,0 +1,6 @@
import type { ContentItem, ContentSource } from "../content/types";
export type IngestResult = {
source: ContentSource;
items: ContentItem[];
};

View 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
View 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
View 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();
}

View 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
View 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>

View 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>

View 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>

View 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>

View 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>