mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 16:44:56 +02:00
Initial commit: Indiekit Eleventy theme
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
8
404.njk
Normal 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
56
README.md
Normal 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
68
_data/blueskyFeed.js
Normal 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
190
_data/cv.js
Normal 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
123
_data/funkwhaleActivity.js
Normal 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
228
_data/githubActivity.js
Normal 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
48
_data/githubRepos.js
Normal 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
96
_data/mastodonFeed.js
Normal 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(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
66
_data/site.js
Normal file
66
_data/site.js
Normal 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
206
_data/youtubeChannel.js
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
184
_includes/components/blog-sidebar.njk
Normal file
184
_includes/components/blog-sidebar.njk
Normal 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>
|
||||||
66
_includes/components/funkwhale-stats-content.njk
Normal file
66
_includes/components/funkwhale-stats-content.njk
Normal 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 %}
|
||||||
64
_includes/components/h-card.njk
Normal file
64
_includes/components/h-card.njk
Normal 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>
|
||||||
63
_includes/components/reply-context.njk
Normal file
63
_includes/components/reply-context.njk
Normal 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 %}
|
||||||
258
_includes/components/sidebar.njk
Normal file
258
_includes/components/sidebar.njk
Normal 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 %}
|
||||||
158
_includes/components/webmentions.njk
Normal file
158
_includes/components/webmentions.njk
Normal 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
219
_includes/layouts/base.njk
Normal 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
208
_includes/layouts/home.njk
Normal 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
123
_includes/layouts/post.njk
Normal 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
68
about.njk
Normal 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
92
articles.njk
Normal 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 →
|
||||||
|
</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
130
blog.njk
Normal 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 →
|
||||||
|
</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
29
bookmarks.njk
Normal 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
27
categories-index.njk
Normal 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
69
categories.njk
Normal 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 →
|
||||||
|
</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">← All categories</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
317
css/tailwind.css
Normal file
317
css/tailwind.css
Normal 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
360
eleventy.config.js
Normal 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(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /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
34
feed-json.njk
Normal 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
31
feed.njk
Normal 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
268
funkwhale.njk
Normal 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
266
github.njk
Normal 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 →
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-surface-600 dark:text-surface-400">No repositories found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
5
images/default-avatar.svg
Normal file
5
images/default-avatar.svg
Normal 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
BIN
images/og-default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
images/rick.jpg
Normal file
BIN
images/rick.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
105
interactions.njk
Normal file
105
interactions.njk
Normal 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
47
likes.njk
Normal 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
89
notes.njk
Normal 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
4514
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal 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
23
photos.njk
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
51
replies.njk
Normal file
51
replies.njk
Normal 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
53
reposts.njk
Normal 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
79
tailwind.config.js
Normal 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
262
youtube.njk
Normal 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>
|
||||||
Reference in New Issue
Block a user