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