Initial commit: Indiekit Eleventy theme

This commit is contained in:
Ricardo
2026-01-24 12:13:34 +01:00
commit 2b225197b4
47 changed files with 9418 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Dependencies
node_modules/
# Build output
_site/
css/style.css
# Cache
.cache/
# Content (symlinked at runtime)
content/
uploads/
# Personal overrides (should be in parent repo)
*.rmendes
# OS files
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
*.swo

8
404.njk Normal file
View File

@@ -0,0 +1,8 @@
---
layout: layouts/base.njk
title: Page Not Found
permalink: /404.html
---
<h1>404 - Page Not Found</h1>
<p>Sorry, the page you're looking for doesn't exist.</p>
<p><a href="/">Go back to the homepage</a></p>

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# Indiekit Eleventy Theme
A modern, responsive Eleventy theme designed for [Indiekit](https://getindiekit.com/)-powered IndieWeb blogs.
## Features
- **IndieWeb Ready**: Full h-card, h-entry, h-feed microformats support
- **Dark Mode**: Automatic dark/light mode with manual toggle
- **Responsive**: Mobile-first design with Tailwind CSS
- **Social Integration**:
- Bluesky and Mastodon feeds in sidebar
- GitHub activity page
- Funkwhale listening history
- YouTube channel display
- **Performance**: Optimized images, lazy loading, prefetching
- **Accessible**: Semantic HTML, proper ARIA labels
## Usage
This theme is designed to be used as a git submodule in an Indiekit deployment:
```bash
git submodule add https://github.com/rmdes/indiekit-eleventy-theme.git eleventy-site
```
## Configuration
The theme uses environment variables for configuration. See the data files in `_data/` for required variables:
- `SITE_NAME`, `SITE_URL`, `SITE_DESCRIPTION`
- `AUTHOR_NAME`, `AUTHOR_BIO`, `AUTHOR_AVATAR`
- `GITHUB_USERNAME`, `BLUESKY_HANDLE`, `MASTODON_INSTANCE`
- And more...
## Directory Structure
```
├── _data/ # Eleventy data files (site config, API fetchers)
├── _includes/ # Layouts and components
├── css/ # Tailwind CSS source
├── images/ # Static images
├── *.njk # Page templates
├── eleventy.config.js
└── package.json
```
## Development
```bash
npm install
npm run dev
```
## License
MIT

68
_data/blueskyFeed.js Normal file
View File

@@ -0,0 +1,68 @@
/**
* Bluesky Feed Data
* Fetches recent posts from Bluesky using the AT Protocol API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { BskyAgent } from "@atproto/api";
export default async function () {
const handle = process.env.BLUESKY_HANDLE || "";
try {
// Create agent and resolve handle to DID
const agent = new BskyAgent({ service: "https://bsky.social" });
// Get the author's feed using public API (no auth needed for public posts)
const feedUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${handle}&limit=10`;
const response = await EleventyFetch(feedUrl, {
duration: "15m", // Cache for 15 minutes
type: "json",
fetchOptions: {
headers: {
Accept: "application/json",
},
},
});
if (!response.feed) {
console.log("No Bluesky feed found for handle:", handle);
return [];
}
// Transform the feed into a simpler format
return response.feed.map((item) => {
// Extract rkey from AT URI (at://did:plc:xxx/app.bsky.feed.post/rkey)
const rkey = item.post.uri.split("/").pop();
const postUrl = `https://bsky.app/profile/${item.post.author.handle}/post/${rkey}`;
return {
text: item.post.record.text,
createdAt: item.post.record.createdAt,
uri: item.post.uri,
url: postUrl,
cid: item.post.cid,
author: {
handle: item.post.author.handle,
displayName: item.post.author.displayName,
avatar: item.post.author.avatar,
},
likeCount: item.post.likeCount || 0,
repostCount: item.post.repostCount || 0,
replyCount: item.post.replyCount || 0,
// Extract any embedded links or images
embed: item.post.embed
? {
type: item.post.embed.$type,
images: item.post.embed.images || [],
external: item.post.embed.external || null,
}
: null,
};
});
} catch (error) {
console.error("Error fetching Bluesky feed:", error.message);
return [];
}
}

190
_data/cv.js Normal file
View File

@@ -0,0 +1,190 @@
/**
* CV Data - Easy to update!
*
* To add a new experience: Add an entry to the `experience` array
* To add a new project: Add an entry to the `projects` array
* To update skills: Modify the `skills` object
*/
export default {
// Last updated date - automatically set to build time
lastUpdated: new Date().toISOString().split("T")[0],
// Work Experience - Add new positions at the TOP of the array
experience: [
{
title: "Middleware Engineer",
company: "FGTB-ABVV",
location: "Brussels",
startDate: "2023-11",
endDate: null, // null = present
type: "full-time",
description: "Technology Specialist focusing on IT infrastructure and application delivery",
highlights: [
"Strategic migration of Java applications from legacy IBM Datapowers and PureApp systems",
"Containerized application deployment on VMware Linux and OpenShift Kubernetes clusters",
"Mastering OpenShift, Kubernetes, and Docker technologies"
]
},
{
title: "Solution Architect",
company: "OSINTukraine.com",
location: "Remote",
startDate: "2022-02",
endDate: null,
type: "volunteer",
description: "Open-source intelligence (OSINT) initiative for Ukraine conflict monitoring",
highlights: [
"Collection, archiving, translation, analysis and dissemination of critical information",
"Monitoring Russian Telegram channels with filtering, categorization, and archiving",
"Sub-projects: War crimes archive, Drones research, Location-related alerts system"
]
},
{
title: "DevOps Training",
company: "BeCode",
location: "Brussels",
startDate: "2021-09",
endDate: "2022-03",
type: "training",
description: "7-month intensive DevOps specialization",
highlights: [
"Vagrant and Ansible infrastructure as code for WordPress, Nginx, Redis",
"Docker Swarm cluster management",
"GitLab CI/CD with SonarQube security audits",
"Jenkins pipelines, Python basics, Prometheus/Grafana monitoring"
]
},
{
title: "CTO",
company: "DigitYser",
location: "Brussels",
startDate: "2018-10",
endDate: "2020-03",
type: "full-time",
description: "Digital flagship of tech communities in Brussels",
highlights: [
"Hosting infrastructure and automation",
"Integrations with digital marketing tools",
"Technical Event Management: Livestreaming, sound, video, photos"
]
},
{
title: "Solution Architect",
company: "Armada.digital",
location: "Brussels",
startDate: "2016-05",
endDate: "2021-12",
type: "freelance",
description: "Consultancy to amplify visibility of good causes",
highlights: [
"Custom communication and collaboration solutions",
"Empowering individuals and ethical businesses"
]
},
{
title: "FactChecking Platform",
company: "Journalistes Solidaires",
location: "Brussels",
startDate: "2020-03",
endDate: "2020-05",
type: "volunteer",
description: "Cloudron/Docker backend for factchecking workflow",
highlights: [
"WordPress with custom post types for COVID-19 disinformation monitoring"
]
},
{
title: "Event Manager",
company: "European Data Innovation Hub",
location: "Brussels",
startDate: "2019-02",
endDate: "2020-03",
type: "full-time",
description: "Technical event organization and management"
},
{
title: "Technical Advisor",
company: "WomenPreneur-Initiative",
location: "Brussels",
startDate: "2019-01",
endDate: "2020-01",
type: "volunteer",
description: "Technical guidance for women-focused entrepreneurship initiative"
},
{
title: "Technical Advisor",
company: "Promote Ukraine",
location: "Brussels",
startDate: "2019-01",
endDate: "2020-01",
type: "freelance",
description: "Technical consulting for Ukraine advocacy organization"
}
],
// Current/Recent Projects - Add new projects at the TOP
projects: [
{
name: "OSINT Intelligence Platform",
url: "https://osintukraine.com",
description: "Real-time monitoring and analysis platform for open-source intelligence",
technologies: ["Docker", "Telegram API", "Python", "PostgreSQL"],
status: "active"
},
{
name: "Indiekit Cloudron Package",
url: "https://github.com/rmdes/indiekit-cloudron",
description: "Cloudron-packaged IndieWeb publishing server with Eleventy frontend",
technologies: ["Node.js", "Eleventy", "Docker", "Cloudron"],
status: "active"
}
// Add more projects here as needed
],
// Skills - Organized by category
skills: {
containers: ["OpenShift", "Kubernetes", "Docker", "Docker Swarm"],
automation: ["Ansible", "Vagrant", "GitLab CI/CD", "Jenkins", "GitHub Actions"],
monitoring: ["Prometheus", "Grafana", "OpenTelemetry"],
systems: ["Linux Administration", "System Administration", "VMware"],
hosting: ["Cloudron", "On-Premise", "Cloud Infrastructure"],
web: ["Nginx", "Redis", "WordPress", "TLS/SSL", "Eleventy"],
security: ["SonarQube", "Information Assurance", "OSINT"],
languages: ["Python", "Bash", "JavaScript", "Node.js"]
},
// Languages spoken
languages: [
{ name: "Portuguese", level: "Native" },
{ name: "French", level: "Fluent" },
{ name: "English", level: "Fluent" },
{ name: "Spanish", level: "Conversational" }
],
// Education
education: [
{
degree: "DevOps Training",
institution: "BeCode",
location: "Brussels",
year: "2021-2022",
description: "7-month intensive DevOps specialization"
},
{
degree: "Bachelor's in Management Information Technology",
institution: "ISLA - Instituto Superior de Gestão e Tecnologia",
location: "Portugal",
year: "1998-2001",
description: "Curso Técnico Superior Profissional de Informática de Gestão"
}
],
// Interests
interests: [
"Music Production (Ableton Live, Ableton Push 3)",
"IndieWeb & Decentralized Tech",
"Open Source Intelligence (OSINT)",
"Democracy & Digital Rights"
]
};

123
_data/funkwhaleActivity.js Normal file
View File

@@ -0,0 +1,123 @@
/**
* Funkwhale Activity Data
* Fetches from Indiekit's endpoint-funkwhale public API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
const FUNKWHALE_INSTANCE = process.env.FUNKWHALE_INSTANCE || "";
/**
* Fetch from Indiekit's public Funkwhale API endpoint
*/
async function fetchFromIndiekit(endpoint) {
try {
const url = `${INDIEKIT_URL}/funkwhaleapi/api/${endpoint}`;
console.log(`[funkwhaleActivity] Fetching from Indiekit: ${url}`);
const data = await EleventyFetch(url, {
duration: "15m",
type: "json",
});
console.log(`[funkwhaleActivity] Indiekit ${endpoint} success`);
return data;
} catch (error) {
console.log(
`[funkwhaleActivity] Indiekit API unavailable for ${endpoint}: ${error.message}`
);
return null;
}
}
/**
* Format duration in seconds to human-readable string
*/
function formatDuration(seconds) {
if (!seconds || seconds < 0) return "0:00";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}d`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
export default async function () {
try {
console.log("[funkwhaleActivity] Fetching Funkwhale data...");
// Fetch all data from Indiekit API
const [nowPlaying, listenings, favorites, stats] = await Promise.all([
fetchFromIndiekit("now-playing"),
fetchFromIndiekit("listenings"),
fetchFromIndiekit("favorites"),
fetchFromIndiekit("stats"),
]);
// Check if we got data
const hasData = nowPlaying || listenings?.listenings?.length || stats?.summary;
if (!hasData) {
console.log("[funkwhaleActivity] No data available from Indiekit");
return {
nowPlaying: null,
listenings: [],
favorites: [],
stats: null,
instanceUrl: FUNKWHALE_INSTANCE,
source: "unavailable",
};
}
console.log("[funkwhaleActivity] Using Indiekit API data");
// Format stats with human-readable durations
let formattedStats = null;
if (stats?.summary) {
formattedStats = {
...stats,
summary: {
all: {
...stats.summary.all,
totalDurationFormatted: formatDuration(stats.summary.all?.totalDuration || 0),
},
month: {
...stats.summary.month,
totalDurationFormatted: formatDuration(stats.summary.month?.totalDuration || 0),
},
week: {
...stats.summary.week,
totalDurationFormatted: formatDuration(stats.summary.week?.totalDuration || 0),
},
},
};
}
return {
nowPlaying: nowPlaying || null,
listenings: listenings?.listenings || [],
favorites: favorites?.favorites || [],
stats: formattedStats,
instanceUrl: FUNKWHALE_INSTANCE,
source: "indiekit",
};
} catch (error) {
console.error("[funkwhaleActivity] Error:", error.message);
return {
nowPlaying: null,
listenings: [],
favorites: [],
stats: null,
instanceUrl: FUNKWHALE_INSTANCE,
source: "error",
};
}
}

228
_data/githubActivity.js Normal file
View File

@@ -0,0 +1,228 @@
/**
* GitHub Activity Data
* Fetches from Indiekit's endpoint-github public API
* Falls back to direct GitHub API if Indiekit is unavailable
*/
import EleventyFetch from "@11ty/eleventy-fetch";
const GITHUB_USERNAME = process.env.GITHUB_USERNAME || "";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
// Fallback featured repos if Indiekit API unavailable (from env: comma-separated)
const FALLBACK_FEATURED_REPOS = process.env.GITHUB_FEATURED_REPOS?.split(",").filter(Boolean) || [];
/**
* Fetch from Indiekit's public GitHub API endpoint
*/
async function fetchFromIndiekit(endpoint) {
try {
const url = `${INDIEKIT_URL}/githubapi/api/${endpoint}`;
console.log(`[githubActivity] Fetching from Indiekit: ${url}`);
const data = await EleventyFetch(url, {
duration: "15m",
type: "json",
});
console.log(`[githubActivity] Indiekit ${endpoint} success`);
return data;
} catch (error) {
console.log(
`[githubActivity] Indiekit API unavailable for ${endpoint}: ${error.message}`
);
return null;
}
}
/**
* Fetch from GitHub API directly
*/
async function fetchFromGitHub(endpoint) {
const url = `https://api.github.com${endpoint}`;
const headers = {
Accept: "application/vnd.github.v3+json",
"User-Agent": "Eleventy-Site",
};
if (process.env.GITHUB_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
return await EleventyFetch(url, {
duration: "15m",
type: "json",
fetchOptions: { headers },
});
}
/**
* Truncate text with ellipsis
*/
function truncate(text, maxLength = 80) {
if (!text || text.length <= maxLength) return text || "";
return text.slice(0, maxLength - 1) + "...";
}
/**
* Extract commits from push events
*/
function extractCommits(events) {
if (!Array.isArray(events)) return [];
return events
.filter((event) => event.type === "PushEvent")
.flatMap((event) =>
(event.payload?.commits || []).map((commit) => ({
sha: commit.sha.slice(0, 7),
message: truncate(commit.message.split("\n")[0]),
url: `https://github.com/${event.repo.name}/commit/${commit.sha}`,
repo: event.repo.name,
repoUrl: `https://github.com/${event.repo.name}`,
date: event.created_at,
}))
)
.slice(0, 10);
}
/**
* Extract PRs/Issues from events
*/
function extractContributions(events) {
if (!Array.isArray(events)) return [];
return events
.filter(
(event) =>
(event.type === "PullRequestEvent" || event.type === "IssuesEvent") &&
event.payload?.action === "opened"
)
.map((event) => {
const item = event.payload.pull_request || event.payload.issue;
return {
type: event.type === "PullRequestEvent" ? "pr" : "issue",
title: truncate(item?.title),
url: item?.html_url,
repo: event.repo.name,
repoUrl: `https://github.com/${event.repo.name}`,
number: item?.number,
date: event.created_at,
};
})
.slice(0, 10);
}
/**
* Format starred repos
*/
function formatStarred(repos) {
if (!Array.isArray(repos)) return [];
return repos.map((repo) => ({
name: repo.full_name,
description: truncate(repo.description, 120),
url: repo.html_url,
stars: repo.stargazers_count,
language: repo.language,
topics: repo.topics?.slice(0, 5) || [],
}));
}
/**
* Fetch featured repos directly from GitHub (fallback)
*/
async function fetchFeaturedFromGitHub(repoList) {
const featured = [];
for (const repoFullName of repoList) {
try {
const repo = await fetchFromGitHub(`/repos/${repoFullName}`);
let commits = [];
try {
const commitsData = await fetchFromGitHub(
`/repos/${repoFullName}/commits?per_page=5`
);
commits = commitsData.map((c) => ({
sha: c.sha.slice(0, 7),
message: truncate(c.commit.message.split("\n")[0]),
url: c.html_url,
date: c.commit.author.date,
}));
} catch (e) {
console.log(`[githubActivity] Could not fetch commits for ${repoFullName}`);
}
featured.push({
fullName: repo.full_name,
name: repo.name,
description: repo.description,
url: repo.html_url,
stars: repo.stargazers_count,
forks: repo.forks_count,
language: repo.language,
isPrivate: repo.private,
commits,
});
} catch (error) {
console.log(`[githubActivity] Could not fetch ${repoFullName}: ${error.message}`);
}
}
return featured;
}
export default async function () {
try {
console.log("[githubActivity] Fetching GitHub data...");
// Try Indiekit public API first
const [indiekitStars, indiekitCommits, indiekitActivity, indiekitFeatured] =
await Promise.all([
fetchFromIndiekit("stars"),
fetchFromIndiekit("commits"),
fetchFromIndiekit("activity"),
fetchFromIndiekit("featured"),
]);
// Check if Indiekit API is available
const hasIndiekitData =
indiekitStars?.stars ||
indiekitCommits?.commits ||
indiekitFeatured?.featured;
if (hasIndiekitData) {
console.log("[githubActivity] Using Indiekit API data");
return {
stars: indiekitStars?.stars || [],
commits: indiekitCommits?.commits || [],
activity: indiekitActivity?.activity || [],
featured: indiekitFeatured?.featured || [],
source: "indiekit",
};
}
// Fallback to direct GitHub API
console.log("[githubActivity] Falling back to GitHub API");
const [events, starred, featured] = await Promise.all([
fetchFromGitHub(`/users/${GITHUB_USERNAME}/events/public?per_page=50`),
fetchFromGitHub(`/users/${GITHUB_USERNAME}/starred?per_page=20&sort=created`),
fetchFeaturedFromGitHub(FALLBACK_FEATURED_REPOS),
]);
return {
stars: formatStarred(starred || []),
commits: extractCommits(events || []),
contributions: extractContributions(events || []),
featured,
source: "github",
};
} catch (error) {
console.error("[githubActivity] Error:", error.message);
return {
stars: [],
commits: [],
contributions: [],
featured: [],
source: "error",
};
}
}

48
_data/githubRepos.js Normal file
View File

@@ -0,0 +1,48 @@
/**
* GitHub Repos Data
* Fetches public repositories from GitHub API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
export default async function () {
const username = process.env.GITHUB_USERNAME || "";
try {
// Fetch public repos, sorted by updated date
const url = `https://api.github.com/users/${username}/repos?sort=updated&per_page=10&type=owner`;
const repos = await EleventyFetch(url, {
duration: "1h", // Cache for 1 hour
type: "json",
fetchOptions: {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "Eleventy-Site",
},
},
});
// Filter and transform repos
return repos
.filter((repo) => !repo.fork && !repo.private) // Exclude forks and private repos
.map((repo) => ({
name: repo.name,
full_name: repo.full_name,
description: repo.description,
html_url: repo.html_url,
homepage: repo.homepage,
language: repo.language,
stargazers_count: repo.stargazers_count,
forks_count: repo.forks_count,
open_issues_count: repo.open_issues_count,
topics: repo.topics || [],
updated_at: repo.updated_at,
created_at: repo.created_at,
}))
.slice(0, 10); // Limit to 10 repos
} catch (error) {
console.error("Error fetching GitHub repos:", error.message);
return [];
}
}

96
_data/mastodonFeed.js Normal file
View File

@@ -0,0 +1,96 @@
/**
* Mastodon Feed Data
* Fetches recent posts from Mastodon using the public API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
export default async function () {
const instance = process.env.MASTODON_INSTANCE?.replace("https://", "") || "";
const username = process.env.MASTODON_USER || "";
try {
// First, look up the account ID
const lookupUrl = `https://${instance}/api/v1/accounts/lookup?acct=${username}`;
const account = await EleventyFetch(lookupUrl, {
duration: "1h", // Cache account lookup for 1 hour
type: "json",
fetchOptions: {
headers: {
Accept: "application/json",
},
},
});
if (!account || !account.id) {
console.log("Mastodon account not found:", username);
return [];
}
// Fetch recent statuses (excluding replies and boosts for cleaner feed)
const statusesUrl = `https://${instance}/api/v1/accounts/${account.id}/statuses?limit=10&exclude_replies=true&exclude_reblogs=true`;
const statuses = await EleventyFetch(statusesUrl, {
duration: "15m", // Cache for 15 minutes
type: "json",
fetchOptions: {
headers: {
Accept: "application/json",
},
},
});
if (!statuses || !Array.isArray(statuses)) {
console.log("No Mastodon statuses found for:", username);
return [];
}
// Transform statuses into a simpler format
return statuses.map((status) => ({
id: status.id,
url: status.url,
text: stripHtml(status.content),
htmlContent: status.content,
createdAt: status.created_at,
author: {
username: status.account.username,
displayName: status.account.display_name || status.account.username,
avatar: status.account.avatar,
url: status.account.url,
},
favouritesCount: status.favourites_count || 0,
reblogsCount: status.reblogs_count || 0,
repliesCount: status.replies_count || 0,
// Media attachments
media: status.media_attachments
? status.media_attachments.map((m) => ({
type: m.type,
url: m.url,
previewUrl: m.preview_url,
description: m.description,
}))
: [],
}));
} catch (error) {
console.error("Error fetching Mastodon feed:", error.message);
return [];
}
}
// Simple HTML stripper for plain text display
function stripHtml(html) {
if (!html) return "";
return html
.replace(/<br\s*\/?>/gi, " ")
.replace(/<\/p>/gi, " ")
.replace(/<[^>]+>/g, "")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}

66
_data/site.js Normal file
View File

@@ -0,0 +1,66 @@
/**
* Site configuration for Eleventy
*
* Configure via environment variables in Cloudron app settings.
* All values have sensible defaults for initial deployment.
*/
// Parse social links from env (format: "name|url|icon,name|url|icon")
function parseSocialLinks(envVar) {
if (!envVar) return [];
return envVar.split(",").map((link) => {
const [name, url, icon] = link.split("|").map((s) => s.trim());
return { name, url, rel: "me", icon: icon || name.toLowerCase() };
});
}
// Default social links if none configured
const defaultSocial = [
{
name: "GitHub",
url: "https://github.com/",
rel: "me",
icon: "github",
},
];
export default {
// Basic site info
name: process.env.SITE_NAME || "My IndieWeb Blog",
url: process.env.SITE_URL || "https://example.com",
me: process.env.SITE_URL || "https://example.com",
locale: process.env.SITE_LOCALE || "en",
description:
process.env.SITE_DESCRIPTION ||
"An IndieWeb-powered blog with Micropub support",
// Author info (shown in h-card, about page, etc.)
author: {
name: process.env.AUTHOR_NAME || "Blog Author",
url: process.env.SITE_URL || "https://example.com",
avatar: process.env.AUTHOR_AVATAR || "/images/default-avatar.svg",
title: process.env.AUTHOR_TITLE || "",
bio: process.env.AUTHOR_BIO || "Welcome to my IndieWeb blog.",
location: process.env.AUTHOR_LOCATION || "",
email: process.env.AUTHOR_EMAIL || "",
},
// Social links (for rel="me" and footer)
// Set SITE_SOCIAL env var as: "GitHub|https://github.com/user|github,Mastodon|https://mastodon.social/@user|mastodon"
social: parseSocialLinks(process.env.SITE_SOCIAL) || defaultSocial,
// Feed integrations (usernames for data fetching)
feeds: {
github: process.env.GITHUB_USERNAME || "",
bluesky: process.env.BLUESKY_HANDLE || "",
mastodon: {
instance: process.env.MASTODON_INSTANCE?.replace("https://", "") || "",
username: process.env.MASTODON_USER || "",
},
},
// Webmentions configuration
webmentions: {
domain: process.env.SITE_URL?.replace("https://", "").replace("http://", "") || "example.com",
},
};

206
_data/youtubeChannel.js Normal file
View File

@@ -0,0 +1,206 @@
/**
* YouTube Channel Data
* Fetches from Indiekit's endpoint-youtube public API
* Supports single or multiple channels
*/
import EleventyFetch from "@11ty/eleventy-fetch";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
/**
* Fetch from Indiekit's public YouTube API endpoint
*/
async function fetchFromIndiekit(endpoint) {
try {
const url = `${INDIEKIT_URL}/youtubeapi/api/${endpoint}`;
console.log(`[youtubeChannel] Fetching from Indiekit: ${url}`);
const data = await EleventyFetch(url, {
duration: "5m",
type: "json",
});
console.log(`[youtubeChannel] Indiekit ${endpoint} success`);
return data;
} catch (error) {
console.log(
`[youtubeChannel] Indiekit API unavailable for ${endpoint}: ${error.message}`
);
return null;
}
}
/**
* Format large numbers with locale separators
*/
function formatNumber(num) {
if (!num) return "0";
return new Intl.NumberFormat().format(num);
}
/**
* Format view count with K/M suffix for compact display
*/
function formatViewCount(num) {
if (!num) return "0";
if (num >= 1000000) {
return (num / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
}
if (num >= 1000) {
return (num / 1000).toFixed(1).replace(/\\.0$/, "") + "K";
}
return num.toString();
}
/**
* Format relative time from ISO date string
*/
function formatRelativeTime(dateString) {
if (!dateString) return "";
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Yesterday";
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
return `${Math.floor(diffDays / 365)} years ago`;
}
/**
* Format channel data with computed fields
*/
function formatChannel(channel) {
if (!channel) return null;
return {
...channel,
subscriberCountFormatted: formatNumber(channel.subscriberCount),
videoCountFormatted: formatNumber(channel.videoCount),
viewCountFormatted: formatNumber(channel.viewCount),
url: `https://www.youtube.com/channel/${channel.id}`,
};
}
/**
* Format video data with computed fields
*/
function formatVideo(video) {
return {
...video,
viewCountFormatted: formatViewCount(video.viewCount),
relativeTime: formatRelativeTime(video.publishedAt),
};
}
export default async function () {
try {
console.log("[youtubeChannel] Fetching YouTube data...");
// Fetch all data from Indiekit API
const [channelData, videosData, liveData] = await Promise.all([
fetchFromIndiekit("channel"),
fetchFromIndiekit("videos"),
fetchFromIndiekit("live"),
]);
// Check if we got data
const hasData =
channelData?.channel ||
channelData?.channels?.length ||
videosData?.videos?.length;
if (!hasData) {
console.log("[youtubeChannel] No data available from Indiekit");
return {
channel: null,
channels: [],
videos: [],
videosByChannel: {},
liveStatus: null,
liveStatuses: [],
isMultiChannel: false,
source: "unavailable",
};
}
console.log("[youtubeChannel] Using Indiekit API data");
// Determine if multi-channel mode
const isMultiChannel = !!(channelData?.channels && channelData.channels.length > 1);
// Format channels
let channels = [];
let channel = null;
if (isMultiChannel) {
channels = (channelData.channels || []).map(formatChannel).filter(Boolean);
channel = channels[0] || null;
} else {
channel = formatChannel(channelData?.channel);
channels = channel ? [channel] : [];
}
// Format videos
const videos = (videosData?.videos || []).map(formatVideo);
// Group videos by channel if multi-channel
let videosByChannel = {};
if (isMultiChannel && videosData?.videosByChannel) {
for (const [channelName, channelVideos] of Object.entries(videosData.videosByChannel)) {
videosByChannel[channelName] = (channelVideos || []).map(formatVideo);
}
} else if (channel) {
videosByChannel[channel.configName || channel.title] = videos;
}
// Format live status
let liveStatus = null;
let liveStatuses = [];
if (liveData) {
if (isMultiChannel && liveData.liveStatuses) {
liveStatuses = liveData.liveStatuses;
// Find first live or upcoming
const live = liveStatuses.find((s) => s.isLive);
const upcoming = liveStatuses.find((s) => s.isUpcoming && !s.isLive);
liveStatus = {
isLive: !!live,
isUpcoming: !live && !!upcoming,
stream: live?.stream || upcoming?.stream || null,
};
} else {
liveStatus = {
isLive: liveData.isLive || false,
isUpcoming: liveData.isUpcoming || false,
stream: liveData.stream || null,
};
liveStatuses = [{ ...liveStatus, channelConfigName: channel?.configName }];
}
}
return {
channel,
channels,
videos,
videosByChannel,
liveStatus,
liveStatuses,
isMultiChannel,
source: "indiekit",
};
} catch (error) {
console.error("[youtubeChannel] Error:", error.message);
return {
channel: null,
channels: [],
videos: [],
videosByChannel: {},
liveStatus: null,
liveStatuses: [],
isMultiChannel: false,
source: "error",
};
}
}

View File

@@ -0,0 +1,184 @@
{# Blog Sidebar - Shown on individual post pages #}
{# Contains: Author compact card, Related posts, Categories, Recent posts #}
{# Author Compact Card #}
<div class="widget">
<div class="h-card flex items-center gap-3">
<img
src="{{ site.author.avatar }}"
alt="{{ site.author.name }}"
class="u-photo w-12 h-12 rounded-full object-cover"
loading="lazy"
>
<div>
<a href="{{ site.author.url }}" class="u-url p-name font-medium text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400">
{{ site.author.name }}
</a>
<p class="p-job-title text-xs text-surface-500">{{ site.author.title }}</p>
</div>
</div>
</div>
{# Post Navigation Widget - Previous/Next #}
{% if previousPost or nextPost %}
<div class="widget">
<h3 class="widget-title">More Posts</h3>
<div class="space-y-3">
{% if previousPost %}
<div class="border-b border-surface-200 dark:border-surface-700 pb-3">
<span class="text-xs text-surface-500 uppercase tracking-wide block mb-1">Previous</span>
<a href="{{ previousPost.url }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline line-clamp-2">
{{ previousPost.data.title or previousPost.data.name or "Untitled" }}
</a>
</div>
{% endif %}
{% if nextPost %}
<div>
<span class="text-xs text-surface-500 uppercase tracking-wide block mb-1">Next</span>
<a href="{{ nextPost.url }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline line-clamp-2">
{{ nextPost.data.title or nextPost.data.name or "Untitled" }}
</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
{# Table of Contents Widget (for articles with headings) #}
{% if toc and toc.length %}
<div class="widget">
<h3 class="widget-title">Contents</h3>
<nav class="toc">
<ul class="space-y-1 text-sm">
{% for item in toc %}
<li class="{% if item.level > 2 %}ml-{{ (item.level - 2) * 3 }}{% endif %}">
<a href="#{{ item.slug }}" class="text-surface-600 dark:text-surface-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors">
{{ item.text }}
</a>
</li>
{% endfor %}
</ul>
</nav>
</div>
{% endif %}
{# Categories for This Post #}
{% if category %}
<div class="widget">
<h3 class="widget-title">Categories</h3>
<div class="flex flex-wrap gap-2">
{% if category is string %}
<a href="/categories/{{ category | slugify }}/" class="p-category text-xs px-2 py-1 bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300 rounded-full hover:bg-primary-200 dark:hover:bg-primary-800 transition-colors">
{{ category }}
</a>
{% else %}
{% for cat in category %}
<a href="/categories/{{ cat | slugify }}/" class="p-category text-xs px-2 py-1 bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300 rounded-full hover:bg-primary-200 dark:hover:bg-primary-800 transition-colors">
{{ cat }}
</a>
{% endfor %}
{% endif %}
</div>
</div>
{% endif %}
{# Recent Posts Widget #}
{% if collections.posts %}
<div class="widget">
<h3 class="widget-title">Recent Posts</h3>
<ul class="space-y-2">
{% for post in collections.posts | head(5) %}
{% if post.url != page.url %}
<li>
<a href="{{ post.url }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline line-clamp-2">
{{ post.data.title or post.data.name or "Untitled" }}
</a>
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published or post.date }}">
{{ (post.data.published or post.date) | date("MMM d, yyyy") }}
</time>
</li>
{% endif %}
{% endfor %}
</ul>
<a href="/blog/" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 inline-block">
View all posts
</a>
</div>
{% endif %}
{# Webmentions Widget (if this post has any) #}
{% if webmentions and webmentions.length %}
<div class="widget">
<h3 class="widget-title flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
Interactions
</h3>
<div class="space-y-2">
{% set likes = webmentions | filter("like-of") %}
{% set reposts = webmentions | filter("repost-of") %}
{% set replies = webmentions | filter("in-reply-to") %}
{% if likes.length %}
<p class="text-sm text-surface-600 dark:text-surface-400">
<span class="font-medium text-surface-900 dark:text-surface-100">{{ likes.length }}</span> likes
</p>
{% endif %}
{% if reposts.length %}
<p class="text-sm text-surface-600 dark:text-surface-400">
<span class="font-medium text-surface-900 dark:text-surface-100">{{ reposts.length }}</span> reposts
</p>
{% endif %}
{% if replies.length %}
<p class="text-sm text-surface-600 dark:text-surface-400">
<span class="font-medium text-surface-900 dark:text-surface-100">{{ replies.length }}</span> replies
</p>
{% endif %}
</div>
</div>
{% endif %}
{# Share Widget #}
<div class="widget">
<h3 class="widget-title">Share</h3>
<div class="flex gap-2">
<a href="https://bsky.app/intent/compose?text={{ title | urlencode }}%20{{ site.url }}{{ page.url | urlencode }}"
target="_blank"
rel="noopener"
class="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-[#0085ff]/10 text-[#0085ff] hover:bg-[#0085ff]/20 transition-colors text-sm font-medium"
title="Share on Bluesky">
<svg class="w-4 h-4" viewBox="0 0 568 501" fill="currentColor">
<path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/>
</svg>
</a>
<a href="https://{{ site.feeds.mastodon.instance }}/share?text={{ title | urlencode }}%20{{ site.url }}{{ page.url | urlencode }}"
target="_blank"
rel="noopener"
class="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-[#6364ff]/10 text-[#6364ff] hover:bg-[#6364ff]/20 transition-colors text-sm font-medium"
title="Share on Mastodon">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
</svg>
</a>
</div>
</div>
{# Subscribe Widget #}
<div class="widget">
<h3 class="widget-title">Subscribe</h3>
<div class="space-y-2">
<a href="/feed.xml" class="flex items-center gap-2 text-sm text-surface-600 dark:text-surface-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors">
<svg class="w-4 h-4 text-orange-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1Z"/>
</svg>
RSS Feed
</a>
<a href="/feed.json" class="flex items-center gap-2 text-sm text-surface-600 dark:text-surface-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors">
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m3 12h2v2H8v-2m4-8h2v10h-2V7m4 4h2v6h-2v-6Z"/>
</svg>
JSON Feed
</a>
</div>
</div>

View File

@@ -0,0 +1,66 @@
{# Stats Summary Cards #}
{% if summary %}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<div class="p-4 bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 text-center">
<span class="text-2xl font-bold text-primary-600 dark:text-primary-400 block">{{ summary.totalPlays or 0 }}</span>
<span class="text-xs text-surface-500 uppercase tracking-wide">Plays</span>
</div>
<div class="p-4 bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 text-center">
<span class="text-2xl font-bold text-primary-600 dark:text-primary-400 block">{{ summary.uniqueTracks or 0 }}</span>
<span class="text-xs text-surface-500 uppercase tracking-wide">Tracks</span>
</div>
<div class="p-4 bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 text-center">
<span class="text-2xl font-bold text-primary-600 dark:text-primary-400 block">{{ summary.uniqueArtists or 0 }}</span>
<span class="text-xs text-surface-500 uppercase tracking-wide">Artists</span>
</div>
<div class="p-4 bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 text-center">
<span class="text-2xl font-bold text-primary-600 dark:text-primary-400 block">{{ summary.totalDurationFormatted or '0m' }}</span>
<span class="text-xs text-surface-500 uppercase tracking-wide">Listened</span>
</div>
</div>
{% endif %}
{# Top Artists #}
{% if topArtists and topArtists.length %}
<div class="mb-8">
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-4">Top Artists</h3>
<div class="space-y-2">
{% for artist in topArtists | head(5) %}
<div class="flex items-center gap-3 p-3 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
<span class="w-6 h-6 flex items-center justify-center text-sm font-bold text-surface-400 bg-surface-100 dark:bg-surface-700 rounded-full">{{ loop.index }}</span>
<span class="flex-1 font-medium text-surface-900 dark:text-surface-100">{{ artist.name }}</span>
<span class="text-sm text-surface-500">{{ artist.playCount }} plays</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# Top Albums #}
{% if topAlbums and topAlbums.length %}
<div>
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-4">Top Albums</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{% for album in topAlbums | head(5) %}
<div class="text-center">
{% if album.coverUrl %}
<img src="{{ album.coverUrl }}" alt="" class="w-full aspect-square object-cover rounded-lg mb-2" loading="lazy">
{% else %}
<div class="w-full aspect-square bg-surface-200 dark:bg-surface-700 rounded-lg mb-2 flex items-center justify-center">
<svg class="w-8 h-8 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z"/>
</svg>
</div>
{% endif %}
<p class="text-sm font-medium text-surface-900 dark:text-surface-100 truncate">{{ album.title }}</p>
<p class="text-xs text-surface-500 truncate">{{ album.artist }}</p>
<p class="text-xs text-surface-400">{{ album.playCount }} plays</p>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if not summary and not topArtists and not topAlbums %}
<p class="text-surface-600 dark:text-surface-400">No statistics available for this period.</p>
{% endif %}

View File

@@ -0,0 +1,64 @@
{# h-card - IndieWeb identity microformat #}
{# See: https://microformats.org/wiki/h-card #}
<div class="h-card p-author" itemscope itemtype="http://schema.org/Person">
{# Avatar #}
<a href="{{ site.author.url }}" class="u-url u-uid hidden" rel="me" itemprop="url">
<img
class="u-photo hidden"
src="{{ site.author.avatar }}"
alt="{{ site.author.name }}"
loading="lazy"
itemprop="image"
>
</a>
{# Name and identity #}
<span class="p-name font-semibold text-surface-900 dark:text-surface-100" itemprop="name">
{{ site.author.name }}
</span>
{# Title/role #}
{% if site.author.title %}
<span class="p-job-title text-surface-600 dark:text-surface-400 text-sm" itemprop="jobTitle">
{{ site.author.title }}
</span>
{% endif %}
{# Location #}
{% if site.author.location %}
<span class="p-locality hidden" itemprop="address" itemscope itemtype="http://schema.org/PostalAddress">
<span itemprop="addressLocality">{{ site.author.location }}</span>
</span>
{% endif %}
{# Social links with rel="me" #}
<nav class="flex flex-wrap gap-3 mt-2" aria-label="Social links">
{% for link in site.social %}
<a
href="{{ link.url }}"
rel="{{ link.rel }} noopener"
class="text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
aria-label="{{ link.name }}"
target="_blank">
{% if link.icon == "github" %}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path>
</svg>
{% elif link.icon == "linkedin" %}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"></path>
</svg>
{% elif link.icon == "bluesky" %}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"></path>
</svg>
{% elif link.icon == "mastodon" %}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12v6.406z"></path>
</svg>
{% endif %}
</a>
{% endfor %}
</nav>
</div>

View File

@@ -0,0 +1,63 @@
{# Reply Context Component #}
{# Displays rich context for replies, likes, reposts, and bookmarks #}
{# Uses h-cite microformat for citing external content #}
{% if in_reply_to or like_of or repost_of or bookmark_of %}
<aside class="reply-context p-4 mb-6 bg-surface-100 dark:bg-surface-800 rounded-lg border-l-4 border-primary-500">
{% if in_reply_to %}
<div class="u-in-reply-to h-cite">
<p class="text-sm text-surface-500 dark:text-surface-400 mb-2 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
<span>In reply to:</span>
</p>
<a class="u-url font-medium text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ in_reply_to }}">
{{ in_reply_to }}
</a>
</div>
{% endif %}
{% if like_of %}
<div class="u-like-of h-cite">
<p class="text-sm text-surface-500 dark:text-surface-400 mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
<span>Liked:</span>
</p>
<a class="u-url font-medium text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ like_of }}">
{{ like_of }}
</a>
</div>
{% endif %}
{% if repost_of %}
<div class="u-repost-of h-cite">
<p class="text-sm text-surface-500 dark:text-surface-400 mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span>Reposted:</span>
</p>
<a class="u-url font-medium text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ repost_of }}">
{{ repost_of }}
</a>
</div>
{% endif %}
{% if bookmark_of %}
<div class="u-bookmark-of h-cite">
<p class="text-sm text-surface-500 dark:text-surface-400 mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/>
</svg>
<span>Bookmarked:</span>
</p>
<a class="u-url font-medium text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ bookmark_of }}">
{{ bookmark_of }}
</a>
</div>
{% endif %}
</aside>
{% endif %}

View File

@@ -0,0 +1,258 @@
{# Sidebar Components #}
{# Contains: Author card, Bluesky feed, GitHub repos, RSS feed #}
{# Author Card Widget #}
<div class="widget">
<div class="h-card flex items-center gap-4">
<img
src="{{ site.author.avatar }}"
alt="{{ site.author.name }}"
class="u-photo w-16 h-16 rounded-full object-cover"
loading="lazy"
>
<div>
<a href="{{ site.author.url }}" class="u-url p-name font-bold text-lg block hover:text-primary-600 dark:hover:text-primary-400">
{{ site.author.name }}
</a>
<p class="p-job-title text-sm text-surface-600 dark:text-surface-400">{{ site.author.title }}</p>
<p class="p-locality text-sm text-surface-500 dark:text-surface-500">{{ site.author.location }}</p>
</div>
</div>
<p class="p-note mt-3 text-sm text-surface-700 dark:text-surface-300">{{ site.author.bio }}</p>
</div>
{# Social Feed Widget - Tabbed Bluesky/Mastodon #}
{% if (blueskyFeed and blueskyFeed.length) or (mastodonFeed and mastodonFeed.length) %}
<div class="widget" x-data="{ activeTab: 'bluesky' }">
<h3 class="widget-title">Social Activity</h3>
{# Tab buttons #}
<div class="flex gap-1 mb-4 border-b border-surface-200 dark:border-surface-700">
{% if blueskyFeed and blueskyFeed.length %}
<button
@click="activeTab = 'bluesky'"
:class="activeTab === 'bluesky' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors -mb-px"
>
<svg class="w-4 h-4 text-[#0085ff]" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"></path>
</svg>
Bluesky
</button>
{% endif %}
{% if mastodonFeed and mastodonFeed.length %}
<button
@click="activeTab = 'mastodon'"
:class="activeTab === 'mastodon' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors -mb-px"
>
<svg class="w-4 h-4 text-[#6364ff]" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12v6.406z"></path>
</svg>
Mastodon
</button>
{% endif %}
</div>
{# Bluesky Tab Content #}
{% if blueskyFeed and blueskyFeed.length %}
<div x-show="activeTab === 'bluesky'" x-cloak>
<ul class="space-y-3">
{% for post in blueskyFeed | head(5) %}
<li class="border-b border-surface-200 dark:border-surface-700 pb-3 last:border-0">
<a href="{{ post.url }}" target="_blank" rel="noopener" class="block group">
<p class="text-sm text-surface-700 dark:text-surface-300 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
{{ post.text | truncate(140) }}
</p>
<div class="flex items-center gap-3 mt-2 text-xs text-surface-500">
<time datetime="{{ post.createdAt }}">{{ post.createdAt | date("MMM d, yyyy") }}</time>
{% if post.likeCount > 0 %}
<span class="flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
{{ post.likeCount }}
</span>
{% endif %}
</div>
</a>
</li>
{% endfor %}
</ul>
<a href="https://bsky.app/profile/{{ site.feeds.bluesky }}" target="_blank" rel="noopener" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 inline-flex items-center gap-1">
View on Bluesky
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
{% endif %}
{# Mastodon Tab Content #}
{% if mastodonFeed and mastodonFeed.length %}
<div x-show="activeTab === 'mastodon'" x-cloak>
<ul class="space-y-3">
{% for post in mastodonFeed | head(5) %}
<li class="border-b border-surface-200 dark:border-surface-700 pb-3 last:border-0">
<a href="{{ post.url }}" target="_blank" rel="noopener" class="block group">
<p class="text-sm text-surface-700 dark:text-surface-300 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
{{ post.text | truncate(140) }}
</p>
<div class="flex items-center gap-3 mt-2 text-xs text-surface-500">
<time datetime="{{ post.createdAt }}">{{ post.createdAt | date("MMM d, yyyy") }}</time>
{% if post.favouritesCount > 0 %}
<span class="flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
{{ post.favouritesCount }}
</span>
{% endif %}
</div>
</a>
</li>
{% endfor %}
</ul>
<a href="https://{{ site.feeds.mastodon.instance }}/@{{ site.feeds.mastodon.username }}" target="_blank" rel="noopener" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 inline-flex items-center gap-1">
View on Mastodon
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
{% endif %}
</div>
{% endif %}
{# GitHub Repos Widget #}
{% if githubRepos and githubRepos.length %}
<div class="widget">
<h3 class="widget-title flex items-center gap-2">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path>
</svg>
GitHub Projects
</h3>
<ul class="space-y-3">
{% for repo in githubRepos | head(5) %}
<li class="repo-card">
<a href="{{ repo.html_url }}" class="font-medium text-primary-600 dark:text-primary-400 hover:underline" target="_blank" rel="noopener">
{{ repo.name }}
</a>
{% if repo.description %}
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">{{ repo.description | truncate(80) }}</p>
{% endif %}
<div class="repo-meta">
{% if repo.language %}
<span>{{ repo.language }}</span>
{% endif %}
<span>{{ repo.stargazers_count }} stars</span>
</div>
</li>
{% endfor %}
</ul>
<a href="https://github.com/{{ site.feeds.github }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 block">
View all repositories
</a>
</div>
{% endif %}
{# Funkwhale Now Playing Widget #}
{% if funkwhaleActivity and (funkwhaleActivity.nowPlaying or funkwhaleActivity.stats) %}
<div class="widget">
<h3 class="widget-title flex items-center gap-2">
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
Listening
</h3>
{# Now Playing / Recently Played #}
{% if funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.track %}
<div class="{% if funkwhaleActivity.nowPlaying.status == 'now-playing' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800{% else %}bg-surface-50 dark:bg-surface-800{% endif %} rounded-lg p-3 mb-3">
{% if funkwhaleActivity.nowPlaying.status == 'now-playing' %}
<div class="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mb-2">
<span class="flex gap-0.5 items-end h-2.5">
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 30%;"></span>
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 70%; animation-delay: 0.2s;"></span>
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span>
</span>
Now Playing
</div>
{% elif funkwhaleActivity.nowPlaying.status == 'recently-played' %}
<div class="text-xs text-surface-500 mb-2">Recently Played</div>
{% endif %}
<div class="flex items-center gap-3">
{% if funkwhaleActivity.nowPlaying.coverUrl %}
<img src="{{ funkwhaleActivity.nowPlaying.coverUrl }}" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy">
{% endif %}
<div class="min-w-0 flex-1">
<p class="font-medium text-sm text-surface-900 dark:text-surface-100 truncate">
{% if funkwhaleActivity.nowPlaying.trackUrl %}
<a href="{{ funkwhaleActivity.nowPlaying.trackUrl }}" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank" rel="noopener">
{{ funkwhaleActivity.nowPlaying.track }}
</a>
{% else %}
{{ funkwhaleActivity.nowPlaying.track }}
{% endif %}
</p>
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ funkwhaleActivity.nowPlaying.artist }}</p>
</div>
</div>
</div>
{% endif %}
{# Quick Stats #}
{% if funkwhaleActivity.stats and funkwhaleActivity.stats.summary %}
{% set stats = funkwhaleActivity.stats.summary.all %}
<div class="grid grid-cols-3 gap-2 text-center mb-3">
<div class="p-2 bg-surface-50 dark:bg-surface-800 rounded">
<span class="text-lg font-bold text-primary-600 dark:text-primary-400 block">{{ stats.totalPlays or 0 }}</span>
<span class="text-[10px] text-surface-500 uppercase">plays</span>
</div>
<div class="p-2 bg-surface-50 dark:bg-surface-800 rounded">
<span class="text-lg font-bold text-primary-600 dark:text-primary-400 block">{{ stats.uniqueArtists or 0 }}</span>
<span class="text-[10px] text-surface-500 uppercase">artists</span>
</div>
<div class="p-2 bg-surface-50 dark:bg-surface-800 rounded">
<span class="text-lg font-bold text-primary-600 dark:text-primary-400 block">{{ stats.totalDurationFormatted or '0m' }}</span>
<span class="text-[10px] text-surface-500 uppercase">listened</span>
</div>
</div>
{% endif %}
<a href="/funkwhale/" class="text-sm text-primary-600 dark:text-primary-400 hover:underline flex items-center gap-1">
View full listening history
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</a>
</div>
{% endif %}
{# Recent Posts Widget (for non-blog pages) #}
{% if recentPosts and recentPosts.length %}
<div class="widget">
<h3 class="widget-title">Recent Posts</h3>
<ul class="space-y-2">
{% for post in recentPosts | head(5) %}
<li>
<a href="{{ post.url }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">
{{ post.data.title or post.data.name or "Untitled" }}
</a>
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published }}">
{{ post.data.published | date("MMM d, yyyy") }}
</time>
</li>
{% endfor %}
</ul>
<a href="/posts/" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 block">
View all posts
</a>
</div>
{% endif %}
{# Categories/Tags Widget #}
{% if categories and categories.length %}
<div class="widget">
<h3 class="widget-title">Categories</h3>
<div class="flex flex-wrap gap-2">
{% for category in categories %}
<a href="/categories/{{ category | slugify }}/" class="p-category">
{{ category }}
</a>
{% endfor %}
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,158 @@
{# Webmentions Component #}
{# Displays likes, reposts, and replies for a post #}
{% set mentions = webmentions | webmentionsForUrl(page.url) %}
{% if mentions.length %}
<section class="webmentions mt-8 pt-8 border-t border-surface-200 dark:border-surface-700" id="webmentions">
<h2 class="text-xl font-bold text-surface-900 dark:text-surface-100 mb-6">
Webmentions ({{ mentions.length }})
</h2>
{# Likes #}
{% set likes = mentions | webmentionsByType('likes') %}
{% if likes.length %}
<div class="webmention-likes mb-6">
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">
{{ likes.length }} Like{% if likes.length != 1 %}s{% endif %}
</h3>
<div class="avatar-row">
{% for like in likes %}
<a href="{{ like.author.url }}"
class="inline-block"
title="{{ like.author.name }}"
target="_blank"
rel="noopener">
<img
src="{{ like.author.photo or '/images/default-avatar.png' }}"
alt="{{ like.author.name }}"
class="w-8 h-8 rounded-full"
loading="lazy"
>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{# Reposts #}
{% set reposts = mentions | webmentionsByType('reposts') %}
{% if reposts.length %}
<div class="webmention-reposts mb-6">
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">
{{ reposts.length }} Repost{% if reposts.length != 1 %}s{% endif %}
</h3>
<div class="avatar-row">
{% for repost in reposts %}
<a href="{{ repost.author.url }}"
class="inline-block"
title="{{ repost.author.name }}"
target="_blank"
rel="noopener">
<img
src="{{ repost.author.photo or '/images/default-avatar.png' }}"
alt="{{ repost.author.name }}"
class="w-8 h-8 rounded-full"
loading="lazy"
>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{# Replies #}
{% set replies = mentions | webmentionsByType('replies') %}
{% if replies.length %}
<div class="webmention-replies">
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-4">
{{ replies.length }} Repl{% if replies.length != 1 %}ies{% else %}y{% endif %}
</h3>
<ul class="space-y-4">
{% for reply in replies %}
<li class="p-4 bg-surface-100 dark:bg-surface-800 rounded-lg">
<div class="flex gap-3">
<a href="{{ reply.author.url }}" target="_blank" rel="noopener">
<img
src="{{ reply.author.photo or '/images/default-avatar.png' }}"
alt="{{ reply.author.name }}"
class="w-10 h-10 rounded-full"
loading="lazy"
>
</a>
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 mb-1">
<a href="{{ reply.author.url }}"
class="font-semibold text-surface-900 dark:text-surface-100 hover:underline"
target="_blank"
rel="noopener">
{{ reply.author.name }}
</a>
<a href="{{ reply.url }}"
class="text-xs text-surface-500 hover:underline"
target="_blank"
rel="noopener">
<time datetime="{{ reply.published }}">
{{ reply.published | date("MMM d, yyyy") }}
</time>
</a>
</div>
<div class="text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none">
{{ reply.content.html | safe if reply.content.html else reply.content.text }}
</div>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Other mentions #}
{% set otherMentions = mentions | webmentionsByType('mentions') %}
{% if otherMentions.length %}
<div class="webmention-mentions mt-6">
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">
{{ otherMentions.length }} Mention{% if otherMentions.length != 1 %}s{% endif %}
</h3>
<ul class="space-y-2 text-sm">
{% for mention in otherMentions %}
<li>
<a href="{{ mention.url }}"
class="text-primary-600 dark:text-primary-400 hover:underline"
target="_blank"
rel="noopener">
{{ mention.author.name }} mentioned this on {{ mention.published | date("MMM d, yyyy") }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</section>
{% endif %}
{# Webmention send form #}
<section class="webmention-form mt-8 p-4 bg-surface-100 dark:bg-surface-800 rounded-lg">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-2">
Send a Webmention
</h3>
<p class="text-xs text-surface-600 dark:text-surface-400 mb-3">
Have you written a response to this post? Send a webmention by entering your post URL below.
</p>
<form action="https://webmention.io/{{ site.webmentions.domain }}/webmention" method="post" class="flex gap-2">
<input type="hidden" name="target" value="{{ site.url }}{{ page.url }}">
<input
type="url"
name="source"
placeholder="https://your-site.com/response"
required
class="flex-1 px-3 py-2 text-sm bg-white dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors">
Send
</button>
</form>
</section>

219
_includes/layouts/base.njk Normal file
View File

@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="{{ site.locale | default('en') }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if title %}{{ title }} - {% endif %}{{ site.name }}</title>
{# OpenGraph meta tags #}
{% set ogTitle = title | default(site.name) %}
{% set ogDesc = description | default(content | ogDescription(200)) | default(site.description) %}
{% set contentImage = content | extractFirstImage %}
<meta property="og:title" content="{{ ogTitle }}">
<meta property="og:site_name" content="{{ site.name }}">
<meta property="og:url" content="{{ site.url }}{{ page.url }}">
<meta property="og:type" content="{% if page.url == '/' %}website{% else %}article{% endif %}">
<meta property="og:description" content="{{ ogDesc }}">
<meta name="description" content="{{ ogDesc }}">
{% if photo %}
<meta property="og:image" content="{% if photo.startsWith('http') %}{{ photo }}{% else %}{{ site.url }}{% if not photo.startsWith('/') %}/{% endif %}{{ photo }}{% endif %}">
{% elif image %}
<meta property="og:image" content="{% if image.startsWith('http') %}{{ image }}{% else %}{{ site.url }}{% if not image.startsWith('/') %}/{% endif %}{{ image }}{% endif %}">
{% elif contentImage %}
<meta property="og:image" content="{% if contentImage.startsWith('http') %}{{ contentImage }}{% else %}{{ site.url }}{% if not contentImage.startsWith('/') %}/{% endif %}{{ contentImage }}{% endif %}">
{% else %}
<meta property="og:image" content="{{ site.url }}/images/og-default.png">
{% endif %}
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:locale" content="{{ site.locale | default('en_US') }}">
{# Twitter Card meta tags #}
{% set hasImage = photo or image or contentImage %}
<meta name="twitter:card" content="{% if hasImage %}summary_large_image{% else %}summary{% endif %}">
<meta name="twitter:title" content="{{ ogTitle }}">
<meta name="twitter:description" content="{{ ogDesc }}">
{% if photo %}
<meta name="twitter:image" content="{% if photo.startsWith('http') %}{{ photo }}{% else %}{{ site.url }}/{{ photo }}{% endif %}">
{% elif image %}
<meta name="twitter:image" content="{% if image.startsWith('http') %}{{ image }}{% else %}{{ site.url }}/{{ image }}{% endif %}">
{% elif contentImage %}
<meta name="twitter:image" content="{% if contentImage.startsWith('http') %}{{ contentImage }}{% else %}{{ site.url }}/{{ contentImage }}{% endif %}">
{% endif %}
<link rel="stylesheet" href="/css/style.css?v={{ '/css/style.css' | hash }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lite-youtube-embed@0.3.2/src/lite-yt-embed.min.css">
<script src="https://cdn.jsdelivr.net/npm/lite-youtube-embed@0.3.2/src/lite-yt-embed.min.js" defer></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>[x-cloak] { display: none !important; }</style>
<link rel="canonical" href="{{ site.url }}{{ page.url }}">
<link rel="alternate" type="application/rss+xml" href="/feed.xml" title="RSS Feed">
<link rel="alternate" type="application/json" href="/feed.json" title="JSON Feed">
<link rel="authorization_endpoint" href="{{ site.url }}/auth">
<link rel="token_endpoint" href="{{ site.url }}/auth/token">
<link rel="micropub" href="{{ site.url }}/micropub">
<link rel="webmention" href="https://webmention.io/{{ site.webmentions.domain }}/webmention">
<link rel="pingback" href="https://webmention.io/{{ site.webmentions.domain }}/xmlrpc">
{# IndieAuth rel="me" links for identity verification #}
{% for social in site.social %}
<link rel="me" href="{{ social.url }}">
{% endfor %}
</head>
<body>
<script>
// Apply theme immediately to prevent flash
(function() {
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
<header class="site-header">
<div class="container header-container">
<a href="/" class="site-title">{{ site.name }}</a>
{# Mobile menu button #}
<button id="menu-toggle" type="button" class="menu-toggle" aria-label="Toggle menu" aria-expanded="false">
<svg class="menu-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
<svg class="close-icon hidden" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
{# Desktop nav + Theme toggle (visible on desktop) #}
<div class="header-actions">
<nav class="site-nav" id="site-nav">
<a href="/">Home</a>
<a href="/about/">About</a>
<a href="/blog/">Blog</a>
<a href="/articles/">Articles</a>
<a href="/notes/">Notes</a>
<a href="/interactions/">Interactions</a>
<a href="/github/">GitHub</a>
<a href="/funkwhale/">Listening</a>
<a href="/youtube/">YouTube</a>
</nav>
<button id="theme-toggle" type="button" class="theme-toggle" aria-label="Toggle dark mode" title="Toggle dark mode">
<svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
</div>
</div>
{# Mobile nav dropdown #}
<nav class="mobile-nav hidden" id="mobile-nav">
<a href="/">Home</a>
<a href="/about/">About</a>
<a href="/blog/">Blog</a>
<a href="/articles/">Articles</a>
<a href="/notes/">Notes</a>
<a href="/interactions/">Interactions</a>
<a href="/github/">GitHub</a>
<a href="/funkwhale/">Listening</a>
<a href="/youtube/">YouTube</a>
</nav>
</header>
<main class="container py-8">
{% if withSidebar %}
<div class="layout-with-sidebar">
<div class="main-content">
{{ content | safe }}
</div>
<aside class="sidebar">
{% include "components/sidebar.njk" %}
</aside>
</div>
{% elif withBlogSidebar %}
<div class="layout-with-sidebar">
<div class="main-content">
{{ content | safe }}
</div>
<aside class="sidebar blog-sidebar">
{% include "components/blog-sidebar.njk" %}
</aside>
</div>
{% else %}
{{ content | safe }}
{% endif %}
</main>
<footer class="site-footer">
<div class="container">
<div class="flex flex-wrap justify-center gap-4 mb-4">
<a href="/feed.xml" class="inline-flex items-center gap-1 text-primary-600 dark:text-primary-400 hover:underline">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1Z"/></svg>
RSS Feed
</a>
<a href="/feed.json" class="inline-flex items-center gap-1 text-primary-600 dark:text-primary-400 hover:underline">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m3 12h2v2H8v-2m4-8h2v10h-2V7m4 4h2v6h-2v-6Z"/></svg>
JSON Feed
</a>
</div>
<p>Powered by <a href="https://getindiekit.com">Indiekit</a> + <a href="https://11ty.dev">Eleventy</a></p>
</div>
</footer>
<script>
// Mobile menu toggle
const menuToggle = document.getElementById('menu-toggle');
const mobileNav = document.getElementById('mobile-nav');
const menuIcon = menuToggle?.querySelector('.menu-icon');
const closeIcon = menuToggle?.querySelector('.close-icon');
if (menuToggle && mobileNav) {
menuToggle.addEventListener('click', () => {
const isOpen = !mobileNav.classList.contains('hidden');
mobileNav.classList.toggle('hidden');
menuIcon?.classList.toggle('hidden');
closeIcon?.classList.toggle('hidden');
menuToggle.setAttribute('aria-expanded', !isOpen);
});
// Close menu when clicking a link
mobileNav.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
mobileNav.classList.add('hidden');
menuIcon?.classList.remove('hidden');
closeIcon?.classList.add('hidden');
menuToggle.setAttribute('aria-expanded', 'false');
});
});
}
// Theme toggle functionality
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
}
// Link prefetching on mouseover/touchstart for faster navigation
function prefetch(e) {
if (e.target.tagName !== 'A') return;
if (e.target.origin !== location.origin) return;
const removeFragment = (url) => url.split('#')[0];
if (removeFragment(location.href) === removeFragment(e.target.href)) return;
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = e.target.href;
document.head.appendChild(link);
}
document.documentElement.addEventListener('mouseover', prefetch, { capture: true, passive: true });
document.documentElement.addEventListener('touchstart', prefetch, { capture: true, passive: true });
</script>
</body>
</html>

208
_includes/layouts/home.njk Normal file
View File

@@ -0,0 +1,208 @@
---
layout: layouts/base.njk
withSidebar: true
---
{# Hero Section #}
<section class="mb-12">
<div class="flex flex-col md:flex-row gap-8 items-start">
{# Avatar #}
<img
src="{{ site.author.avatar }}"
alt="{{ site.author.name }}"
class="w-32 h-32 rounded-full object-cover shadow-lg"
loading="eager"
>
{# Introduction #}
<div class="flex-1">
<h1 class="text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">
{{ site.author.name }}
</h1>
<p class="text-xl text-primary-600 dark:text-primary-400 mb-4">
{{ site.author.title }}
</p>
<p class="text-lg text-surface-700 dark:text-surface-300 mb-6">
{{ site.author.bio }}
</p>
{# Social Links #}
<div class="flex flex-wrap gap-3">
{% for link in site.social %}
<a
href="{{ link.url }}"
rel="{{ link.rel }} noopener"
class="inline-flex items-center gap-2 px-3 py-2 text-sm bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
target="_blank"
>
{% if link.icon == "github" %}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path></svg>
{% elif link.icon == "linkedin" %}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"></path></svg>
{% elif link.icon == "bluesky" %}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"></path></svg>
{% elif link.icon == "mastodon" %}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12v6.406z"></path></svg>
{% endif %}
<span class="text-sm font-medium">{{ link.name }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
</section>
{# Work Experience Timeline - only show if data exists #}
{% if cv.experience and cv.experience.length %}
<section class="mb-12">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6">Experience</h2>
<div class="timeline">
{% for job in cv.experience %}
<article class="timeline-item">
<div class="flex flex-wrap items-baseline gap-2 mb-2">
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100">
{{ job.title }}
</h3>
<span class="text-primary-600 dark:text-primary-400">@ {{ job.company }}</span>
{% if job.type != "full-time" %}
<span class="text-xs px-2 py-0.5 bg-surface-200 dark:bg-surface-700 rounded">{{ job.type }}</span>
{% endif %}
</div>
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2">
<time datetime="{{ job.startDate }}">{{ job.startDate }}</time> -
{% if job.endDate %}
<time datetime="{{ job.endDate }}">{{ job.endDate }}</time>
{% else %}
Present
{% endif %}
· {{ job.location }}
</p>
{% if job.description %}
<p class="text-surface-700 dark:text-surface-300 mb-2">{{ job.description }}</p>
{% endif %}
{% if job.highlights %}
<ul class="list-disc list-inside text-sm text-surface-600 dark:text-surface-400 space-y-1">
{% for highlight in job.highlights %}
<li>{{ highlight }}</li>
{% endfor %}
</ul>
{% endif %}
</article>
{% endfor %}
</div>
</section>
{% endif %}
{# Projects Section - only show if data exists #}
{% if cv.projects and cv.projects.length %}
<section class="mb-12">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6">Projects</h2>
<div class="grid md:grid-cols-2 gap-4">
{% for project in cv.projects %}
<article class="p-4 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
<h3 class="font-semibold text-surface-900 dark:text-surface-100 mb-1">
{% if project.url %}
<a href="{{ project.url }}" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank" rel="noopener">
{{ project.name }}
</a>
{% else %}
{{ project.name }}
{% endif %}
</h3>
<p class="text-sm text-surface-600 dark:text-surface-400 mb-3">{{ project.description }}</p>
<div class="flex flex-wrap gap-2">
{% for tech in project.technologies %}
<span class="skill-badge">{{ tech }}</span>
{% endfor %}
</div>
{% if project.status == "active" %}
<span class="inline-block mt-3 text-xs px-2 py-0.5 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded">Active</span>
{% endif %}
</article>
{% endfor %}
</div>
</section>
{% endif %}
{# Skills Section - only show if data exists #}
{% if cv.skills and (cv.skills | dictsort | length) %}
<section class="mb-12">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6">Skills</h2>
<div class="grid md:grid-cols-2 gap-6">
{% for category, skills in cv.skills %}
<div>
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">
{{ category }}
</h3>
<div class="flex flex-wrap gap-2">
{% for skill in skills %}
<span class="skill-badge">{{ skill }}</span>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{# Education & Languages - only show if data exists #}
{% if (cv.education and cv.education.length) or (cv.languages and cv.languages.length) %}
<section class="mb-12 grid md:grid-cols-2 gap-8">
{# Education #}
{% if cv.education and cv.education.length %}
<div>
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6">Education</h2>
{% for edu in cv.education %}
<article class="mb-4 last:mb-0">
<h3 class="font-semibold text-surface-900 dark:text-surface-100">{{ edu.degree }}</h3>
<p class="text-primary-600 dark:text-primary-400">{{ edu.institution }}</p>
<p class="text-sm text-surface-600 dark:text-surface-400">{{ edu.year }} · {{ edu.location }}</p>
</article>
{% endfor %}
</div>
{% endif %}
{# Languages #}
{% if cv.languages and cv.languages.length %}
<div>
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6">Languages</h2>
<ul class="space-y-2">
{% for lang in cv.languages %}
<li class="flex justify-between items-center">
<span class="text-surface-900 dark:text-surface-100">{{ lang.name }}</span>
<span class="text-sm text-surface-600 dark:text-surface-400">{{ lang.level }}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</section>
{% endif %}
{# Interests - only show if data exists #}
{% if cv.interests and cv.interests.length %}
<section class="mb-12">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6">Interests</h2>
<div class="flex flex-wrap gap-2">
{% for interest in cv.interests %}
<span class="skill-badge">{{ interest }}</span>
{% endfor %}
</div>
</section>
{% endif %}
{# Last Updated - only show if CV has content #}
{% if cv.lastUpdated and (cv.experience.length or cv.projects.length) %}
<p class="text-sm text-surface-500 text-center">
Last updated: {{ cv.lastUpdated }}
</p>
{% endif %}

123
_includes/layouts/post.njk Normal file
View File

@@ -0,0 +1,123 @@
---
layout: layouts/base.njk
withBlogSidebar: true
---
<article class="h-entry post">
{% if title %}
<h1 class="p-name text-3xl font-bold text-surface-900 dark:text-surface-100 mb-4">{{ title }}</h1>
{% endif %}
<div class="post-meta mb-6">
<time class="dt-published" datetime="{{ date.toISOString() }}">
{{ date | dateDisplay }}
</time>
{% if category %}
<span class="post-categories">
{# Handle both string and array categories #}
{% if category is string %}
<a href="/categories/{{ category | slugify }}/" class="p-category">{{ category }}</a>
{% else %}
{% for cat in category %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a>
{% endfor %}
{% endif %}
</span>
{% endif %}
</div>
{# Bridgy syndication content - controls what gets posted to social networks #}
{# Uses description/summary if available, otherwise first 280 chars of content #}
{% set bridgySummary = description or summary or (content | ogDescription(280)) %}
{% if bridgySummary %}
<p class="p-summary e-bridgy-mastodon-content e-bridgy-bluesky-content hidden">{{ bridgySummary }}</p>
{% endif %}
<div class="e-content prose prose-surface dark:prose-invert max-w-none">
{{ content | safe }}
</div>
{# Rich reply context with h-cite microformat #}
{% include "components/reply-context.njk" %}
{# Syndication Footer - shows where this post was also published #}
{% if syndication %}
<footer class="post-footer mt-8 pt-6 border-t border-surface-200 dark:border-surface-700">
<div class="flex flex-wrap items-center gap-4">
<span class="text-sm text-surface-500 dark:text-surface-400">Also on:</span>
<div class="flex flex-wrap gap-3">
{% for url in syndication %}
{% if "bsky.app" in url or "bluesky" in url %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#0085ff]/10 text-[#0085ff] hover:bg-[#0085ff]/20 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication" title="View on Bluesky">
<svg class="w-4 h-4" viewBox="0 0 568 501" fill="currentColor" aria-hidden="true">
<path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/>
</svg>
<span>Bluesky</span>
</a>
{% elif "mstdn" in url or "mastodon" in url or "social" in url %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#6364ff]/10 text-[#6364ff] hover:bg-[#6364ff]/20 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication" title="View on Mastodon">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
</svg>
<span>Mastodon</span>
</a>
{% else %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
<span>{{ url | replace("https://", "") | truncate(20) }}</span>
</a>
{% endif %}
{% endfor %}
</div>
</div>
</footer>
{% endif %}
<a class="u-url" href="{{ page.url }}" hidden>Permalink</a>
{# Author h-card for IndieWeb authorship #}
<span class="p-author h-card hidden">
<a class="p-name u-url" href="{{ site.author.url }}">{{ site.author.name }}</a>
<img class="u-photo" src="{{ site.author.avatar }}" alt="{{ site.author.name }}" hidden>
</span>
{# JSON-LD Structured Data for SEO #}
{% set postImage = photo or image or (content | extractFirstImage) %}
{% set postDesc = description | default(content | ogDescription(160)) %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": {{ (title or "Untitled") | dump | safe }},
"url": "{{ site.url }}{{ page.url }}",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{{ site.url }}{{ page.url }}"
},
"datePublished": "{{ date.toISOString() }}",
"dateModified": "{{ date.toISOString() }}",
"author": {
"@type": "Person",
"name": "{{ site.author.name }}",
"url": "{{ site.author.url }}"
},
"publisher": {
"@type": "Organization",
"name": "{{ site.name }}",
"url": "{{ site.url }}",
"logo": {
"@type": "ImageObject",
"url": "{{ site.url }}/images/og-default.png"
}
},
"description": {{ postDesc | dump | safe }}{% if postImage %},
"image": ["{{ site.url }}{% if postImage.startsWith('/') %}{{ postImage }}{% else %}/{{ postImage }}{% endif %}"]{% endif %}
}
</script>
</article>
{# Webmentions display - likes, reposts, replies #}
{% include "components/webmentions.njk" %}

68
about.njk Normal file
View File

@@ -0,0 +1,68 @@
---
layout: layouts/base.njk
title: About
permalink: /about/
---
<article class="h-card">
<header class="mb-8 flex flex-col md:flex-row gap-8 items-start">
<img
src="{{ site.author.avatar }}"
alt="{{ site.author.name }}"
class="u-photo w-40 h-40 rounded-full object-cover shadow-lg"
loading="eager"
>
<div>
<h1 class="p-name text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">
{{ site.author.name }}
</h1>
{% if site.author.title %}
<p class="p-job-title text-xl text-primary-600 dark:text-primary-400 mb-2">
{{ site.author.title }}
</p>
{% endif %}
{% if site.author.location %}
<p class="p-locality text-surface-600 dark:text-surface-400 mb-4">
{{ site.author.location }}
</p>
{% endif %}
<a href="{{ site.author.url }}" class="u-url u-uid hidden" rel="me">{{ site.author.url }}</a>
</div>
</header>
<div class="prose dark:prose-invert prose-lg max-w-none">
<p class="p-note text-lg">{{ site.author.bio }}</p>
<h2>About This Site</h2>
<p>
This site is powered by <a href="https://getindiekit.com">Indiekit</a>, an IndieWeb
server that supports Micropub, Webmentions, and other IndieWeb standards. It runs on
<a href="https://cloudron.io">Cloudron</a> for easy self-hosting.
</p>
<h2>IndieWeb</h2>
<p>
I'm part of the <a href="https://indieweb.org">IndieWeb</a> movement - owning my content
and identity online. You can interact with my posts through Webmentions - reply, like,
or repost from your own website and it will show up here.
</p>
{% if site.social.length > 0 %}
<h2>Connect</h2>
<p>Find me on:</p>
<ul>
{% for link in site.social %}
<li>
<a href="{{ link.url }}" rel="me" target="_blank">{{ link.name }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if site.author.email %}
<p>
Or send me an email at
<a href="mailto:{{ site.author.email }}" class="u-email">{{ site.author.email }}</a>
</p>
{% endif %}
</div>
</article>

92
articles.njk Normal file
View File

@@ -0,0 +1,92 @@
---
layout: layouts/base.njk
title: Articles
withSidebar: true
pagination:
data: collections.articles
size: 20
alias: paginatedArticles
permalink: "articles/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
---
<div class="h-feed">
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Articles</h1>
<p class="text-surface-600 dark:text-surface-400 mb-8">
Long-form posts and essays.
<span class="text-sm">({{ collections.articles.length }} total)</span>
</p>
{% if paginatedArticles.length > 0 %}
<ul class="post-list">
{% for post in paginatedArticles %}
<li class="h-entry post-card">
<div class="post-header">
<h2 class="text-xl font-semibold mb-1 flex-1">
<a class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400" href="{{ post.url }}">
{{ post.data.title or "Untitled" }}
</a>
</h2>
</div>
<div class="post-meta mt-2">
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
{% if post.data.category %}
<span class="post-categories">
{% if post.data.category is string %}
<span class="p-category">{{ post.data.category }}</span>
{% else %}
{% for cat in post.data.category %}
<span class="p-category">{{ cat }}</span>
{% endfor %}
{% endif %}
</span>
{% endif %}
</div>
<p class="p-summary text-surface-700 dark:text-surface-300 mt-3">
{{ post.templateContent | striptags | truncate(250) }}
</p>
<a href="{{ post.url }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 inline-block">
Read more &rarr;
</a>
</li>
{% endfor %}
</ul>
{# Pagination controls #}
{% if pagination.pages.length > 1 %}
<nav class="pagination" aria-label="Articles pagination">
<div class="pagination-info">
Page {{ pagination.pageNumber + 1 }} of {{ pagination.pages.length }}
</div>
<div class="pagination-links">
{% if pagination.href.previous %}
<a href="{{ pagination.href.previous }}" class="pagination-link" aria-label="Previous page">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
Previous
</a>
{% else %}
<span class="pagination-link disabled">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
Previous
</span>
{% endif %}
{% if pagination.href.next %}
<a href="{{ pagination.href.next }}" class="pagination-link" aria-label="Next page">
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</a>
{% else %}
<span class="pagination-link disabled">
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</span>
{% endif %}
</div>
</nav>
{% endif %}
{% else %}
<p class="text-surface-600 dark:text-surface-400">No articles yet.</p>
{% endif %}
</div>

130
blog.njk Normal file
View File

@@ -0,0 +1,130 @@
---
layout: layouts/base.njk
title: Blog
withSidebar: true
pagination:
data: collections.posts
size: 20
alias: paginatedPosts
permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
---
<div class="h-feed">
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Blog</h1>
<p class="text-surface-600 dark:text-surface-400 mb-8">
All posts including articles and notes.
<span class="text-sm">({{ collections.posts.length }} total)</span>
</p>
{% if paginatedPosts.length > 0 %}
<ul class="post-list">
{% for post in paginatedPosts %}
<li class="h-entry post-card">
{# Article with title #}
{% if post.data.title %}
<div class="post-header">
<h2 class="text-xl font-semibold mb-1">
<a class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400" href="{{ post.url }}">
{{ post.data.title }}
</a>
</h2>
<div class="post-meta">
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
{% if post.data.category %}
<span class="post-categories">
{% if post.data.category is string %}
<span class="p-category">{{ post.data.category }}</span>
{% else %}
{% for cat in post.data.category %}
<span class="p-category">{{ cat }}</span>
{% endfor %}
{% endif %}
</span>
{% endif %}
</div>
</div>
<p class="p-summary text-surface-700 dark:text-surface-300 mt-3">
{{ post.templateContent | striptags | truncate(250) }}
</p>
<a href="{{ post.url }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 inline-block">
Read more &rarr;
</a>
{# Note without title #}
{% else %}
<div class="post-header">
<a class="u-url" href="{{ post.url }}">
<time class="dt-published text-sm text-primary-600 dark:text-primary-400 font-medium" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</a>
{% if post.data.category %}
<span class="post-categories ml-2">
{% if post.data.category is string %}
<span class="p-category">{{ post.data.category }}</span>
{% else %}
{% for cat in post.data.category %}
<span class="p-category">{{ cat }}</span>
{% endfor %}
{% endif %}
</span>
{% endif %}
</div>
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
{{ post.templateContent | safe }}
</div>
<div class="post-footer mt-3">
<a href="{{ post.url }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">
Permalink
</a>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{# Pagination controls #}
{% if pagination.pages.length > 1 %}
<nav class="pagination" aria-label="Blog pagination">
<div class="pagination-info">
Page {{ pagination.pageNumber + 1 }} of {{ pagination.pages.length }}
</div>
<div class="pagination-links">
{% if pagination.href.previous %}
<a href="{{ pagination.href.previous }}" class="pagination-link" aria-label="Previous page">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
Previous
</a>
{% else %}
<span class="pagination-link disabled">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
Previous
</span>
{% endif %}
{% if pagination.href.next %}
<a href="{{ pagination.href.next }}" class="pagination-link" aria-label="Next page">
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</a>
{% else %}
<span class="pagination-link disabled">
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</span>
{% endif %}
</div>
</nav>
{% endif %}
{% else %}
<p class="text-surface-600 dark:text-surface-400">No posts yet. Create your first post using a Micropub client!</p>
<p class="mt-4 text-surface-600 dark:text-surface-400">Some popular Micropub clients:</p>
<ul class="list-disc list-inside mt-2 text-surface-700 dark:text-surface-300 space-y-1">
<li><a href="https://quill.p3k.io" class="text-primary-600 dark:text-primary-400 hover:underline">Quill</a> - Web-based</li>
<li><a href="https://indiepass.app" class="text-primary-600 dark:text-primary-400 hover:underline">IndiePass</a> - Mobile app</li>
<li><a href="https://micropublish.net" class="text-primary-600 dark:text-primary-400 hover:underline">Micropublish</a> - Web-based</li>
</ul>
{% endif %}
</div>

29
bookmarks.njk Normal file
View File

@@ -0,0 +1,29 @@
---
layout: layouts/base.njk
title: Bookmarks
permalink: /bookmarks/
---
<h1>Bookmarks</h1>
{% if collections.bookmarks.length > 0 %}
<ul class="post-list">
{% for post in collections.bookmarks %}
<li class="h-entry">
{% if post.data.title %}
<h2><a class="p-name u-url" href="{{ post.url }}">{{ post.data.title }}</a></h2>
{% endif %}
<div class="post-meta">
<time class="dt-published" datetime="{{ post.date.toISOString() }}">
{{ post.date | dateDisplay }}
</time>
</div>
{% if post.data.bookmark_of %}
<p><a class="u-bookmark-of" href="{{ post.data.bookmark_of }}">{{ post.data.bookmark_of }}</a></p>
{% endif %}
<div class="e-content">{{ post.templateContent | safe }}</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>No bookmarks yet.</p>
{% endif %}

27
categories-index.njk Normal file
View File

@@ -0,0 +1,27 @@
---
layout: layouts/base.njk
title: Categories
withSidebar: true
permalink: categories/
---
<div>
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Categories</h1>
<p class="text-surface-600 dark:text-surface-400 mb-8">
Browse posts by category.
<span class="text-sm">({{ collections.categories.length }} categories)</span>
</p>
{% if collections.categories.length > 0 %}
<ul class="flex flex-wrap gap-3">
{% for cat in collections.categories %}
<li>
<a href="/categories/{{ cat | slugify }}/" class="inline-block px-4 py-2 bg-surface-100 dark:bg-surface-800 text-surface-900 dark:text-surface-100 rounded-lg hover:bg-primary-100 dark:hover:bg-primary-900 hover:text-primary-700 dark:hover:text-primary-300 transition-colors">
{{ cat }}
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No categories yet.</p>
{% endif %}
</div>

69
categories.njk Normal file
View File

@@ -0,0 +1,69 @@
---
layout: layouts/base.njk
withSidebar: true
pagination:
data: collections.categories
size: 1
alias: category
permalink: "categories/{{ category | slugify }}/"
eleventyComputed:
title: "{{ category }}"
---
<div class="h-feed">
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">{{ category }}</h1>
<p class="text-surface-600 dark:text-surface-400 mb-8">
Posts tagged with "{{ category }}".
</p>
{% set categoryPosts = [] %}
{% for post in collections.posts %}
{% if post.data.category %}
{% if post.data.category is string %}
{% if post.data.category == category %}
{% set categoryPosts = (categoryPosts.push(post), categoryPosts) %}
{% endif %}
{% else %}
{% if category in post.data.category %}
{% set categoryPosts = (categoryPosts.push(post), categoryPosts) %}
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
{% if categoryPosts.length > 0 %}
<p class="text-sm text-surface-500 dark:text-surface-400 mb-4">{{ categoryPosts.length }} post{% if categoryPosts.length != 1 %}s{% endif %}</p>
<ul class="post-list">
{% for post in categoryPosts %}
<li class="h-entry post-card">
<div class="post-header">
<h2 class="text-xl font-semibold mb-1 flex-1">
<a class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400" href="{{ post.url }}">
{{ post.data.title or post.templateContent | striptags | truncate(60) or "Untitled" }}
</a>
</h2>
</div>
<div class="post-meta mt-2">
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
{% set postType = post.inputPath | replace("./content/", "") %}
{% set postType = postType.split("/")[0] %}
<span class="post-type">{{ postType }}</span>
</div>
<p class="p-summary text-surface-700 dark:text-surface-300 mt-3">
{{ post.templateContent | striptags | truncate(250) }}
</p>
<a href="{{ post.url }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 inline-block">
View &rarr;
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No posts found with this category.</p>
{% endif %}
<div class="mt-8">
<a href="/categories/" class="text-primary-600 dark:text-primary-400 hover:underline">&larr; All categories</a>
</div>
</div>

317
css/tailwind.css Normal file
View File

@@ -0,0 +1,317 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Accessibility utilities */
@layer utilities {
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skip-link {
@apply absolute -top-full left-0 z-50 bg-primary-600 text-white px-4 py-2;
}
.skip-link:focus {
@apply top-0;
}
}
/* Reduce motion for accessibility */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Dark mode body background */
@layer base {
body {
@apply bg-white dark:bg-surface-950 text-surface-900 dark:text-surface-100;
}
}
/* Layout styles */
@layer components {
/* Site header */
.site-header {
@apply bg-white dark:bg-surface-900 border-b border-surface-200 dark:border-surface-700 py-4 sticky top-0 z-50;
}
.header-container {
@apply flex items-center justify-between;
}
.site-title {
@apply text-xl font-bold text-surface-900 dark:text-white no-underline hover:text-primary-600 dark:hover:text-primary-400 transition-colors;
}
/* Header actions (nav + theme toggle) */
.header-actions {
@apply hidden md:flex items-center gap-4;
}
.site-nav {
@apply flex items-center gap-4;
}
.site-nav a {
@apply text-surface-600 dark:text-surface-400 hover:text-primary-600 dark:hover:text-primary-400 no-underline transition-colors py-2;
}
/* Mobile menu toggle button */
.menu-toggle {
@apply md:hidden p-2 rounded-lg text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors;
}
/* Mobile navigation dropdown */
.mobile-nav {
@apply md:hidden border-t border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-900;
}
.mobile-nav a {
@apply block px-4 py-3 text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-primary-600 dark:hover:text-primary-400 no-underline transition-colors border-b border-surface-100 dark:border-surface-800 last:border-b-0;
}
/* Theme toggle button */
.theme-toggle {
@apply p-2 rounded-lg text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors;
}
.theme-toggle .sun-icon {
@apply hidden;
}
.theme-toggle .moon-icon {
@apply block;
}
.dark .theme-toggle .sun-icon {
@apply block;
}
.dark .theme-toggle .moon-icon {
@apply hidden;
}
/* Container */
.container {
@apply max-w-5xl mx-auto px-4;
}
/* Site footer */
.site-footer {
@apply mt-12 py-8 border-t border-surface-200 dark:border-surface-700 text-center text-sm text-surface-500;
}
.site-footer a {
@apply text-primary-600 dark:text-primary-400 hover:underline;
}
/* Layout with sidebar - mobile-first with responsive grid */
.layout-with-sidebar {
@apply grid gap-6 md:gap-8 lg:grid-cols-3;
}
.main-content {
@apply lg:col-span-2 min-w-0; /* min-w-0 prevents flex/grid overflow */
}
.sidebar {
@apply space-y-6 lg:sticky lg:top-24 lg:self-start;
}
/* Main content area - adjust padding for mobile */
main.container {
@apply py-6 md:py-8;
}
}
/* Custom component styles */
@layer components {
/* Post list */
.post-list {
@apply list-none p-0 m-0 space-y-6;
}
.post-list li {
@apply pb-6 border-b border-surface-200 dark:border-surface-700 last:border-0;
}
/* Post meta */
.post-meta {
@apply text-sm text-surface-600 dark:text-surface-400 flex flex-wrap gap-2 items-center;
}
/* Category tags */
.p-category {
@apply inline-block px-2 py-0.5 text-xs bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200 rounded;
}
/* Webmention styles */
.webmention-likes .avatar-row {
@apply flex flex-wrap gap-1;
}
.webmention-likes img {
@apply w-8 h-8 rounded-full;
}
/* GitHub components */
.repo-card {
@apply p-4 border border-surface-200 dark:border-surface-700 rounded-lg;
}
.repo-meta {
@apply flex gap-4 text-sm text-surface-600 dark:text-surface-400 mt-2;
}
/* Timeline for CV */
.timeline {
@apply relative pl-6 border-l-2 border-primary-500;
}
.timeline-item {
@apply relative pb-6 last:pb-0;
}
.timeline-item::before {
content: '';
@apply absolute -left-[calc(1.5rem+5px)] top-1.5 w-3 h-3 bg-primary-500 rounded-full;
}
/* Skills badges */
.skill-badge {
@apply inline-block px-3 py-1 text-sm bg-surface-100 dark:bg-surface-800 rounded-full;
}
/* Widget cards */
.widget {
@apply p-4 bg-surface-100 dark:bg-surface-800 rounded-lg;
}
.widget-title {
@apply font-bold text-lg mb-4;
}
/* Post cards */
.post-card {
@apply p-5 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm;
}
.post-header {
@apply flex flex-wrap items-center gap-2;
}
.post-footer {
@apply pt-3 border-t border-surface-100 dark:border-surface-700;
}
/* Pagination */
.pagination {
@apply mt-12 pt-8 border-t border-surface-200 dark:border-surface-700 flex flex-col sm:flex-row items-center justify-between gap-4;
}
.pagination-info {
@apply text-sm text-surface-600 dark:text-surface-400;
}
.pagination-links {
@apply flex items-center gap-2;
}
.pagination-link {
@apply inline-flex items-center gap-1 px-4 py-2 text-sm font-medium bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors;
}
.pagination-link.disabled {
@apply opacity-50 cursor-not-allowed hover:bg-surface-100 dark:hover:bg-surface-800;
}
}
/* Focus states */
@layer base {
a:focus-visible,
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
@apply outline-2 outline-offset-2 outline-primary-500;
}
}
/* Video embeds */
@layer components {
.video-embed {
@apply relative w-full aspect-video my-4;
}
.video-embed iframe {
@apply absolute inset-0 w-full h-full rounded-lg;
}
}
/* Performance: content-visibility for off-screen rendering optimization */
@layer utilities {
.content-auto {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}
}
/* Apply content-visibility to images and post items for performance */
@layer base {
/* Responsive typography */
html {
@apply text-base md:text-lg;
}
/* Prevent horizontal overflow */
body {
@apply overflow-x-hidden;
}
/* Images - prevent overflow and add content-visibility */
img {
@apply max-w-full h-auto;
content-visibility: auto;
}
/* Pre/code blocks - prevent overflow on mobile */
pre {
@apply overflow-x-auto max-w-full;
}
code {
@apply break-words;
}
/* Links in content - break long URLs */
.e-content a,
.prose a {
@apply break-words;
word-break: break-word;
}
article {
scroll-margin-top: 80px; /* Prevent header overlap when scrolling to anchors */
}
.post-list li {
content-visibility: auto;
contain-intrinsic-size: auto 200px;
}
}

360
eleventy.config.js Normal file
View File

@@ -0,0 +1,360 @@
import pluginWebmentions from "@chrisburnell/eleventy-cache-webmentions";
import pluginRss from "@11ty/eleventy-plugin-rss";
import embedEverything from "eleventy-plugin-embed-everything";
import { eleventyImageTransformPlugin } from "@11ty/eleventy-img";
import sitemap from "@quasibit/eleventy-plugin-sitemap";
import markdownIt from "markdown-it";
import { minify } from "html-minifier-terser";
import { createHash } from "crypto";
import { readFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const siteUrl = process.env.SITE_URL || "https://example.com";
export default function (eleventyConfig) {
// Ignore output directory (prevents re-processing generated files via symlink)
eleventyConfig.ignores.add("_site");
eleventyConfig.ignores.add("_site/**");
// Configure markdown-it with linkify enabled (auto-convert URLs to links)
const md = markdownIt({
html: true,
linkify: true, // Auto-convert URLs to clickable links
typographer: true,
});
eleventyConfig.setLibrary("md", md);
// RSS plugin for feed filters (dateToRfc822, absoluteUrl, etc.)
// Custom feed templates in feed.njk and feed-json.njk use these filters
eleventyConfig.addPlugin(pluginRss);
// JSON encode filter for JSON feed
eleventyConfig.addFilter("jsonEncode", (value) => {
return JSON.stringify(value);
});
// Alias dateToRfc822 (plugin provides dateToRfc2822)
eleventyConfig.addFilter("dateToRfc822", (date) => {
return pluginRss.dateToRfc2822(date);
});
// Embed Everything - auto-embed YouTube, Vimeo, Bluesky, Mastodon, etc.
eleventyConfig.addPlugin(embedEverything, {
use: ["youtube", "vimeo", "twitter", "mastodon", "bluesky", "spotify", "soundcloud"],
youtube: {
options: {
lite: false,
recommendSelfOnly: true,
},
},
mastodon: {
options: {
server: "mstdn.social",
},
},
});
// Custom transform to convert YouTube links to embeds
eleventyConfig.addTransform("youtube-link-to-embed", function (content, outputPath) {
if (!outputPath || !outputPath.endsWith(".html")) {
return content;
}
// Match <a> tags where href contains youtube.com/watch or youtu.be
// Link text can be: URL, www.youtube..., youtube..., or youtube-related text
const youtubePattern = /<a[^>]+href="https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)[^"]*"[^>]*>(?:https?:\/\/)?(?:www\.)?[^<]*(?:youtube|youtu\.be)[^<]*<\/a>/gi;
content = content.replace(youtubePattern, (match, videoId) => {
// Use standard YouTube iframe with exact oEmbed parameters
return `</p><div class="video-embed"><iframe width="560" height="315" src="https://www.youtube.com/embed/${videoId}?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen title="YouTube video"></iframe></div><p>`;
});
// Clean up empty <p></p> tags created by the replacement
content = content.replace(/<p>\s*<\/p>/g, '');
return content;
});
// Image optimization - transforms <img> tags automatically
eleventyConfig.addPlugin(eleventyImageTransformPlugin, {
extensions: "html",
formats: ["webp", "jpeg"],
widths: ["auto"],
failOnError: false,
defaultAttributes: {
loading: "lazy",
decoding: "async",
sizes: "auto",
alt: "",
},
});
// Sitemap generation
eleventyConfig.addPlugin(sitemap, {
sitemap: {
hostname: siteUrl,
},
});
// HTML minification for production builds
eleventyConfig.addTransform("htmlmin", async function (content, outputPath) {
if (outputPath && outputPath.endsWith(".html")) {
return await minify(content, {
collapseWhitespace: true,
removeComments: true,
html5: true,
decodeEntities: true,
minifyCSS: true,
minifyJS: true,
});
}
return content;
});
// Copy static assets to output
eleventyConfig.addPassthroughCopy("css");
eleventyConfig.addPassthroughCopy("images");
// Watch for content changes
eleventyConfig.addWatchTarget("./content/");
eleventyConfig.addWatchTarget("./css/");
// Webmentions plugin configuration
const wmDomain = siteUrl.replace("https://", "").replace("http://", "");
eleventyConfig.addPlugin(pluginWebmentions, {
domain: siteUrl,
feed: `https://webmention.io/api/mentions.jf2?domain=${wmDomain}&token=${process.env.WEBMENTION_IO_TOKEN}`,
key: "children",
});
// Date formatting filter
eleventyConfig.addFilter("dateDisplay", (dateObj) => {
if (!dateObj) return "";
const date = new Date(dateObj);
return date.toLocaleDateString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
});
});
// ISO date filter
eleventyConfig.addFilter("isoDate", (dateObj) => {
if (!dateObj) return "";
return new Date(dateObj).toISOString();
});
// Truncate filter
eleventyConfig.addFilter("truncate", (str, len = 200) => {
if (!str) return "";
if (str.length <= len) return str;
return str.slice(0, len).trim() + "...";
});
// Clean excerpt for OpenGraph - strips HTML, decodes entities, removes extra whitespace
eleventyConfig.addFilter("ogDescription", (content, len = 200) => {
if (!content) return "";
// Strip HTML tags
let text = content.replace(/<[^>]+>/g, ' ');
// Decode common HTML entities
text = text.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ');
// Remove extra whitespace
text = text.replace(/\s+/g, ' ').trim();
// Truncate
if (text.length > len) {
text = text.slice(0, len).trim() + "...";
}
return text;
});
// Extract first image from content for OpenGraph fallback
eleventyConfig.addFilter("extractFirstImage", (content) => {
if (!content) return null;
// Match <img> tags and extract src attribute
const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["']/i);
if (imgMatch && imgMatch[1]) {
let src = imgMatch[1];
// Skip data URIs and external placeholder images
if (src.startsWith('data:')) return null;
// Return the src (will be made absolute in template)
return src;
}
return null;
});
// Head filter for arrays
eleventyConfig.addFilter("head", (array, n) => {
if (!Array.isArray(array) || n < 1) return array;
return array.slice(0, n);
});
// Slugify filter
eleventyConfig.addFilter("slugify", (str) => {
if (!str) return "";
return str
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
});
// Hash filter for cache busting - generates MD5 hash of file content
eleventyConfig.addFilter("hash", (filePath) => {
try {
const fullPath = resolve(__dirname, filePath.startsWith("/") ? `.${filePath}` : filePath);
const content = readFileSync(fullPath);
return createHash("md5").update(content).digest("hex").slice(0, 8);
} catch {
// Return timestamp as fallback if file not found
return Date.now().toString(36);
}
});
// Date filter (for sidebar dates)
eleventyConfig.addFilter("date", (dateObj, format) => {
if (!dateObj) return "";
const date = new Date(dateObj);
const options = {};
if (format.includes("MMM")) options.month = "short";
if (format.includes("d")) options.day = "numeric";
if (format.includes("yyyy")) options.year = "numeric";
return date.toLocaleDateString("en-US", options);
});
// Webmention filters
eleventyConfig.addFilter("webmentionsForUrl", function (webmentions, url) {
if (!webmentions || !url) return [];
const absoluteUrl = url.startsWith("http")
? url
: `${siteUrl}${url}`;
return webmentions.filter(
(wm) =>
wm["wm-target"] === absoluteUrl ||
wm["wm-target"] === absoluteUrl.replace(/\/$/, "")
);
});
eleventyConfig.addFilter("webmentionsByType", function (mentions, type) {
if (!mentions) return [];
const typeMap = {
likes: "like-of",
reposts: "repost-of",
bookmarks: "bookmark-of",
replies: "in-reply-to",
mentions: "mention-of",
};
const wmProperty = typeMap[type] || type;
return mentions.filter((m) => m["wm-property"] === wmProperty);
});
// Collections for different post types
// Note: content path is content/ due to symlink structure
// "posts" shows ALL content types combined
eleventyConfig.addCollection("posts", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/**/*.md")
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("notes", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/notes/**/*.md")
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("articles", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/articles/**/*.md")
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("bookmarks", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/bookmarks/**/*.md")
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("photos", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/photos/**/*.md")
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("likes", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/likes/**/*.md")
.sort((a, b) => b.date - a.date);
});
// Replies collection - posts with in_reply_to property
eleventyConfig.addCollection("replies", function (collectionApi) {
return collectionApi
.getAll()
.filter((item) => item.data.in_reply_to)
.sort((a, b) => b.date - a.date);
});
// Reposts collection - posts with repost_of property
eleventyConfig.addCollection("reposts", function (collectionApi) {
return collectionApi
.getAll()
.filter((item) => item.data.repost_of)
.sort((a, b) => b.date - a.date);
});
// All content combined for homepage feed
eleventyConfig.addCollection("feed", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/**/*.md")
.sort((a, b) => b.date - a.date)
.slice(0, 20);
});
// Categories collection - deduplicated by slug to avoid duplicate permalinks
eleventyConfig.addCollection("categories", function (collectionApi) {
const categoryMap = new Map(); // slug -> original name (first seen)
const slugify = (str) => str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
collectionApi.getAll().forEach((item) => {
if (item.data.category) {
const cats = Array.isArray(item.data.category) ? item.data.category : [item.data.category];
cats.forEach((cat) => {
if (cat && typeof cat === 'string' && cat.trim()) {
const slug = slugify(cat.trim());
if (slug && !categoryMap.has(slug)) {
categoryMap.set(slug, cat.trim());
}
}
});
}
});
return [...categoryMap.values()].sort();
});
// Recent posts for sidebar
eleventyConfig.addCollection("recentPosts", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/posts/**/*.md")
.sort((a, b) => b.date - a.date)
.slice(0, 5);
});
return {
dir: {
input: ".",
output: "_site",
includes: "_includes",
data: "_data",
},
markdownTemplateEngine: false, // Disable to avoid Nunjucks interpreting {{ in content
htmlTemplateEngine: "njk",
};
}

34
feed-json.njk Normal file
View File

@@ -0,0 +1,34 @@
---
permalink: /feed.json
eleventyExcludeFromCollections: true
---
{
"version": "https://jsonfeed.org/version/1.1",
"title": "{{ site.name }}",
"home_page_url": "{{ site.url }}/",
"feed_url": "{{ site.url }}/feed.json",
"description": "{{ site.description }}",
"language": "{{ site.locale | default('en') }}",
"authors": [
{
"name": "{{ site.author | default('Ricardo Mendes') }}",
"url": "{{ site.url }}/"
}
],
"items": [
{%- for post in collections.feed %}
{%- set absolutePostUrl = site.url + post.url %}
{%- set postImage = post.data.photo or post.data.image or (post.content | extractFirstImage) %}
{
"id": "{{ absolutePostUrl }}",
"url": "{{ absolutePostUrl }}",
"title": {{ post.data.title | default(post.content | striptags | truncate(80)) | jsonEncode | safe }},
"content_html": {{ post.content | htmlToAbsoluteUrls(absolutePostUrl) | jsonEncode | safe }},
"date_published": "{{ post.date | dateToRfc3339 }}"
{%- if postImage %},
"image": "{{ postImage | url | absoluteUrl(site.url) }}"
{%- endif %}
}{% if not loop.last %},{% endif %}
{%- endfor %}
]
}

31
feed.njk Normal file
View File

@@ -0,0 +1,31 @@
---
permalink: /feed.xml
eleventyExcludeFromCollections: true
---
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>{{ site.name }}</title>
<link>{{ site.url }}/</link>
<description>{{ site.description }}</description>
<language>{{ site.locale | default('en') }}</language>
<atom:link href="{{ site.url }}/feed.xml" rel="self" type="application/rss+xml"/>
<lastBuildDate>{{ collections.feed | getNewestCollectionItemDate | dateToRfc822 }}</lastBuildDate>
{%- for post in collections.feed %}
{%- set absolutePostUrl = site.url + post.url %}
{%- set postImage = post.data.photo or post.data.image or (post.content | extractFirstImage) %}
<item>
<title>{{ post.data.title | default(post.content | striptags | truncate(80)) | escape }}</title>
<link>{{ absolutePostUrl }}</link>
<guid isPermaLink="true">{{ absolutePostUrl }}</guid>
<pubDate>{{ post.date | dateToRfc822 }}</pubDate>
<description>{{ post.content | htmlToAbsoluteUrls(absolutePostUrl) | escape }}</description>
{%- if postImage %}
{%- set imageUrl = postImage | url | absoluteUrl(site.url) %}
<enclosure url="{{ imageUrl }}" type="image/jpeg" length="0"/>
<media:content url="{{ imageUrl }}" medium="image"/>
{%- endif %}
</item>
{%- endfor %}
</channel>
</rss>

268
funkwhale.njk Normal file
View File

@@ -0,0 +1,268 @@
---
layout: layouts/base.njk
title: Funkwhale Listening Activity
permalink: /funkwhale/
withSidebar: true
---
<div class="funkwhale-page" x-data="{ activeTab: 'all' }">
<header class="mb-8">
<h1 class="text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">Listening Activity</h1>
<p class="text-surface-600 dark:text-surface-400">
What I've been listening to on
<a href="{{ funkwhaleActivity.instanceUrl }}" class="text-primary-600 dark:text-primary-400 hover:underline" target="_blank" rel="noopener">
Funkwhale
</a>
</p>
</header>
{# Now Playing / Recently Played Hero #}
{% if funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.status %}
<section class="mb-12">
<div class="relative p-6 rounded-2xl overflow-hidden {% if funkwhaleActivity.nowPlaying.status == 'now-playing' %}bg-gradient-to-br from-green-500/10 to-green-600/5 border-2 border-green-500/30{% else %}bg-gradient-to-br from-primary-500/10 to-primary-600/5 border border-primary-500/20{% endif %}">
<div class="flex items-center gap-5">
{% if funkwhaleActivity.nowPlaying.coverUrl %}
<img
src="{{ funkwhaleActivity.nowPlaying.coverUrl }}"
alt=""
class="w-24 h-24 rounded-lg shadow-lg object-cover"
loading="lazy"
>
{% else %}
<div class="w-24 h-24 rounded-lg bg-surface-200 dark:bg-surface-700 flex items-center justify-center">
<svg class="w-10 h-10 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
</div>
{% endif %}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
{% if funkwhaleActivity.nowPlaying.status == 'now-playing' %}
<span class="inline-flex items-center gap-1.5 px-2 py-1 text-xs font-medium bg-green-500/20 text-green-700 dark:text-green-400 rounded-full">
<span class="flex gap-0.5 items-end h-3">
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 30%; animation-delay: 0s;"></span>
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 70%; animation-delay: 0.2s;"></span>
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span>
</span>
Now Playing
</span>
{% else %}
<span class="inline-flex items-center gap-1.5 px-2 py-1 text-xs font-medium bg-primary-500/20 text-primary-700 dark:text-primary-400 rounded-full">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Recently Played
</span>
{% endif %}
</div>
<h2 class="text-xl font-bold text-surface-900 dark:text-surface-100 truncate">
{% if funkwhaleActivity.nowPlaying.trackUrl %}
<a href="{{ funkwhaleActivity.nowPlaying.trackUrl }}" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank" rel="noopener">
{{ funkwhaleActivity.nowPlaying.track }}
</a>
{% else %}
{{ funkwhaleActivity.nowPlaying.track }}
{% endif %}
</h2>
<p class="text-surface-600 dark:text-surface-400">{{ funkwhaleActivity.nowPlaying.artist }}</p>
{% if funkwhaleActivity.nowPlaying.album %}
<p class="text-sm text-surface-500 mt-1">{{ funkwhaleActivity.nowPlaying.album }}</p>
{% endif %}
<p class="text-xs text-surface-500 mt-2">{{ funkwhaleActivity.nowPlaying.relativeTime }}</p>
</div>
</div>
</div>
</section>
{% endif %}
{# Stats Section with Tabs #}
{% if funkwhaleActivity.stats %}
<section class="mb-12">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6 flex items-center gap-2">
<svg class="w-6 h-6 text-primary-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
Listening Statistics
</h2>
{# Tab buttons #}
<div class="flex gap-1 mb-6 border-b border-surface-200 dark:border-surface-700 overflow-x-auto">
<button
@click="activeTab = 'all'"
:class="activeTab === 'all' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap"
>
All Time
</button>
<button
@click="activeTab = 'month'"
:class="activeTab === 'month' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap"
>
This Month
</button>
<button
@click="activeTab = 'week'"
:class="activeTab === 'week' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap"
>
This Week
</button>
<button
@click="activeTab = 'trends'"
:class="activeTab === 'trends' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap"
>
Trends
</button>
</div>
{# All Time Tab #}
<div x-show="activeTab === 'all'" x-cloak>
{% set summary = funkwhaleActivity.stats.summary.all %}
{% set topArtists = funkwhaleActivity.stats.topArtists.all %}
{% set topAlbums = funkwhaleActivity.stats.topAlbums.all %}
{% include "components/funkwhale-stats-content.njk" %}
</div>
{# This Month Tab #}
<div x-show="activeTab === 'month'" x-cloak>
{% set summary = funkwhaleActivity.stats.summary.month %}
{% set topArtists = funkwhaleActivity.stats.topArtists.month %}
{% set topAlbums = funkwhaleActivity.stats.topAlbums.month %}
{% include "components/funkwhale-stats-content.njk" %}
</div>
{# This Week Tab #}
<div x-show="activeTab === 'week'" x-cloak>
{% set summary = funkwhaleActivity.stats.summary.week %}
{% set topArtists = funkwhaleActivity.stats.topArtists.week %}
{% set topAlbums = funkwhaleActivity.stats.topAlbums.week %}
{% include "components/funkwhale-stats-content.njk" %}
</div>
{# Trends Tab #}
<div x-show="activeTab === 'trends'" x-cloak>
{% if funkwhaleActivity.stats.trends and funkwhaleActivity.stats.trends.length %}
<div class="bg-white dark:bg-surface-800 rounded-xl p-6 border border-surface-200 dark:border-surface-700">
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-4">Daily Listening (Last 30 Days)</h3>
<div class="flex items-end gap-1 h-32">
{% set maxCount = 1 %}
{% for day in funkwhaleActivity.stats.trends %}
{% if day.count > maxCount %}
{% set maxCount = day.count %}
{% endif %}
{% endfor %}
{% for day in funkwhaleActivity.stats.trends %}
<div
class="flex-1 bg-primary-500 hover:bg-primary-600 rounded-t transition-colors cursor-pointer"
style="height: {{ (day.count / maxCount * 100) if maxCount > 0 else 0 }}%; min-height: 2px;"
title="{{ day.date }}: {{ day.count }} plays"
></div>
{% endfor %}
</div>
<div class="flex justify-between text-xs text-surface-500 mt-2">
<span>{{ funkwhaleActivity.stats.trends[0].date }}</span>
<span>{{ funkwhaleActivity.stats.trends[funkwhaleActivity.stats.trends.length - 1].date }}</span>
</div>
</div>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No trend data available yet.</p>
{% endif %}
</div>
</section>
{% endif %}
{# Recent Listenings #}
<section class="mb-12">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6 flex items-center gap-2">
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Recent Listens
</h2>
{% if funkwhaleActivity.listenings.length %}
<div class="space-y-3">
{% for listening in funkwhaleActivity.listenings | head(15) %}
<div class="flex items-center gap-4 p-3 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-primary-400 dark:hover:border-primary-600 transition-colors">
{% if listening.coverUrl %}
<img src="{{ listening.coverUrl }}" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy">
{% else %}
<div class="w-12 h-12 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z"/>
</svg>
</div>
{% endif %}
<div class="flex-1 min-w-0">
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate">
{% if listening.trackUrl %}
<a href="{{ listening.trackUrl }}" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank" rel="noopener">
{{ listening.track }}
</a>
{% else %}
{{ listening.track }}
{% endif %}
</h3>
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ listening.artist }}</p>
</div>
<div class="text-right flex-shrink-0">
<span class="text-xs text-surface-500">{{ listening.relativeTime }}</span>
{% if listening.duration %}
<span class="text-xs text-surface-400 block">{{ listening.duration }}</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No recent listening history available.</p>
{% endif %}
</section>
{# Favorites #}
{% if funkwhaleActivity.favorites.length %}
<section class="mb-12">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6 flex items-center gap-2">
<svg class="w-6 h-6 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
Favorite Tracks
</h2>
<div class="grid md:grid-cols-2 gap-4">
{% for favorite in funkwhaleActivity.favorites | head(10) %}
<div class="flex items-center gap-3 p-3 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
{% if favorite.coverUrl %}
<img src="{{ favorite.coverUrl }}" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy">
{% else %}
<div class="w-14 h-14 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-surface-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</div>
{% endif %}
<div class="flex-1 min-w-0">
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate">
{% if favorite.trackUrl %}
<a href="{{ favorite.trackUrl }}" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank" rel="noopener">
{{ favorite.track }}
</a>
{% else %}
{{ favorite.track }}
{% endif %}
</h3>
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ favorite.artist }}</p>
{% if favorite.album %}
<p class="text-xs text-surface-500 truncate">{{ favorite.album }}</p>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
</div>

266
github.njk Normal file
View File

@@ -0,0 +1,266 @@
---
layout: layouts/base.njk
title: GitHub Activity
permalink: /github/
withSidebar: true
---
<div class="github-page">
<header class="mb-8">
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">GitHub Activity</h1>
<p class="text-surface-600 dark:text-surface-400">
My open source contributions and starred repositories.
<a href="https://github.com/{{ site.feeds.github }}" class="text-primary-600 dark:text-primary-400 hover:underline" target="_blank" rel="noopener">
Follow me on GitHub
</a>
</p>
</header>
{# Featured Projects Section #}
{% if githubActivity.featured.length %}
<section class="mb-12">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6 flex items-center gap-2">
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/>
</svg>
Featured Projects
</h2>
<div class="grid md:grid-cols-2 gap-6">
{% for repo in githubActivity.featured %}
<article class="p-5 bg-gradient-to-br from-primary-50 to-white dark:from-surface-800 dark:to-surface-800 rounded-xl border-2 border-primary-200 dark:border-primary-800 shadow-sm">
<div class="flex items-start justify-between mb-3">
<h3 class="font-bold text-lg text-surface-900 dark:text-surface-100">
<a href="{{ repo.url }}" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank" rel="noopener">
{{ repo.fullName }}
</a>
</h3>
{% if repo.isPrivate %}
<span class="text-xs px-2 py-0.5 bg-surface-200 dark:bg-surface-700 text-surface-600 dark:text-surface-400 rounded">Private</span>
{% endif %}
</div>
{% if repo.description %}
<p class="text-sm text-surface-600 dark:text-surface-400 mb-4">{{ repo.description }}</p>
{% endif %}
<div class="flex flex-wrap items-center gap-3 text-sm text-surface-500 mb-4">
{% if repo.language %}
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded-full bg-primary-500"></span>
{{ repo.language }}
</span>
{% endif %}
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279-7.416-3.967-7.417 3.967 1.481-8.279-6.064-5.828 8.332-1.151z"/>
</svg>
{{ repo.stars }}
</span>
{% if repo.forks > 0 %}
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/>
</svg>
{{ repo.forks }}
</span>
{% endif %}
</div>
{% if repo.commits and repo.commits.length %}
<details class="border-t border-primary-200 dark:border-surface-700 pt-3 mt-3">
<summary class="cursor-pointer text-sm font-medium text-primary-600 dark:text-primary-400 hover:underline">
Recent commits ({{ repo.commits.length }})
</summary>
<ul class="mt-3 space-y-2">
{% for commit in repo.commits %}
<li class="flex items-start gap-2 text-sm">
<code class="flex-shrink-0 text-xs font-mono bg-surface-100 dark:bg-surface-700 px-1.5 py-0.5 rounded">
<a href="{{ commit.url }}" class="text-primary-600 dark:text-primary-400 hover:underline" target="_blank" rel="noopener">{{ commit.sha }}</a>
</code>
<span class="text-surface-700 dark:text-surface-300 truncate">{{ commit.message }}</span>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
</article>
{% endfor %}
</div>
</section>
{% endif %}
{# Starred Repos Section #}
<section class="mb-12">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6 flex items-center gap-2">
<svg class="w-6 h-6 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279-7.416-3.967-7.417 3.967 1.481-8.279-6.064-5.828 8.332-1.151z"/>
</svg>
Starred Repositories
</h2>
{% if githubActivity.stars.length %}
<div class="grid md:grid-cols-2 gap-4">
{% for repo in githubActivity.stars | head(10) %}
<article class="p-4 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-primary-400 dark:hover:border-primary-600 transition-colors">
<h3 class="font-semibold text-surface-900 dark:text-surface-100 mb-1">
<a href="{{ repo.url }}" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank" rel="noopener">
{{ repo.name }}
</a>
</h3>
{% if repo.description %}
<p class="text-sm text-surface-600 dark:text-surface-400 mb-3">{{ repo.description }}</p>
{% endif %}
<div class="flex flex-wrap gap-2 mb-3">
{% for topic in repo.topics %}
<span class="text-xs px-2 py-0.5 bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200 rounded">
{{ topic }}
</span>
{% endfor %}
</div>
<div class="flex flex-wrap items-center gap-3 text-sm text-surface-500">
{% if repo.language %}
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded-full bg-primary-500"></span>
{{ repo.language }}
</span>
{% endif %}
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279-7.416-3.967-7.417 3.967 1.481-8.279-6.064-5.828 8.332-1.151z"/>
</svg>
{{ repo.stars }}
</span>
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No starred repositories found.</p>
{% endif %}
</section>
{# Recent Commits Section #}
<section class="mb-12">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6 flex items-center gap-2">
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Recent Commits
</h2>
{% if githubActivity.commits.length %}
<div class="space-y-3">
{% for commit in githubActivity.commits %}
<div class="flex items-start gap-3 p-3 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
<code class="text-xs font-mono bg-surface-100 dark:bg-surface-700 px-2 py-1 rounded">
{{ commit.sha }}
</code>
<div class="flex-1 min-w-0">
<a href="{{ commit.url }}" class="text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400" target="_blank" rel="noopener">
{{ commit.message }}
</a>
<p class="text-xs text-surface-500 mt-1">
<a href="{{ commit.repoUrl }}" class="hover:underline" target="_blank" rel="noopener">{{ commit.repo }}</a>
· {{ commit.date | date("MMM d, yyyy") }}
</p>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No recent commits found.</p>
{% endif %}
</section>
{# Contributions Section #}
{% if githubActivity.contributions.length %}
<section class="mb-12">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6 flex items-center gap-2">
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/>
</svg>
Pull Requests & Issues
</h2>
<div class="space-y-3">
{% for item in githubActivity.contributions %}
<div class="flex items-start gap-3 p-3 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
{% if item.type == "pr" %}
<span class="flex-shrink-0 px-2 py-1 text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded">PR</span>
{% else %}
<span class="flex-shrink-0 px-2 py-1 text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded">Issue</span>
{% endif %}
<div class="flex-1 min-w-0">
<a href="{{ item.url }}" class="text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400" target="_blank" rel="noopener">
{{ item.title }}
</a>
<p class="text-xs text-surface-500 mt-1">
<a href="{{ item.repoUrl }}" class="hover:underline" target="_blank" rel="noopener">{{ item.repo }}</a>
#{{ item.number }}
· {{ item.date | date("MMM d, yyyy") }}
</p>
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{# My Repositories Section #}
<section>
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6 flex items-center gap-2">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
</svg>
My Repositories
</h2>
{% if githubRepos.length %}
<div class="grid md:grid-cols-2 gap-4">
{% for repo in githubRepos | head(6) %}
<article class="p-4 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
<h3 class="font-semibold text-surface-900 dark:text-surface-100 mb-1">
<a href="{{ repo.html_url }}" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank" rel="noopener">
{{ repo.name }}
</a>
</h3>
{% if repo.description %}
<p class="text-sm text-surface-600 dark:text-surface-400 mb-3">{{ repo.description | truncate(100) }}</p>
{% endif %}
<div class="flex flex-wrap items-center gap-3 text-sm text-surface-500">
{% if repo.language %}
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded-full bg-primary-500"></span>
{{ repo.language }}
</span>
{% endif %}
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279-7.416-3.967-7.417 3.967 1.481-8.279-6.064-5.828 8.332-1.151z"/>
</svg>
{{ repo.stargazers_count }}
</span>
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/>
</svg>
{{ repo.forks_count }}
</span>
</div>
</article>
{% endfor %}
</div>
<a href="https://github.com/{{ site.feeds.github }}?tab=repositories" class="inline-block mt-4 text-primary-600 dark:text-primary-400 hover:underline">
View all repositories &rarr;
</a>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No repositories found.</p>
{% endif %}
</section>
</div>

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<circle cx="50" cy="50" r="50" fill="#e4e4e7"/>
<circle cx="50" cy="40" r="18" fill="#a1a1aa"/>
<ellipse cx="50" cy="85" rx="30" ry="25" fill="#a1a1aa"/>
</svg>

After

Width:  |  Height:  |  Size: 255 B

BIN
images/og-default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
images/rick.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

4
index.njk Normal file
View File

@@ -0,0 +1,4 @@
---
layout: layouts/home.njk
title: Home
---

105
interactions.njk Normal file
View File

@@ -0,0 +1,105 @@
---
layout: layouts/base.njk
title: Interactions
permalink: /interactions/
---
<div class="page-header mb-8">
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Interactions</h1>
<p class="text-surface-600 dark:text-surface-400">My engagement with content across the IndieWeb.</p>
</div>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{# Likes #}
<a href="/likes/" class="block p-6 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors group">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-red-100 dark:bg-red-900/30 rounded-full">
<svg class="w-6 h-6 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-primary-600 dark:group-hover:text-primary-400">Likes</h2>
<p class="text-sm text-surface-500">{{ collections.likes.length }} item{% if collections.likes.length != 1 %}s{% endif %}</p>
</div>
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm">Content I've appreciated across the web.</p>
</a>
{# Replies #}
<a href="/replies/" class="block p-6 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors group">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-full">
<svg class="w-6 h-6 text-primary-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-primary-600 dark:group-hover:text-primary-400">Replies</h2>
<p class="text-sm text-surface-500">{{ collections.replies.length }} item{% if collections.replies.length != 1 %}s{% endif %}</p>
</div>
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm">My responses to posts across the web.</p>
</a>
{# Bookmarks #}
<a href="/bookmarks/" class="block p-6 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors group">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-yellow-100 dark:bg-yellow-900/30 rounded-full">
<svg class="w-6 h-6 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-primary-600 dark:group-hover:text-primary-400">Bookmarks</h2>
<p class="text-sm text-surface-500">{{ collections.bookmarks.length }} item{% if collections.bookmarks.length != 1 %}s{% endif %}</p>
</div>
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm">Links I've saved for later.</p>
</a>
{# Reposts #}
<a href="/reposts/" class="block p-6 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors group">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-green-100 dark:bg-green-900/30 rounded-full">
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-primary-600 dark:group-hover:text-primary-400">Reposts</h2>
<p class="text-sm text-surface-500">{{ collections.reposts.length }} item{% if collections.reposts.length != 1 %}s{% endif %}</p>
</div>
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm">Content I've shared from others.</p>
</a>
{# Photos #}
<a href="/photos/" class="block p-6 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors group">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-primary-600 dark:group-hover:text-primary-400">Photos</h2>
<p class="text-sm text-surface-500">{{ collections.photos.length }} item{% if collections.photos.length != 1 %}s{% endif %}</p>
</div>
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm">Photo posts and images.</p>
</a>
</div>
<div class="mt-12 p-6 bg-primary-50 dark:bg-primary-900/20 rounded-lg">
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">About IndieWeb Interactions</h2>
<p class="text-surface-600 dark:text-surface-400 text-sm mb-4">
These pages show different types of IndieWeb interactions I've made. Each type uses specific microformat properties
to indicate the relationship to the original content.
</p>
<ul class="text-sm text-surface-600 dark:text-surface-400 space-y-1">
<li><strong>Likes</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-like-of</code></li>
<li><strong>Replies</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-in-reply-to</code></li>
<li><strong>Bookmarks</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-bookmark-of</code></li>
<li><strong>Reposts</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-repost-of</code></li>
</ul>
</div>

47
likes.njk Normal file
View File

@@ -0,0 +1,47 @@
---
layout: layouts/base.njk
title: Likes
permalink: /likes/
---
<div class="page-header mb-8">
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Likes</h1>
<p class="text-surface-600 dark:text-surface-400">Content I've liked across the web.</p>
</div>
{% if collections.likes.length > 0 %}
<ul class="space-y-6">
{% for post in collections.likes %}
<li class="h-entry p-4 bg-surface-100 dark:bg-surface-800 rounded-lg">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg class="w-8 h-8 text-red-500" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="post-meta text-sm text-surface-500 dark:text-surface-400 mb-2">
<time class="dt-published" datetime="{{ post.date.toISOString() }}">
{{ post.date | dateDisplay }}
</time>
</div>
{% if post.data.like_of %}
<p class="mb-2">
<a class="u-like-of text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ post.data.like_of }}">
{{ post.data.like_of }}
</a>
</p>
{% endif %}
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm max-w-none">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-surface-400 hover:underline mt-2 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No likes yet.</p>
{% endif %}

89
notes.njk Normal file
View File

@@ -0,0 +1,89 @@
---
layout: layouts/base.njk
title: Notes
withSidebar: true
pagination:
data: collections.notes
size: 20
alias: paginatedNotes
permalink: "notes/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
---
<div class="h-feed">
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Notes</h1>
<p class="text-surface-600 dark:text-surface-400 mb-8">
Short thoughts, updates, and quick posts.
<span class="text-sm">({{ collections.notes.length }} total)</span>
</p>
{% if paginatedNotes.length > 0 %}
<ul class="post-list">
{% for post in paginatedNotes %}
<li class="h-entry post-card">
<div class="post-header">
<a class="u-url" href="{{ post.url }}">
<time class="dt-published text-sm text-primary-600 dark:text-primary-400 font-medium" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</a>
{% if post.data.category %}
<span class="post-categories ml-2">
{% if post.data.category is string %}
<span class="p-category">{{ post.data.category }}</span>
{% else %}
{% for cat in post.data.category %}
<span class="p-category">{{ cat }}</span>
{% endfor %}
{% endif %}
</span>
{% endif %}
</div>
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
{{ post.templateContent | safe }}
</div>
<div class="post-footer mt-3">
<a href="{{ post.url }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">
Permalink
</a>
</div>
</li>
{% endfor %}
</ul>
{# Pagination controls #}
{% if pagination.pages.length > 1 %}
<nav class="pagination" aria-label="Notes pagination">
<div class="pagination-info">
Page {{ pagination.pageNumber + 1 }} of {{ pagination.pages.length }}
</div>
<div class="pagination-links">
{% if pagination.href.previous %}
<a href="{{ pagination.href.previous }}" class="pagination-link" aria-label="Previous page">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
Previous
</a>
{% else %}
<span class="pagination-link disabled">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
Previous
</span>
{% endif %}
{% if pagination.href.next %}
<a href="{{ pagination.href.next }}" class="pagination-link" aria-label="Next page">
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</a>
{% else %}
<span class="pagination-link disabled">
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</span>
{% endif %}
</div>
</nav>
{% endif %}
{% else %}
<p class="text-surface-600 dark:text-surface-400">No notes yet.</p>
{% endif %}
</div>

4514
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "rmendes-eleventy-site",
"version": "1.0.0",
"description": "Personal website powered by Indiekit and Eleventy",
"type": "module",
"scripts": {
"build": "eleventy",
"dev": "eleventy --serve --watch",
"build:css": "postcss css/tailwind.css -o css/style.css"
},
"dependencies": {
"@11ty/eleventy": "^3.0.0",
"html-minifier-terser": "^7.0.0",
"markdown-it": "^14.0.0",
"@11ty/eleventy-fetch": "^4.0.1",
"@11ty/eleventy-img": "^6.0.0",
"@11ty/eleventy-plugin-rss": "^2.0.2",
"@chrisburnell/eleventy-cache-webmentions": "^2.2.7",
"@quasibit/eleventy-plugin-sitemap": "^2.2.0",
"@atproto/api": "^0.12.0",
"eleventy-plugin-embed-everything": "^1.21.0",
"rss-parser": "^3.13.0"
},
"devDependencies": {
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"postcss-cli": "^11.0.0",
"autoprefixer": "^10.4.0",
"@tailwindcss/typography": "^0.5.0"
}
}

23
photos.njk Normal file
View File

@@ -0,0 +1,23 @@
---
layout: layouts/base.njk
title: Photos
permalink: /photos/
---
<h1>Photos</h1>
{% if collections.photos.length > 0 %}
<ul class="post-list">
{% for post in collections.photos %}
<li class="h-entry">
<div class="post-meta">
<time class="dt-published" datetime="{{ post.date.toISOString() }}">
{{ post.date | dateDisplay }}
</time>
</div>
<div class="e-content">{{ post.templateContent | safe }}</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>No photos yet.</p>
{% endif %}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

51
replies.njk Normal file
View File

@@ -0,0 +1,51 @@
---
layout: layouts/base.njk
title: Replies
permalink: /replies/
---
<div class="page-header mb-8">
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Replies</h1>
<p class="text-surface-600 dark:text-surface-400">My responses to posts across the web.</p>
</div>
{% if collections.replies.length > 0 %}
<ul class="space-y-6">
{% for post in collections.replies %}
<li class="h-entry p-4 bg-surface-100 dark:bg-surface-800 rounded-lg border-l-4 border-primary-500">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg class="w-8 h-8 text-primary-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
</div>
<div class="flex-1 min-w-0">
{% if post.data.title %}
<h2 class="p-name text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">
<a class="hover:underline" href="{{ post.url }}">{{ post.data.title }}</a>
</h2>
{% endif %}
<div class="post-meta text-sm text-surface-500 dark:text-surface-400 mb-2">
<time class="dt-published" datetime="{{ post.date.toISOString() }}">
{{ post.date | dateDisplay }}
</time>
</div>
{% if post.data.in_reply_to %}
<p class="mb-3 text-sm">
<span class="text-surface-500">In reply to:</span>
<a class="u-in-reply-to text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ post.data.in_reply_to }}">
{{ post.data.in_reply_to }}
</a>
</p>
{% endif %}
<div class="e-content prose dark:prose-invert prose-sm max-w-none">
{{ post.templateContent | safe }}
</div>
<a class="u-url text-xs text-surface-400 hover:underline mt-2 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No replies yet.</p>
{% endif %}

53
reposts.njk Normal file
View File

@@ -0,0 +1,53 @@
---
layout: layouts/base.njk
title: Reposts
permalink: /reposts/
---
<div class="page-header mb-8">
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Reposts</h1>
<p class="text-surface-600 dark:text-surface-400">Content I've shared from others.</p>
</div>
{% if collections.reposts.length > 0 %}
<ul class="space-y-6">
{% for post in collections.reposts %}
<li class="h-entry p-4 bg-surface-100 dark:bg-surface-800 rounded-lg">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg class="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</div>
<div class="flex-1 min-w-0">
{% if post.data.title %}
<h2 class="p-name text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">
<a class="hover:underline" href="{{ post.url }}">{{ post.data.title }}</a>
</h2>
{% endif %}
<div class="post-meta text-sm text-surface-500 dark:text-surface-400 mb-2">
<time class="dt-published" datetime="{{ post.date.toISOString() }}">
{{ post.date | dateDisplay }}
</time>
</div>
{% if post.data.repost_of %}
<p class="mb-3 text-sm">
<span class="text-surface-500">Reposted:</span>
<a class="u-repost-of text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ post.data.repost_of }}">
{{ post.data.repost_of }}
</a>
</p>
{% endif %}
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm max-w-none">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-surface-400 hover:underline mt-2 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No reposts yet.</p>
{% endif %}

79
tailwind.config.js Normal file
View File

@@ -0,0 +1,79 @@
import typography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./**/*.njk",
"./**/*.md",
"./_includes/**/*.njk",
"./content/**/*.md",
],
darkMode: "class",
theme: {
extend: {
colors: {
primary: {
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
950: "#172554",
},
surface: {
50: "#fafafa",
100: "#f4f4f5",
200: "#e4e4e7",
300: "#d4d4d8",
400: "#a1a1aa",
500: "#71717a",
600: "#52525b",
700: "#3f3f46",
800: "#27272a",
900: "#18181b",
950: "#09090b",
},
},
fontFamily: {
sans: [
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"sans-serif",
],
mono: [
"ui-monospace",
"SF Mono",
"Monaco",
"Cascadia Code",
"monospace",
],
},
maxWidth: {
content: "720px",
wide: "1200px",
},
typography: (theme) => ({
DEFAULT: {
css: {
"--tw-prose-links": theme("colors.primary.600"),
maxWidth: "none",
},
},
invert: {
css: {
"--tw-prose-links": theme("colors.primary.400"),
},
},
}),
},
},
plugins: [typography],
};

262
youtube.njk Normal file
View File

@@ -0,0 +1,262 @@
---
layout: layouts/base.njk
title: YouTube Channels
permalink: /youtube/
withSidebar: true
---
<div class="youtube-page" x-data="{ activeChannel: 0 }">
<header class="mb-8">
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">YouTube Channels</h1>
<p class="text-surface-600 dark:text-surface-400">
Latest videos and live streams from my YouTube channels.
</p>
</header>
{# Multi-channel tabs #}
{% if youtubeChannel.isMultiChannel and youtubeChannel.channels.length > 1 %}
<div class="mb-6">
<div class="flex flex-wrap gap-2 border-b border-surface-200 dark:border-surface-700">
{% for channel in youtubeChannel.channels %}
<button
@click="activeChannel = {{ loop.index0 }}"
:class="activeChannel === {{ loop.index0 }} ? 'border-red-500 text-red-600 dark:text-red-400' : 'border-transparent text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100'"
class="px-4 py-2 font-medium border-b-2 -mb-px transition-colors"
>
{{ channel.configName or channel.title }}
</button>
{% endfor %}
</div>
</div>
{% endif %}
{# Channel sections #}
{% for channel in youtubeChannel.channels %}
<div x-show="activeChannel === {{ loop.index0 }}" {% if not loop.first %}x-cloak{% endif %}>
{# Channel Header #}
<section class="mb-8">
<div class="flex items-center gap-5 p-6 bg-gradient-to-br from-red-500/10 to-red-600/5 dark:from-red-900/20 dark:to-red-800/10 rounded-2xl border border-red-500/20">
{% if channel.thumbnail %}
<img
src="{{ channel.thumbnail }}"
alt=""
class="w-20 h-20 rounded-full shadow-lg object-cover flex-shrink-0"
loading="lazy"
>
{% else %}
<div class="w-20 h-20 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<svg class="w-10 h-10 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
</svg>
</div>
{% endif %}
<div class="flex-1 min-w-0">
<h2 class="text-xl font-bold text-surface-900 dark:text-surface-100 truncate">
<a href="{{ channel.url }}" class="hover:text-red-600 dark:hover:text-red-400" target="_blank" rel="noopener">
{{ channel.title }}
</a>
</h2>
{% if channel.customUrl %}
<p class="text-sm text-surface-500">{{ channel.customUrl }}</p>
{% endif %}
<div class="flex flex-wrap items-center gap-4 mt-2 text-sm text-surface-600 dark:text-surface-400">
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
{{ channel.subscriberCountFormatted }} subscribers
</span>
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
{{ channel.videoCountFormatted }} videos
</span>
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ channel.viewCountFormatted }} views
</span>
</div>
</div>
{# Live Status Badge for this channel #}
{% set channelLiveStatus = youtubeChannel.liveStatuses | selectattr("channelConfigName", "equalto", channel.configName) | first %}
<div class="flex-shrink-0">
{% if channelLiveStatus and channelLiveStatus.isLive %}
<span class="inline-flex items-center gap-2 px-3 py-1.5 bg-red-500 text-white text-sm font-semibold rounded-full animate-pulse">
<span class="w-2 h-2 bg-white rounded-full"></span>
LIVE
</span>
{% elif channelLiveStatus and channelLiveStatus.isUpcoming %}
<span class="inline-flex items-center gap-2 px-3 py-1.5 bg-blue-500 text-white text-sm font-semibold rounded-full">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Upcoming
</span>
{% else %}
<span class="inline-flex items-center gap-2 px-3 py-1.5 bg-surface-200 dark:bg-surface-700 text-surface-600 dark:text-surface-400 text-sm font-medium rounded-full">
Offline
</span>
{% endif %}
</div>
</div>
</section>
{# Live Stream Section for this channel #}
{% set channelLiveStatus = youtubeChannel.liveStatuses | selectattr("channelConfigName", "equalto", channel.configName) | first %}
{% if channelLiveStatus and channelLiveStatus.stream and (channelLiveStatus.isLive or channelLiveStatus.isUpcoming) %}
<section class="mb-12">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6 flex items-center gap-2">
{% if channelLiveStatus.isLive %}
<span class="flex items-center gap-2 text-red-500">
<span class="w-3 h-3 bg-red-500 rounded-full animate-pulse"></span>
Live Now
</span>
{% else %}
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Upcoming Stream
{% endif %}
</h2>
<a href="{{ channelLiveStatus.stream.url }}" class="block group" target="_blank" rel="noopener">
<div class="relative rounded-xl overflow-hidden {% if channelLiveStatus.isLive %}ring-2 ring-red-500{% else %}ring-1 ring-blue-500/50{% endif %}">
{% if channelLiveStatus.stream.thumbnail %}
<img
src="{{ channelLiveStatus.stream.thumbnail }}"
alt=""
class="w-full aspect-video object-cover"
>
{% endif %}
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
<div class="p-6">
<h3 class="text-xl font-bold text-white group-hover:text-red-300 transition-colors">
{{ channelLiveStatus.stream.title }}
</h3>
<p class="text-white/80 mt-2 flex items-center gap-2">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Watch Now
</p>
</div>
</div>
{% if channelLiveStatus.isLive %}
<div class="absolute top-4 left-4">
<span class="inline-flex items-center gap-1.5 px-2 py-1 bg-red-500 text-white text-xs font-bold rounded">
<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span>
LIVE
</span>
</div>
{% endif %}
</div>
</a>
</section>
{% endif %}
{# Videos Grid for this channel #}
<section class="mb-12">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-6 flex items-center gap-2">
<svg class="w-6 h-6 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
</svg>
Latest Videos
</h2>
{% set channelName = channel.configName or channel.title %}
{% set channelVideos = youtubeChannel.videosByChannel[channelName] %}
{% if channelVideos and channelVideos.length %}
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{% for video in channelVideos | head(9) %}
<article class="group bg-white dark:bg-surface-800 rounded-xl overflow-hidden border border-surface-200 dark:border-surface-700 hover:border-red-400 dark:hover:border-red-600 transition-colors">
<a href="{{ video.url }}" class="block" target="_blank" rel="noopener">
<div class="relative aspect-video">
{% if video.thumbnail %}
<img
src="{{ video.thumbnail }}"
alt=""
class="w-full h-full object-cover"
loading="lazy"
>
{% else %}
<div class="w-full h-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center">
<svg class="w-12 h-12 text-surface-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
{% endif %}
{# Duration badge #}
{% if video.durationFormatted and not video.isLive %}
<span class="absolute bottom-2 right-2 px-1.5 py-0.5 bg-black/80 text-white text-xs font-medium rounded">
{{ video.durationFormatted }}
</span>
{% elif video.isLive %}
<span class="absolute bottom-2 right-2 px-1.5 py-0.5 bg-red-500 text-white text-xs font-bold rounded flex items-center gap-1">
<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span>
LIVE
</span>
{% endif %}
{# Play overlay on hover #}
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<div class="w-12 h-12 rounded-full bg-red-500/90 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<svg class="w-6 h-6 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
</div>
</a>
<div class="p-4">
<h3 class="font-semibold text-surface-900 dark:text-surface-100 line-clamp-2 mb-2 group-hover:text-red-600 dark:group-hover:text-red-400">
<a href="{{ video.url }}" target="_blank" rel="noopener">
{{ video.title }}
</a>
</h3>
<div class="flex items-center gap-3 text-sm text-surface-500">
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ video.viewCountFormatted }}
</span>
<span>{{ video.relativeTime }}</span>
</div>
</div>
</article>
{% endfor %}
</div>
<div class="mt-8 text-center">
<a
href="{{ channel.url }}"
class="inline-flex items-center gap-2 px-6 py-3 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg transition-colors"
target="_blank"
rel="noopener"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
</svg>
View All on YouTube
</a>
</div>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No videos available yet.</p>
{% endif %}
</section>
</div>
{% endfor %}
{# Fallback for no channels #}
{% if not youtubeChannel.channels.length %}
<p class="text-surface-600 dark:text-surface-400">No YouTube channels configured.</p>
{% endif %}
</div>