some fiddeling later

This commit is contained in:
svemagie
2026-03-06 14:37:53 +01:00
parent 30a0cfaf75
commit 6bb3c5a6d6
148 changed files with 26067 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
export default {
url: "https://blog.giersig.eu",
// Debug-Level erhöhen
debug: "indiekit:*",
application: {
name: "Indiekit",
admin: {
username: "admin@blog.giersig.eu",
password: "accus3D!23"
}
},
"@indiekit/endpoint-auth": {
publicUrl: "https://blog.giersig.eu"
},
publication: {
me: "https://blog.giersig.eu",
postTypes: [
{
type: "article",
name: "Artikel",
post: {
path: "src/posts/{slug}.md",
url: "https://blog.giersig.eu/posts/{slug}/",
},
},
{
type: "note",
name: "Notiz",
post: {
path: "src/notes/{slug}.md",
url: "https://blog.giersig.eu/notes/{slug}/",
},
},
{
type: "bookmark",
name: "Lesezeichen",
post: {
path: "src/bookmarks/{slug}.md",
url: "https://blog.giersig.eu/bookmarks/{slug}/",
},
},
],
},
secret: process.env.SECRET,
mongodbUrl: `mongodb://indiekit:${process.env.MONGO_PASSWORD}@10.100.0.20:27017/indiekit`,
plugins: [
"@indiekit/store-github",
"@rmdes/indiekit-endpoint-posts",
"@rmdes/indiekit-endpoint-auth",
"@rmdes/indiekit-endpoint-share",
"@rmdes/indiekit-endpoint-github",
"@rmdes/indiekit-endpoint-webmention-io",
"@rmdes/indiekit-endpoint-conversations",
// "@rmdes/indiekit-endpoint-activitypub",
],
"@indiekit/store-github": {
user: "svemagie",
repo: "blog",
branch: "main",
},
"@rmdes/indiekit-endpoint-github": {
token: process.env.GITHUB_TOKEN,
user: "svemagie",
},
"@rmdes/indiekit-endpoint-webmention-io": {
token: process.env.WEBMENTION_IO_TOKEN,
},
"@rmdes/indiekit-endpoint-conversations": {
enabled: true,
},
"@rmdes/indiekit-endpoint-activitypub": {
username: "blog.giersig.eu",
},
};
+34
View File
@@ -0,0 +1,34 @@
/**
* Blogroll Status Data
* Checks if the blogroll API backend is available at build time.
* Used for conditional navigation — the blogroll page itself loads data client-side.
*/
import EleventyFetch from "@11ty/eleventy-fetch";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
export default async function () {
try {
const url = `${INDIEKIT_URL}/blogrollapi/api/status`;
console.log(`[blogrollStatus] Checking API: ${url}`);
const data = await EleventyFetch(url, {
duration: "15m",
type: "json",
});
console.log("[blogrollStatus] API available");
return {
available: true,
source: "indiekit",
...data,
};
} catch (error) {
console.log(
`[blogrollStatus] API unavailable: ${error.message}`
);
return {
available: false,
source: "unavailable",
};
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Bluesky Feed Data
* Fetches recent posts from Bluesky using the AT Protocol API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { BskyAgent } from "@atproto/api";
export default async function () {
const handle = process.env.BLUESKY_HANDLE || "";
try {
// Create agent and resolve handle to DID
const agent = new BskyAgent({ service: "https://bsky.social" });
// Get the author's feed using public API (no auth needed for public posts)
const feedUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${handle}&limit=10`;
const response = await EleventyFetch(feedUrl, {
duration: "15m", // Cache for 15 minutes
type: "json",
fetchOptions: {
headers: {
Accept: "application/json",
},
},
});
if (!response.feed) {
console.log("No Bluesky feed found for handle:", handle);
return [];
}
// Transform the feed into a simpler format
return response.feed.map((item) => {
// Extract rkey from AT URI (at://did:plc:xxx/app.bsky.feed.post/rkey)
const rkey = item.post.uri.split("/").pop();
const postUrl = `https://bsky.app/profile/${item.post.author.handle}/post/${rkey}`;
return {
text: item.post.record.text,
createdAt: item.post.record.createdAt,
uri: item.post.uri,
url: postUrl,
cid: item.post.cid,
author: {
handle: item.post.author.handle,
displayName: item.post.author.displayName,
avatar: item.post.author.avatar,
},
likeCount: item.post.likeCount || 0,
repostCount: item.post.repostCount || 0,
replyCount: item.post.replyCount || 0,
// Extract any embedded links or images
embed: item.post.embed
? {
type: item.post.embed.$type,
images: item.post.embed.images || [],
external: item.post.embed.external || null,
}
: null,
};
});
} catch (error) {
console.error("Error fetching Bluesky feed:", error.message);
return [];
}
}
+14
View File
@@ -0,0 +1,14 @@
import EleventyFetch from "@11ty/eleventy-fetch";
export default async function () {
try {
const data = await EleventyFetch(
"http://127.0.0.1:8080/conversations/api/mentions?per-page=10000",
{ duration: "15m", type: "json" }
);
return data.children || [];
} catch (e) {
console.log(`[conversationMentions] API unavailable: ${e.message}`);
return [];
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* CV Data — reads from indiekit-endpoint-cv plugin data file.
*
* The CV plugin writes content/.indiekit/cv.json on every save
* and on startup. Eleventy reads that file here.
*
* Falls back to empty defaults if no plugin is installed.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default function () {
try {
const cvPath = resolve(__dirname, "..", "content", ".indiekit", "cv.json");
const raw = readFileSync(cvPath, "utf8");
const data = JSON.parse(raw);
console.log("[cv] Loaded CV data from plugin");
return data;
} catch {
// No CV plugin data file — return empty defaults
return {
lastUpdated: null,
experience: [],
projects: [],
skills: {},
skillTypes: {},
languages: [],
education: [],
interests: {},
interestTypes: {},
};
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* CV Page Configuration Data
* Reads config from indiekit-endpoint-cv plugin CV page builder.
* Falls back to null — cv.njk then uses the default hardcoded layout.
*
* The CV plugin writes a .indiekit/cv-page.json file that Eleventy watches.
* On change, a rebuild picks up the new config, allowing layout changes
* without a Docker rebuild.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default function () {
try {
// Resolve via the content/ symlink relative to the Eleventy project
const configPath = resolve(__dirname, "..", "content", ".indiekit", "cv-page.json");
const raw = readFileSync(configPath, "utf8");
const config = JSON.parse(raw);
console.log("[cvPageConfig] Loaded CV page builder config");
return config;
} catch {
// No CV page builder config — fall back to hardcoded layout in cv.njk
return null;
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Computed data resolved during the data cascade.
*
* Eleventy 3.x parallel rendering causes `page.url`, `page.fileSlug`,
* and `page.inputPath` to return values from OTHER pages being processed
* concurrently. This affects both templates and eleventyComputed functions.
*
* IMPORTANT: Only `permalink` is computed here, because it reads from the
* file's own frontmatter data (per-file, immune to race conditions).
* OG image lookups are done in templates using the `permalink` data value
* and Nunjucks filters (see base.njk).
*
* NEVER use `page.url`, `page.fileSlug`, or `page.inputPath` here.
*
* See: https://github.com/11ty/eleventy/issues/3183
*/
export default {
eleventyComputed: {
// Compute permalink from file path for posts without explicit frontmatter permalink.
// Pattern: content/{type}/{yyyy}-{MM}-{dd}-{slug}.md → /{type}/{yyyy}/{MM}/{dd}/{slug}/
permalink: (data) => {
// Convert stale /content/ permalinks from pre-beta.37 posts to canonical format
if (data.permalink && typeof data.permalink === "string") {
const contentMatch = data.permalink.match(
/^\/content\/([^/]+)\/(\d{4})-(\d{2})-(\d{2})-(.+?)\/?$/
);
if (contentMatch) {
const [, type, year, month, day, slug] = contentMatch;
return `/${type}/${year}/${month}/${day}/${slug}/`;
}
// Valid non-/content/ permalink — use as-is
return data.permalink;
}
// No frontmatter permalink — compute from file path
// NOTE: data.page.inputPath may be wrong due to parallel rendering,
// but posts without frontmatter permalink are rare (only pre-beta.37 edge cases)
const inputPath = data.page?.inputPath || "";
const match = inputPath.match(
/content\/([^/]+)\/(\d{4})-(\d{2})-(\d{2})-(.+)\.md$/
);
if (match) {
const [, type, year, month, day, slug] = match;
return `/${type}/${year}/${month}/${day}/${slug}/`;
}
// For non-matching files (pages, root files), let Eleventy decide
return data.permalink;
},
},
};
+50
View File
@@ -0,0 +1,50 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const CONTENT_DIR = process.env.CONTENT_DIR || "/data/content";
// Standard post types for any Indiekit deployment
const ALL_POST_TYPES = [
{ type: "article", label: "Articles", path: "/articles/", createUrl: "/posts/create?type=article" },
{ type: "note", label: "Notes", path: "/notes/", createUrl: "/posts/create?type=note" },
{ type: "photo", label: "Photos", path: "/photos/", createUrl: "/posts/create?type=photo" },
{ type: "bookmark", label: "Bookmarks", path: "/bookmarks/", createUrl: "/posts/create?type=bookmark" },
{ type: "like", label: "Likes", path: "/likes/", createUrl: "/posts/create?type=like" },
{ type: "reply", label: "Replies", path: "/replies/", createUrl: "/posts/create?type=reply" },
{ type: "repost", label: "Reposts", path: "/reposts/", createUrl: "/posts/create?type=repost" },
];
/**
* Returns the list of enabled post types.
*
* Resolution order:
* 1. .indiekit/post-types.json in content dir (written by Indiekit or deployer)
* 2. POST_TYPES env var (comma-separated: "article,note,photo")
* 3. All standard post types (default)
*/
export default function () {
// 1. Try config file
try {
const configPath = resolve(CONTENT_DIR, ".indiekit", "post-types.json");
const raw = readFileSync(configPath, "utf8");
const types = JSON.parse(raw);
if (Array.isArray(types)) {
// Array of type strings: ["article", "note"]
return ALL_POST_TYPES.filter((pt) => types.includes(pt.type));
}
// Array of objects with at least { type }
return types;
} catch {
// File doesn't exist — fall through
}
// 2. Try env var
const envTypes = process.env.POST_TYPES;
if (envTypes) {
const types = envTypes.split(",").map((t) => t.trim().toLowerCase());
return ALL_POST_TYPES.filter((pt) => types.includes(pt.type));
}
// 3. Default — all standard types
return ALL_POST_TYPES;
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Funkwhale Activity Data
* Fetches from Indiekit's endpoint-funkwhale public API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
const FUNKWHALE_INSTANCE = process.env.FUNKWHALE_INSTANCE || "";
/**
* Fetch from Indiekit's public Funkwhale API endpoint
*/
async function fetchFromIndiekit(endpoint) {
try {
const url = `${INDIEKIT_URL}/funkwhaleapi/api/${endpoint}`;
console.log(`[funkwhaleActivity] Fetching from Indiekit: ${url}`);
const data = await EleventyFetch(url, {
duration: "15m",
type: "json",
});
console.log(`[funkwhaleActivity] Indiekit ${endpoint} success`);
return data;
} catch (error) {
console.log(
`[funkwhaleActivity] Indiekit API unavailable for ${endpoint}: ${error.message}`
);
return null;
}
}
/**
* Format duration in seconds to human-readable string
*/
function formatDuration(seconds) {
if (!seconds || seconds < 0) return "0:00";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}d`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
export default async function () {
try {
console.log("[funkwhaleActivity] Fetching Funkwhale data...");
// Fetch all data from Indiekit API
const [nowPlaying, listenings, favorites, stats] = await Promise.all([
fetchFromIndiekit("now-playing"),
fetchFromIndiekit("listenings"),
fetchFromIndiekit("favorites"),
fetchFromIndiekit("stats"),
]);
// Check if we got data
const hasData = nowPlaying || listenings?.listenings?.length || stats?.summary;
if (!hasData) {
console.log("[funkwhaleActivity] No data available from Indiekit");
return {
nowPlaying: null,
listenings: [],
favorites: [],
stats: null,
instanceUrl: FUNKWHALE_INSTANCE,
source: "unavailable",
};
}
console.log("[funkwhaleActivity] Using Indiekit API data");
// Format stats with human-readable durations
let formattedStats = null;
if (stats?.summary) {
formattedStats = {
...stats,
summary: {
all: {
...stats.summary.all,
totalDurationFormatted: formatDuration(stats.summary.all?.totalDuration || 0),
},
month: {
...stats.summary.month,
totalDurationFormatted: formatDuration(stats.summary.month?.totalDuration || 0),
},
week: {
...stats.summary.week,
totalDurationFormatted: formatDuration(stats.summary.week?.totalDuration || 0),
},
},
};
}
return {
nowPlaying: nowPlaying || null,
listenings: listenings?.listenings || [],
favorites: favorites?.favorites || [],
stats: formattedStats,
instanceUrl: FUNKWHALE_INSTANCE,
source: "indiekit",
};
} catch (error) {
console.error("[funkwhaleActivity] Error:", error.message);
return {
nowPlaying: null,
listenings: [],
favorites: [],
stats: null,
instanceUrl: FUNKWHALE_INSTANCE,
source: "error",
};
}
}
+284
View File
@@ -0,0 +1,284 @@
/**
* 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;
}
/**
* Fetch commits directly from user's recently pushed repos
* Fallback when events API doesn't include commit details
*/
async function fetchCommitsFromRepos(username, limit = 10) {
try {
const repos = await fetchFromGitHub(
`/users/${username}/repos?sort=pushed&per_page=5`
);
if (!Array.isArray(repos) || repos.length === 0) {
return [];
}
const allCommits = [];
for (const repo of repos.slice(0, 5)) {
try {
const repoCommits = await fetchFromGitHub(
`/repos/${repo.full_name}/commits?per_page=5`
);
for (const c of repoCommits) {
allCommits.push({
sha: c.sha.slice(0, 7),
message: truncate(c.commit?.message?.split("\n")[0]),
url: c.html_url,
repo: repo.full_name,
repoUrl: repo.html_url,
date: c.commit?.author?.date,
});
}
} catch {
// Skip repos we can't access
}
}
// Sort by date and limit
return allCommits
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, limit);
} catch (error) {
console.log(`[githubActivity] Could not fetch commits from repos: ${error.message}`);
return [];
}
}
export default async function () {
try {
console.log("[githubActivity] Fetching GitHub data...");
// Try Indiekit public API first
const [indiekitStars, indiekitCommits, indiekitContributions, indiekitActivity, indiekitFeatured] =
await Promise.all([
fetchFromIndiekit("stars"),
fetchFromIndiekit("commits"),
fetchFromIndiekit("contributions"),
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 || [],
contributions: indiekitContributions?.contributions || [],
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),
]);
// Try to extract commits from events first
let commits = extractCommits(events || []);
// If events API didn't have commits, fetch directly from repos
if (commits.length === 0 && GITHUB_USERNAME) {
console.log("[githubActivity] Events API returned no commits, fetching from repos");
commits = await fetchCommitsFromRepos(GITHUB_USERNAME, 10);
}
return {
stars: formatStarred(starred || []),
commits,
contributions: extractContributions(events || []),
featured,
source: "github",
};
} catch (error) {
console.error("[githubActivity] Error:", error.message);
return {
stars: [],
commits: [],
contributions: [],
featured: [],
source: "error",
};
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* GitHub Repos Data
* Fetches public repositories from GitHub API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
export default async function () {
const username = process.env.GITHUB_USERNAME || "";
try {
// Fetch public repos, sorted by updated date
const url = `https://api.github.com/users/${username}/repos?sort=updated&per_page=10&type=owner`;
const repos = await EleventyFetch(url, {
duration: "1h", // Cache for 1 hour
type: "json",
fetchOptions: {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "Eleventy-Site",
},
},
});
// Filter and transform repos
return repos
.filter((repo) => !repo.fork && !repo.private) // Exclude forks and private repos
.map((repo) => ({
name: repo.name,
full_name: repo.full_name,
description: repo.description,
html_url: repo.html_url,
homepage: repo.homepage,
language: repo.language,
stargazers_count: repo.stargazers_count,
forks_count: repo.forks_count,
open_issues_count: repo.open_issues_count,
topics: repo.topics || [],
updated_at: repo.updated_at,
created_at: repo.created_at,
}))
.slice(0, 10); // Limit to 10 repos
} catch (error) {
console.error("Error fetching GitHub repos:", error.message);
return [];
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* GitHub Starred Repos Metadata
* Fetches the starred API response (cached 15min) to extract totalCount.
* Only totalCount is passed to Eleventy's data cascade — the full star
* list is discarded after parsing, keeping build memory low.
* The starred page fetches all data client-side via Alpine.js.
*/
import EleventyFetch from "@11ty/eleventy-fetch";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
export default async function () {
try {
const url = `${INDIEKIT_URL}/githubapi/api/starred/all`;
const response = await EleventyFetch(url, {
duration: "15m",
type: "json",
});
return {
totalCount: response.totalCount || 0,
buildDate: new Date().toISOString(),
};
} catch (error) {
console.log(`[githubStarred] Could not fetch starred count: ${error.message}`);
return {
totalCount: 0,
buildDate: new Date().toISOString(),
};
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Homepage Configuration Data
* Reads config from indiekit-endpoint-homepage plugin (when installed).
* Falls back to null — home.njk then uses the default layout.
*
* Future: The homepage plugin will write a .indiekit/homepage.json file
* that Eleventy watches. On change, a rebuild picks up the new config,
* allowing layout changes without a Docker rebuild.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default function () {
try {
// Resolve via the content/ symlink relative to the Eleventy project
const configPath = resolve(__dirname, "..", "content", ".indiekit", "homepage.json");
const raw = readFileSync(configPath, "utf8");
const config = JSON.parse(raw);
console.log("[homepageConfig] Loaded plugin config");
return config;
} catch {
// No homepage plugin config — this is the normal case for most deployments
return null;
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Last.fm Activity Data
* Fetches from Indiekit's endpoint-lastfm public API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
const LASTFM_USERNAME = process.env.LASTFM_USERNAME || "";
/**
* Fetch from Indiekit's public Last.fm API endpoint
*/
async function fetchFromIndiekit(endpoint) {
try {
const url = `${INDIEKIT_URL}/lastfmapi/api/${endpoint}`;
console.log(`[lastfmActivity] Fetching from Indiekit: ${url}`);
const data = await EleventyFetch(url, {
duration: "15m",
type: "json",
});
console.log(`[lastfmActivity] Indiekit ${endpoint} success`);
return data;
} catch (error) {
console.log(
`[lastfmActivity] Indiekit API unavailable for ${endpoint}: ${error.message}`
);
return null;
}
}
export default async function () {
try {
console.log("[lastfmActivity] Fetching Last.fm data...");
// Fetch all data from Indiekit API
const [nowPlaying, scrobbles, loved, stats] = await Promise.all([
fetchFromIndiekit("now-playing"),
fetchFromIndiekit("scrobbles"),
fetchFromIndiekit("loved"),
fetchFromIndiekit("stats"),
]);
// Check if we got data
const hasData = nowPlaying || scrobbles?.scrobbles?.length || stats?.summary;
if (!hasData) {
console.log("[lastfmActivity] No data available from Indiekit");
return {
nowPlaying: null,
scrobbles: [],
loved: [],
stats: null,
username: LASTFM_USERNAME,
profileUrl: LASTFM_USERNAME ? `https://www.last.fm/user/${LASTFM_USERNAME}` : null,
source: "unavailable",
};
}
console.log("[lastfmActivity] Using Indiekit API data");
return {
nowPlaying: nowPlaying || null,
scrobbles: scrobbles?.scrobbles || [],
loved: loved?.loved || [],
stats: stats || null,
username: LASTFM_USERNAME,
profileUrl: LASTFM_USERNAME ? `https://www.last.fm/user/${LASTFM_USERNAME}` : null,
source: "indiekit",
};
} catch (error) {
console.error("[lastfmActivity] Error:", error.message);
return {
nowPlaying: null,
scrobbles: [],
loved: [],
stats: null,
username: LASTFM_USERNAME,
profileUrl: null,
source: "error",
};
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Mastodon Feed Data
* Fetches recent posts from Mastodon using the public API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
export default async function () {
const instance = process.env.MASTODON_INSTANCE?.replace("https://", "") || "";
const username = process.env.MASTODON_USER || "";
try {
// First, look up the account ID
const lookupUrl = `https://${instance}/api/v1/accounts/lookup?acct=${username}`;
const account = await EleventyFetch(lookupUrl, {
duration: "1h", // Cache account lookup for 1 hour
type: "json",
fetchOptions: {
headers: {
Accept: "application/json",
},
},
});
if (!account || !account.id) {
console.log("Mastodon account not found:", username);
return [];
}
// Fetch recent statuses (excluding replies and boosts for cleaner feed)
const statusesUrl = `https://${instance}/api/v1/accounts/${account.id}/statuses?limit=10&exclude_replies=true&exclude_reblogs=true`;
const statuses = await EleventyFetch(statusesUrl, {
duration: "15m", // Cache for 15 minutes
type: "json",
fetchOptions: {
headers: {
Accept: "application/json",
},
},
});
if (!statuses || !Array.isArray(statuses)) {
console.log("No Mastodon statuses found for:", username);
return [];
}
// Transform statuses into a simpler format
return statuses.map((status) => ({
id: status.id,
url: status.url,
text: stripHtml(status.content),
htmlContent: status.content,
createdAt: status.created_at,
author: {
username: status.account.username,
displayName: status.account.display_name || status.account.username,
avatar: status.account.avatar,
url: status.account.url,
},
favouritesCount: status.favourites_count || 0,
reblogsCount: status.reblogs_count || 0,
repliesCount: status.replies_count || 0,
// Media attachments
media: status.media_attachments
? status.media_attachments.map((m) => ({
type: m.type,
url: m.url,
previewUrl: m.preview_url,
description: m.description,
}))
: [],
}));
} catch (error) {
console.error("Error fetching Mastodon feed:", error.message);
return [];
}
}
// Simple HTML stripper for plain text display
function stripHtml(html) {
if (!html) return "";
return html
.replace(/<br\s*\/?>/gi, " ")
.replace(/<\/p>/gi, " ")
.replace(/<[^>]+>/g, "")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
+99
View File
@@ -0,0 +1,99 @@
/**
* News/RSS Activity Data
* Fetches from Indiekit's endpoint-rss public API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
/**
* Fetch from Indiekit's public RSS API endpoint
*/
async function fetchFromIndiekit(endpoint) {
try {
const url = `${INDIEKIT_URL}/rssapi/api/${endpoint}`;
console.log(`[newsActivity] Fetching from Indiekit: ${url}`);
const data = await EleventyFetch(url, {
duration: "15m",
type: "json",
});
console.log(`[newsActivity] Indiekit ${endpoint} success`);
return data;
} catch (error) {
console.log(
`[newsActivity] Indiekit API unavailable for ${endpoint}: ${error.message}`
);
return null;
}
}
export default async function () {
try {
console.log("[newsActivity] Fetching RSS feed data...");
// Fetch all data from Indiekit API
const [itemsRes, feedsRes, statusRes] = await Promise.all([
fetchFromIndiekit("items?limit=50"),
fetchFromIndiekit("feeds"),
fetchFromIndiekit("status"),
]);
// Check if we got data
const hasData = itemsRes?.items?.length || feedsRes?.feeds?.length;
if (!hasData) {
console.log("[newsActivity] No data available from Indiekit");
return {
items: [],
feeds: [],
status: null,
lastUpdated: null,
source: "unavailable",
};
}
console.log(
`[newsActivity] Got ${itemsRes?.items?.length || 0} items from ${feedsRes?.feeds?.length || 0} feeds`
);
// Create a map of feed IDs to feed info for quick lookup
const feedMap = new Map();
for (const feed of feedsRes?.feeds || []) {
feedMap.set(feed.id, feed);
}
// Enhance items with additional feed info
const items = (itemsRes?.items || []).map((item) => {
const feed = feedMap.get(item.feedId);
return {
...item,
feedInfo: feed
? {
title: feed.title,
siteUrl: feed.siteUrl,
imageUrl: feed.imageUrl,
}
: null,
};
});
return {
items,
feeds: feedsRes?.feeds || [],
pagination: itemsRes?.pagination || null,
status: statusRes || null,
lastUpdated: statusRes?.lastSync || new Date().toISOString(),
source: "indiekit",
};
} catch (error) {
console.error("[newsActivity] Error:", error.message);
return {
items: [],
feeds: [],
status: null,
lastUpdated: null,
source: "error",
};
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Podroll Status Data
* Checks if the podroll API backend is available at build time.
* Used for conditional navigation — the podroll page itself loads data client-side.
*/
import EleventyFetch from "@11ty/eleventy-fetch";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
export default async function () {
try {
const url = `${INDIEKIT_URL}/podrollapi/api/status`;
console.log(`[podrollStatus] Checking API: ${url}`);
const data = await EleventyFetch(url, {
duration: "15m",
type: "json",
});
console.log("[podrollStatus] API available");
return {
available: true,
source: "indiekit",
...data,
};
} catch (error) {
console.log(
`[podrollStatus] API unavailable: ${error.message}`
);
return {
available: false,
source: "unavailable",
};
}
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Recent Comments Data
* Fetches the 5 most recent comments at build time for the sidebar widget.
*/
import EleventyFetch from "@11ty/eleventy-fetch";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
export default async function () {
try {
const url = `${INDIEKIT_URL}/comments/api/comments?limit=5`;
console.log(`[recentComments] Fetching: ${url}`);
const data = await EleventyFetch(url, {
duration: "15m",
type: "json",
});
console.log(`[recentComments] Got ${(data.children || []).length} comments`);
return data.children || [];
} catch (error) {
console.log(`[recentComments] Unavailable: ${error.message}`);
return [];
}
}
+139
View File
@@ -0,0 +1,139 @@
/**
* 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());
// Bluesky requires "me atproto" for verification
const rel = url.includes("bsky.app") ? "me atproto" : "me";
return { name, url, rel, icon: icon || name.toLowerCase() };
});
}
// Get fediverse handle for fediverse:creator meta tag
// Prefers the site's own ActivityPub identity over external Mastodon account
function getFediverseCreator() {
// Primary: site's own ActivityPub actor (canonical fediverse identity)
const apHandle = process.env.ACTIVITYPUB_HANDLE;
if (apHandle) {
const domain = (process.env.SITE_URL || "https://example.com").replace(/^https?:\/\//, "");
return `@${apHandle}@${domain}`;
}
// Fallback: external Mastodon account (syndication target)
const instance = process.env.MASTODON_INSTANCE?.replace("https://", "") || "";
const user = process.env.MASTODON_USER || "";
if (instance && user) {
return `@${user}@${instance}`;
}
return "";
}
// Auto-generate social links from feed config when SITE_SOCIAL is not set
function buildSocialFromFeeds() {
const links = [];
const github = process.env.GITHUB_USERNAME;
if (github) {
links.push({ name: "GitHub", url: `https://github.com/${github}`, rel: "me", icon: "github" });
}
const bskyHandle = process.env.BLUESKY_HANDLE;
if (bskyHandle) {
links.push({ name: "Bluesky", url: `https://bsky.app/profile/${bskyHandle}`, rel: "me atproto", icon: "bluesky" });
}
const mastoInstance = process.env.MASTODON_INSTANCE?.replace("https://", "");
const mastoUser = process.env.MASTODON_USER;
if (mastoInstance && mastoUser) {
links.push({ name: "Mastodon", url: `https://${mastoInstance}/@${mastoUser}`, rel: "me", icon: "mastodon" });
}
const linkedin = process.env.LINKEDIN_USERNAME;
if (linkedin) {
links.push({ name: "LinkedIn", url: `https://linkedin.com/in/${linkedin}`, rel: "me", icon: "linkedin" });
}
const apHandle = process.env.ACTIVITYPUB_HANDLE;
if (apHandle) {
const siteUrl = process.env.SITE_URL || "https://example.com";
links.push({ name: "ActivityPub", url: `${siteUrl}/activitypub/users/${apHandle}`, rel: "me", icon: "activitypub" });
}
return links;
}
// site.url: no trailing slash — used as URL base for path concatenation ({{ site.url }}/path)
// site.me / site.author.url: trailing slash — Mastodon rel="me" requires exact match
const siteUrlBase = (process.env.SITE_URL || "https://example.com").replace(/\/$/, "");
const siteUrlWithSlash = siteUrlBase + "/";
export default {
// Basic site info
name: process.env.SITE_NAME || "My IndieWeb Blog",
url: siteUrlBase,
me: siteUrlWithSlash,
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: siteUrlWithSlash,
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 || "",
locality: process.env.AUTHOR_LOCALITY || "",
region: process.env.AUTHOR_REGION || "",
country: process.env.AUTHOR_COUNTRY || "",
org: process.env.AUTHOR_ORG || "",
pronoun: process.env.AUTHOR_PRONOUN || "",
categories: process.env.AUTHOR_CATEGORIES?.split(",").map(s => s.trim()) || [],
keyUrl: process.env.AUTHOR_KEY_URL || "",
email: process.env.AUTHOR_EMAIL || "",
},
// Social links (for rel="me" and h-card)
// Set SITE_SOCIAL env var as: "GitHub|https://github.com/user|github,Mastodon|https://mastodon.social/@user|mastodon"
// Falls back to auto-generating from feed config (GITHUB_USERNAME, BLUESKY_HANDLE, etc.)
social: parseSocialLinks(process.env.SITE_SOCIAL).length > 0
? parseSocialLinks(process.env.SITE_SOCIAL)
: buildSocialFromFeeds(),
// 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",
},
// Fediverse creator for meta tag (e.g., @rick@rmendes.net)
fediverseCreator: getFediverseCreator(),
// Support/monetization configuration (used in _textcasting JSON Feed extension)
support: {
url: process.env.SUPPORT_URL || null,
stripe: process.env.SUPPORT_STRIPE_URL || null,
lightning: process.env.SUPPORT_LIGHTNING_ADDRESS || null,
paymentPointer: process.env.SUPPORT_PAYMENT_POINTER || null,
},
// Markdown for Agents — serve clean Markdown to AI agents
// Set MARKDOWN_AGENTS_ENABLED to "false" to disable entirely
markdownAgents: {
enabled: (process.env.MARKDOWN_AGENTS_ENABLED || "true").toLowerCase() === "true",
aiTrain: process.env.MARKDOWN_AGENTS_AI_TRAIN || "yes",
search: process.env.MARKDOWN_AGENTS_SEARCH || "yes",
aiInput: process.env.MARKDOWN_AGENTS_AI_INPUT || "yes",
},
};
+155
View File
@@ -0,0 +1,155 @@
/**
* URL Aliases for Webmention Recovery
*
* Maps new URLs to their old URLs so webmentions from previous
* URL structures can be displayed on current pages.
*
* Place redirect map files in the parent directory of this theme:
* - redirects.map (e.g., micro.blog: /YYYY/MM/DD/slug.html → /notes/...)
* - old-blog-redirects.map (e.g., Known/WP: /YYYY/slug → /content/...)
*/
import { readFileSync, existsSync } 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";
/**
* Parse a redirect map file into URL mappings
* Format: old_path new_path;
*/
function parseRedirectMap(filePath) {
const aliases = {};
if (!existsSync(filePath)) {
console.log(`[urlAliases] File not found: ${filePath}`);
return aliases;
}
try {
const content = readFileSync(filePath, "utf-8");
const lines = content.split("\n").filter((line) => {
const trimmed = line.trim();
return trimmed && !trimmed.startsWith("#");
});
for (const line of lines) {
// Format: /old/path /new/path;
const match = line.match(/^(\S+)\s+(\S+);?$/);
if (match) {
const [, oldPath, newPath] = match;
// Normalize paths (remove trailing slashes, ensure leading slash)
const normalizedNew = newPath.replace(/;$/, "").replace(/\/$/, "");
const normalizedOld = oldPath.replace(/\/$/, "");
// Map new URL → array of old URLs
if (!aliases[normalizedNew]) {
aliases[normalizedNew] = [];
}
aliases[normalizedNew].push(normalizedOld);
}
}
} catch (error) {
console.error(`[urlAliases] Error parsing ${filePath}:`, error.message);
}
return aliases;
}
/**
* Merge multiple alias maps
*/
function mergeAliases(...maps) {
const merged = {};
for (const map of maps) {
for (const [newUrl, oldUrls] of Object.entries(map)) {
if (!merged[newUrl]) {
merged[newUrl] = [];
}
merged[newUrl].push(...oldUrls);
}
}
return merged;
}
// Parse redirect maps from /app/pkg (Docker) or parent directory (local dev)
// In Docker: eleventy-site is at /app/pkg/eleventy-site, maps are at /app/pkg/
// In local dev: maps might be at ../
const pkgRoot = resolve(__dirname, "../..");
// Helper to find first existing file
function findFile(candidates) {
for (const path of candidates) {
if (existsSync(path)) {
console.log(`[urlAliases] Found: ${path}`);
return path;
}
}
console.log(`[urlAliases] No file found in: ${candidates.join(", ")}`);
return null;
}
// Try multiple possible locations for each map type
const microblogMapPath = findFile([
resolve(pkgRoot, "redirects.map"),
resolve(__dirname, "../../redirects.map"),
]);
const knownMapPath = findFile([
resolve(pkgRoot, "old-blog-redirects.map"),
resolve(__dirname, "../../old-blog-redirects.map"),
]);
const microblogAliases = microblogMapPath ? parseRedirectMap(microblogMapPath) : {};
const knownAliases = knownMapPath ? parseRedirectMap(knownMapPath) : {};
const allAliases = mergeAliases(microblogAliases, knownAliases);
// Log summary
const totalMappings = Object.keys(allAliases).length;
const totalOldUrls = Object.values(allAliases).reduce((sum, urls) => sum + urls.length, 0);
console.log(`[urlAliases] Loaded ${totalMappings} URL mappings with ${totalOldUrls} old URLs`);
export default {
// The merged alias map: new URL → [old URLs]
aliases: allAliases,
// Site URL for building absolute URLs
siteUrl,
/**
* Get all URLs (old and new) that should be checked for webmentions
* @param {string} url - Current page URL (relative)
* @returns {string[]} - Array of absolute URLs to check
*/
getAllUrls(url) {
const normalizedUrl = url.replace(/\/$/, "");
const urls = [
`${siteUrl}${url}`,
`${siteUrl}${normalizedUrl}`,
];
// Add old URL variations
const oldUrls = allAliases[normalizedUrl] || [];
for (const oldUrl of oldUrls) {
urls.push(`${siteUrl}${oldUrl}`);
// Also try with trailing slash
urls.push(`${siteUrl}${oldUrl}/`);
}
// Deduplicate
return [...new Set(urls)];
},
/**
* Get just the old URLs for a given new URL
* @param {string} url - Current page URL (relative)
* @returns {string[]} - Array of old relative URLs
*/
getOldUrls(url) {
const normalizedUrl = url.replace(/\/$/, "");
return allAliases[normalizedUrl] || [];
},
};
+206
View File
@@ -0,0 +1,206 @@
/**
* YouTube Channel Data
* Fetches from Indiekit's endpoint-youtube public API
* Supports single or multiple channels
*/
import EleventyFetch from "@11ty/eleventy-fetch";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
/**
* Fetch from Indiekit's public YouTube API endpoint
*/
async function fetchFromIndiekit(endpoint) {
try {
const url = `${INDIEKIT_URL}/youtubeapi/api/${endpoint}`;
console.log(`[youtubeChannel] Fetching from Indiekit: ${url}`);
const data = await EleventyFetch(url, {
duration: "5m",
type: "json",
});
console.log(`[youtubeChannel] Indiekit ${endpoint} success`);
return data;
} catch (error) {
console.log(
`[youtubeChannel] Indiekit API unavailable for ${endpoint}: ${error.message}`
);
return null;
}
}
/**
* Format large numbers with locale separators
*/
function formatNumber(num) {
if (!num) return "0";
return new Intl.NumberFormat().format(num);
}
/**
* Format view count with K/M suffix for compact display
*/
function formatViewCount(num) {
if (!num) return "0";
if (num >= 1000000) {
return (num / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
}
if (num >= 1000) {
return (num / 1000).toFixed(1).replace(/\\.0$/, "") + "K";
}
return num.toString();
}
/**
* Format relative time from ISO date string
*/
function formatRelativeTime(dateString) {
if (!dateString) return "";
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Yesterday";
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
return `${Math.floor(diffDays / 365)} years ago`;
}
/**
* Format channel data with computed fields
*/
function formatChannel(channel) {
if (!channel) return null;
return {
...channel,
subscriberCountFormatted: formatNumber(channel.subscriberCount),
videoCountFormatted: formatNumber(channel.videoCount),
viewCountFormatted: formatNumber(channel.viewCount),
url: `https://www.youtube.com/channel/${channel.id}`,
};
}
/**
* Format video data with computed fields
*/
function formatVideo(video) {
return {
...video,
viewCountFormatted: formatViewCount(video.viewCount),
relativeTime: formatRelativeTime(video.publishedAt),
};
}
export default async function () {
try {
console.log("[youtubeChannel] Fetching YouTube data...");
// Fetch all data from Indiekit API
const [channelData, videosData, liveData] = await Promise.all([
fetchFromIndiekit("channel"),
fetchFromIndiekit("videos"),
fetchFromIndiekit("live"),
]);
// Check if we got data
const hasData =
channelData?.channel ||
channelData?.channels?.length ||
videosData?.videos?.length;
if (!hasData) {
console.log("[youtubeChannel] No data available from Indiekit");
return {
channel: null,
channels: [],
videos: [],
videosByChannel: {},
liveStatus: null,
liveStatuses: [],
isMultiChannel: false,
source: "unavailable",
};
}
console.log("[youtubeChannel] Using Indiekit API data");
// Determine if multi-channel mode
const isMultiChannel = !!(channelData?.channels && channelData.channels.length > 1);
// Format channels
let channels = [];
let channel = null;
if (isMultiChannel) {
channels = (channelData.channels || []).map(formatChannel).filter(Boolean);
channel = channels[0] || null;
} else {
channel = formatChannel(channelData?.channel);
channels = channel ? [channel] : [];
}
// Format videos
const videos = (videosData?.videos || []).map(formatVideo);
// Group videos by channel if multi-channel
let videosByChannel = {};
if (isMultiChannel && videosData?.videosByChannel) {
for (const [channelName, channelVideos] of Object.entries(videosData.videosByChannel)) {
videosByChannel[channelName] = (channelVideos || []).map(formatVideo);
}
} else if (channel) {
videosByChannel[channel.configName || channel.title] = videos;
}
// Format live status
let liveStatus = null;
let liveStatuses = [];
if (liveData) {
if (isMultiChannel && liveData.liveStatuses) {
liveStatuses = liveData.liveStatuses;
// Find first live or upcoming
const live = liveStatuses.find((s) => s.isLive);
const upcoming = liveStatuses.find((s) => s.isUpcoming && !s.isLive);
liveStatus = {
isLive: !!live,
isUpcoming: !live && !!upcoming,
stream: live?.stream || upcoming?.stream || null,
};
} else {
liveStatus = {
isLive: liveData.isLive || false,
isUpcoming: liveData.isUpcoming || false,
stream: liveData.stream || null,
};
liveStatuses = [{ ...liveStatus, channelConfigName: channel?.configName }];
}
}
return {
channel,
channels,
videos,
videosByChannel,
liveStatus,
liveStatuses,
isMultiChannel,
source: "indiekit",
};
} catch (error) {
console.error("[youtubeChannel] Error:", error.message);
return {
channel: null,
channels: [],
videos: [],
videosByChannel: {},
liveStatus: null,
liveStatuses: [],
isMultiChannel: false,
source: "error",
};
}
}
+273
View File
@@ -0,0 +1,273 @@
{# Blog Sidebar - Shown on individual post pages #}
{# Data-driven when homepageConfig.blogPostSidebar is configured, otherwise falls back to default widgets #}
{# Each widget is wrapped in a collapsible container with localStorage persistence #}
{% from "components/icon.njk" import icon %}
{% if homepageConfig and homepageConfig.blogPostSidebar and homepageConfig.blogPostSidebar.length %}
{# === Data-driven mode: render configured widgets === #}
{% for widget in homepageConfig.blogPostSidebar %}
{# Resolve widget title #}
{% if widget.type == "search" %}{% set widgetTitle = "Search" %}
{% elif widget.type == "social-activity" %}{% set widgetTitle = "Social Activity" %}
{% elif widget.type == "github-repos" %}{% set widgetTitle = "GitHub" %}
{% elif widget.type == "funkwhale" %}{% set widgetTitle = "Listening" %}
{% elif widget.type == "recent-posts" %}{% set widgetTitle = "Recent Posts" %}
{% elif widget.type == "blogroll" %}{% set widgetTitle = "Blogroll" %}
{% elif widget.type == "feedland" %}{% set widgetTitle = "FeedLand" %}
{% elif widget.type == "categories" %}{% set widgetTitle = "Categories" %}
{% elif widget.type == "webmentions" %}{% set widgetTitle = "Webmentions" %}
{% elif widget.type == "recent-comments" %}{% set widgetTitle = "Recent Comments" %}
{% elif widget.type == "fediverse-follow" %}{% set widgetTitle = "Fediverse" %}
{% elif widget.type == "author-card" %}{% set widgetTitle = "Author" %}
{% elif widget.type == "author-card-compact" %}{% set widgetTitle = "Author" %}
{% elif widget.type == "subscribe" %}{% set widgetTitle = "Subscribe" %}
{% elif widget.type == "toc" %}{% set widgetTitle = "Table of Contents" %}
{% elif widget.type == "post-categories" %}{% set widgetTitle = "Categories" %}
{% elif widget.type == "share" %}{% set widgetTitle = "Share" %}
{% elif widget.type == "custom-html" %}{% set widgetTitle = (widget.config.title if widget.config and widget.config.title) or "Custom" %}
{% else %}{% set widgetTitle = widget.type %}
{% endif %}
{# Resolve widget icon and accent border #}
{% if widget.type == "social-activity" %}
{% set widgetIcon = "globe" %}{% set widgetIconClass = "w-5 h-5 text-[#0085ff]" %}{% set widgetBorder = "border-l-[3px] border-l-[#0085ff]" %}
{% elif widget.type == "github-repos" %}
{% set widgetIcon = "github" %}{% set widgetIconClass = "w-5 h-5 text-surface-800 dark:text-surface-200" %}{% set widgetBorder = "border-l-[3px] border-l-surface-400 dark:border-l-surface-500" %}
{% elif widget.type == "funkwhale" %}
{% set widgetIcon = "headphones" %}{% set widgetIconClass = "w-5 h-5 text-purple-500" %}{% set widgetBorder = "border-l-[3px] border-l-purple-400 dark:border-l-purple-500" %}
{% elif widget.type == "blogroll" %}
{% set widgetIcon = "book-open" %}{% set widgetIconClass = "w-5 h-5 text-amber-500" %}{% set widgetBorder = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
{% elif widget.type == "feedland" %}
{% set widgetIcon = "rss" %}{% set widgetIconClass = "w-5 h-5 text-amber-500" %}{% set widgetBorder = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
{% elif widget.type == "subscribe" %}
{% set widgetIcon = "rss" %}{% set widgetIconClass = "w-5 h-5 text-orange-500" %}{% set widgetBorder = "border-l-[3px] border-l-orange-400 dark:border-l-orange-500" %}
{% elif widget.type == "fediverse-follow" %}
{% set widgetIcon = "user-plus" %}{% set widgetIconClass = "w-5 h-5 text-[#a730b8]" %}{% set widgetBorder = "border-l-[3px] border-l-[#a730b8]" %}
{% elif widget.type == "author-card" or widget.type == "author-card-compact" %}
{% set widgetIcon = "user" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "recent-posts" %}
{% set widgetIcon = "list" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "categories" or widget.type == "post-categories" %}
{% set widgetIcon = "tag" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "recent-comments" %}
{% set widgetIcon = "chat" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "search" %}
{% set widgetIcon = "search" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "webmentions" %}
{% set widgetIcon = "share" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "toc" %}
{% set widgetIcon = "list" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "share" %}
{% set widgetIcon = "share" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% else %}
{% set widgetIcon = "" %}{% set widgetIconClass = "" %}{% set widgetBorder = "" %}
{% endif %}
{% set widgetKey = "post-widget-" + widget.type + "-" + loop.index0 %}
{% set defaultOpen = "true" if loop.index0 < 3 else "false" %}
{# Collapsible wrapper — Alpine.js handles toggle, localStorage persists state #}
<div
class="widget-collapsible mb-4"
x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : {{ defaultOpen }} }"
>
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden {{ widgetBorder }}">
<button
class="widget-header w-full p-4"
@click="open = !open; localStorage.setItem('{{ widgetKey }}', open)"
:aria-expanded="open ? 'true' : 'false'"
>
<h3 class="widget-title font-bold text-lg flex items-center gap-2">
{% if widgetIcon %}{{ icon(widgetIcon, widgetIconClass) }}{% endif %}
{{ widgetTitle }}
</h3>
<svg
class="widget-chevron"
:class="open && 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div
x-show="open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
>
{# Widget content — inner .widget provides padding, inner title hidden by CSS #}
{% if widget.type == "author-card-compact" %}
{% include "components/widgets/author-card-compact.njk" %}
{% elif widget.type == "author-card" %}
{% include "components/widgets/author-card.njk" %}
{% elif widget.type == "toc" %}
{% include "components/widgets/toc.njk" %}
{% elif widget.type == "post-categories" %}
{% include "components/widgets/post-categories.njk" %}
{% elif widget.type == "recent-posts" %}
{% include "components/widgets/recent-posts-blog.njk" %}
{% elif widget.type == "webmentions" %}
{% include "components/widgets/webmentions.njk" %}
{% elif widget.type == "share" %}
{% include "components/widgets/share.njk" %}
{% elif widget.type == "subscribe" %}
{% include "components/widgets/subscribe.njk" %}
{% elif widget.type == "social-activity" %}
{% include "components/widgets/social-activity.njk" %}
{% elif widget.type == "github-repos" %}
{% include "components/widgets/github-repos.njk" %}
{% elif widget.type == "funkwhale" %}
{% include "components/widgets/funkwhale.njk" %}
{% elif widget.type == "blogroll" %}
{% include "components/widgets/blogroll.njk" %}
{% elif widget.type == "feedland" %}
{% include "components/widgets/feedland.njk" %}
{% elif widget.type == "categories" %}
{% include "components/widgets/categories.njk" %}
{% elif widget.type == "recent-comments" %}
{% include "components/widgets/recent-comments.njk" %}
{% elif widget.type == "search" %}
{% include "components/widgets/search.njk" %}
{% elif widget.type == "fediverse-follow" %}
{% include "components/widgets/fediverse-follow.njk" %}
{% elif widget.type == "custom-html" %}
{% set wConfig = widget.config or {} %}
<is-land on:visible>
<div class="widget">
{% if wConfig.content %}
<div class="prose dark:prose-invert prose-sm max-w-none">
{{ wConfig.content | safe }}
</div>
{% endif %}
</div>
</is-land>
{% else %}
<!-- Unknown widget type: {{ widget.type }} -->
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
{# === Fallback: default blog post sidebar (backward compatibility) === #}
{# Each widget wrapped in collapsible container #}
{# Author Card Compact #}
{% set widgetKey = "post-fb-author-card-compact" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : true }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("user", "w-5 h-5 text-surface-500") }} Author</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/author-card-compact.njk" %}
</div>
</div>
</div>
{# Table of Contents #}
{% set widgetKey = "post-fb-toc" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : true }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("list", "w-5 h-5 text-surface-500") }} Table of Contents</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/toc.njk" %}
</div>
</div>
</div>
{# Post Categories #}
{% set widgetKey = "post-fb-post-categories" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : true }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("tag", "w-5 h-5 text-surface-500") }} Categories</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/post-categories.njk" %}
</div>
</div>
</div>
{# Recent Posts #}
{% set widgetKey = "post-fb-recent-posts" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : false }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("list", "w-5 h-5 text-surface-500") }} Recent Posts</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/recent-posts-blog.njk" %}
</div>
</div>
</div>
{# Webmentions #}
{% set widgetKey = "post-fb-webmentions" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : false }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("share", "w-5 h-5 text-surface-500") }} Webmentions</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/webmentions.njk" %}
</div>
</div>
</div>
{# Share #}
{% set widgetKey = "post-fb-share" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : false }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("share", "w-5 h-5 text-surface-500") }} Share</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/share.njk" %}
</div>
</div>
</div>
{# Subscribe #}
{% set widgetKey = "post-fb-subscribe" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : false }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden border-l-[3px] border-l-orange-400 dark:border-l-orange-500">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("rss", "w-5 h-5 text-orange-500") }} Subscribe</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/subscribe.njk" %}
</div>
</div>
</div>
{# Recent Comments #}
{% set widgetKey = "post-fb-recent-comments" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : false }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("chat", "w-5 h-5 text-surface-500") }} Recent Comments</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/recent-comments.njk" %}
</div>
</div>
</div>
{% endif %}
+109
View File
@@ -0,0 +1,109 @@
{# Comments section — shown on post pages before webmentions #}
{# Collapsed when empty, auto-opens when comments exist #}
{% set absoluteUrl = site.url + page.url %}
<is-land on:visible>
<section class="comments mt-8 pt-8 border-t border-surface-200 dark:border-surface-700" id="comments"
x-data="commentsSection('{{ absoluteUrl }}')"
x-init="init()">
<details class="group" x-bind:open="comments.length > 0 || showForm">
<summary class="flex items-center justify-between cursor-pointer list-none [&::-webkit-details-marker]:hidden" @click="showForm = true">
<h2 class="text-lg font-semibold text-surface-700 dark:text-surface-300">
Comments
<span x-show="comments.length > 0" x-text="'(' + comments.length + ')'" class="text-sm font-normal" x-cloak></span>
</h2>
<svg class="w-4 h-4 text-surface-400 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</summary>
<div class="mt-4">
{# Status messages #}
<div x-show="statusMessage" x-cloak
x-bind:class="statusType === 'error' ? 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400' :
statusType === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' :
'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400'"
class="p-3 rounded-lg mb-4 text-sm">
<span x-text="statusMessage"></span>
</div>
{# Sign-in form (shown when not authenticated) #}
<div x-show="!user" x-cloak>
<p class="text-sm text-surface-600 dark:text-surface-400 mb-3">Sign in with your website to comment:</p>
<form x-on:submit.prevent="startAuth()" class="flex gap-2 items-end flex-wrap">
<div class="flex-1 min-w-[200px]">
<label for="comment-me" class="block text-sm font-medium mb-1">Your website</label>
<input id="comment-me" type="url" x-model="meUrl"
placeholder="https://yourdomain.com" required
class="w-full px-3 py-2 border rounded-lg dark:bg-surface-800 dark:border-surface-600">
</div>
<button type="submit" class="button" x-bind:disabled="authLoading">
<span x-show="!authLoading">Sign In</span>
<span x-show="authLoading" x-cloak>Signing in...</span>
</button>
</form>
</div>
{# Comment form (shown when authenticated) #}
<div x-show="user" x-cloak>
<div class="flex items-center gap-2 mb-3 text-sm text-surface-600 dark:text-surface-400">
<span>Signed in as</span>
<a x-bind:href="user?.url" class="font-medium hover:underline" x-text="user?.name || user?.url" target="_blank" rel="noopener"></a>
<button x-on:click="signOut()" class="text-xs underline hover:no-underline">Sign out</button>
</div>
<form x-on:submit.prevent="submitComment()">
<textarea x-model="commentText" rows="4" required
placeholder="Share your thoughts... (supports **bold**, *italic*, and [links](url))"
class="w-full px-3 py-2 border rounded-lg mb-2 dark:bg-surface-800 dark:border-surface-600"
x-bind:maxlength="maxLength"></textarea>
<div class="flex items-center justify-between">
<span class="text-xs text-surface-500" x-text="commentText.length + '/' + maxLength"></span>
<button type="submit" class="button" x-bind:disabled="submitting">
<span x-show="!submitting">Post Comment</span>
<span x-show="submitting" x-cloak>Posting...</span>
</button>
</div>
</form>
</div>
{# Comment list #}
<div class="mt-6 space-y-4">
<template x-if="loading">
<p class="text-sm text-surface-500">Loading comments...</p>
</template>
<template x-for="comment in comments" x-bind:key="comment.published">
<div class="p-4 bg-surface-100 dark:bg-surface-800 rounded-lg">
<div class="flex items-start gap-3">
<template x-if="comment.author?.photo">
<img x-bind:src="comment.author.photo" x-bind:alt="comment.author.name"
class="w-8 h-8 rounded-full flex-shrink-0" loading="lazy">
</template>
<template x-if="!comment.author?.photo">
<div class="w-8 h-8 rounded-full bg-surface-200 dark:bg-surface-700 flex-shrink-0 flex items-center justify-center text-xs font-bold"
x-text="(comment.author?.name || '?')[0].toUpperCase()">
</div>
</template>
<div class="flex-1">
<div class="flex items-center gap-2">
<a x-bind:href="comment.author?.url" class="font-medium text-sm hover:underline" target="_blank" rel="noopener"
x-text="comment.author?.name || comment.author?.url"></a>
<time class="text-xs text-surface-500" x-bind:datetime="comment.published"
x-text="new Date(comment.published).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })"></time>
</div>
<div class="mt-1 text-sm prose dark:prose-invert" x-html="comment.content?.html || comment.content?.text"></div>
</div>
</div>
</div>
</template>
<template x-if="!loading && comments.length === 0">
<p class="text-sm text-surface-500">No comments yet. Be the first to share your thoughts!</p>
</template>
</div>
</div>
</details>
</section>
</is-land>
+169
View File
@@ -0,0 +1,169 @@
{#
CV Page Builder - renders configured layout, sections, and sidebar
from cvPageConfig (written by indiekit-endpoint-cv plugin)
#}
{% set layout = cvPageConfig.layout or "single-column" %}
{% set hasSidebar = cvPageConfig.sidebar and cvPageConfig.sidebar.length %}
{# CV identity — check cvPageConfig.identity first, fall back to site.author #}
{% set cvId = cvPageConfig.identity if (cvPageConfig and cvPageConfig.identity) else {} %}
{% set authorName = cvId.name or site.author.name %}
{% set authorAvatar = cvId.avatar or site.author.avatar %}
{% set authorTitle = cvId.title or site.author.title %}
{% set authorBio = cvId.bio or site.author.bio %}
{% set authorDescription = cvId.description or '' %}
{% set socialLinks = cvId.social if (cvId.social and cvId.social.length) else site.social %}
{% set cvLocality = cvId.locality or site.author.locality %}
{% set cvCountry = cvId.country or site.author.country %}
{% set cvOrg = cvId.org or site.author.org %}
{% set cvUrl = cvId.url or '' %}
{% set cvEmail = cvId.email or site.author.email %}
{% set cvKeyUrl = cvId.keyUrl or site.author.keyUrl %}
{# Hero — rendered at top when enabled (default: true) #}
{% if cvPageConfig.hero.enabled != false %}
<section class="mb-8 sm:mb-12">
<div class="flex flex-col sm:flex-row gap-6 sm:gap-8 items-start">
<img
src="{{ authorAvatar }}"
alt="{{ authorName }}"
class="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover shadow-lg flex-shrink-0"
loading="eager"
eleventy:ignore
>
<div class="flex-1 min-w-0">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">
{{ authorName }}
</h1>
{% if authorTitle %}
<p class="text-lg sm:text-xl text-accent-600 dark:text-accent-400 mb-3 sm:mb-4">
{{ authorTitle }}
</p>
{% endif %}
{% if authorBio %}
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mb-4">
{{ authorBio }}
</p>
{% endif %}
{% if authorDescription %}
<details class="mb-4 sm:mb-6">
<summary class="text-sm font-medium text-accent-600 dark:text-accent-400 cursor-pointer hover:underline list-none">
More about me &darr;
</summary>
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mt-3">
{{ authorDescription }}
</p>
</details>
{% endif %}
{% from "components/social-icon.njk" import socialIcon, socialIconColorClass %}
{% if cvPageConfig.hero.showSocial != false and socialLinks %}
<div class="flex flex-wrap gap-3">
{% for link in socialLinks %}
<a
href="{{ link.url }}"
rel="{{ link.rel }} noopener"
class="inline-flex items-center gap-2 px-3 py-2 text-sm text-surface-700 dark:text-surface-300 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
target="_blank"
>
<span class="{{ socialIconColorClass(link.icon) }}">{{ socialIcon(link.icon, "w-5 h-5") }}</span>
<span class="text-sm font-medium">{{ link.name }}</span>
</a>
{% endfor %}
</div>
{% endif %}
{# Contact details — location, organization, website, email, PGP #}
{% if cvLocality or cvCountry or cvOrg or cvUrl or cvEmail or cvKeyUrl %}
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-4 text-sm text-surface-500 dark:text-surface-400">
{% if cvLocality or cvCountry %}
<span>{% if cvLocality %}{{ cvLocality }}{% endif %}{% if cvLocality and cvCountry %}, {% endif %}{% if cvCountry %}{{ cvCountry }}{% endif %}</span>
{% endif %}
{% if cvOrg %}
<span>{{ cvOrg }}</span>
{% endif %}
{% if cvUrl %}
<span><a href="{{ cvUrl }}" class="text-accent-600 dark:text-accent-400 hover:underline" target="_blank" rel="noopener">{{ cvUrl | replace("https://", "") | replace("http://", "") }}</a></span>
{% endif %}
{% if cvEmail %}
<span><a href="mailto:{{ cvEmail }}" class="text-accent-600 dark:text-accent-400 hover:underline">{{ cvEmail }}</a></span>
{% endif %}
{% if cvKeyUrl %}
<span><a href="{{ cvKeyUrl }}" class="text-accent-600 dark:text-accent-400 hover:underline" target="_blank" rel="noopener">PGP Key</a></span>
{% endif %}
</div>
{% endif %}
</div>
</div>
</section>
{% endif %}
{# Layout wrapper #}
{% if layout == "single-column" %}
{# Single column — no sidebar, full width sections #}
<div class="cv-sections">
{% for section in cvPageConfig.sections %}
{% include "components/homepage-section.njk" %}
{% endfor %}
</div>
{% elif layout == "two-column" and hasSidebar %}
{# Two column — sections + sidebar #}
<div class="layout-with-sidebar">
<div class="main-content">
<div class="cv-sections">
{% for section in cvPageConfig.sections %}
{% include "components/homepage-section.njk" %}
{% endfor %}
</div>
</div>
<aside class="sidebar" data-pagefind-ignore>
{% include "components/cv-sidebar.njk" %}
</aside>
</div>
{% elif layout == "full-width-hero" %}
{# Full width hero (already rendered above), then two-column below #}
{% if hasSidebar %}
<div class="layout-with-sidebar">
<div class="main-content">
<div class="cv-sections">
{% for section in cvPageConfig.sections %}
{% include "components/homepage-section.njk" %}
{% endfor %}
</div>
</div>
<aside class="sidebar" data-pagefind-ignore>
{% include "components/cv-sidebar.njk" %}
</aside>
</div>
{% else %}
<div class="cv-sections">
{% for section in cvPageConfig.sections %}
{% include "components/homepage-section.njk" %}
{% endfor %}
</div>
{% endif %}
{% else %}
{# Fallback — two-column without sidebar, or unknown layout #}
<div class="cv-sections">
{% for section in cvPageConfig.sections %}
{% include "components/homepage-section.njk" %}
{% endfor %}
</div>
{% endif %}
{# Last Updated #}
{% if cv.lastUpdated %}
<p class="text-sm text-surface-500 text-center mt-8">
Last updated: <time datetime="{{ cv.lastUpdated }}">{{ cv.lastUpdated | date("PPP") }}</time>
</p>
{% endif %}
{# Footer — rendered after the main layout, full width #}
{% include "components/cv-footer.njk" %}
+26
View File
@@ -0,0 +1,26 @@
{# CV Page Builder Footer — renders footer items in a responsive 3-column grid #}
{% if cvPageConfig.footer and cvPageConfig.footer.length %}
<footer class="cv-footer mt-8 sm:mt-12 pt-6 sm:pt-8 border-t border-surface-200 dark:border-surface-700">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{% for section in cvPageConfig.footer %}
{% if section.type == "custom-html" %}
{% set sectionConfig = section.config or {} %}
<div>
{% if sectionConfig.title %}
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-3">{{ sectionConfig.title }}</h3>
{% endif %}
{% if sectionConfig.content %}
<div class="prose dark:prose-invert prose-sm max-w-none">
{{ sectionConfig.content | safe }}
</div>
{% endif %}
</div>
{% else %}
<div>
{% include "components/homepage-section.njk" %}
</div>
{% endif %}
{% endfor %}
</div>
</footer>
{% endif %}
+43
View File
@@ -0,0 +1,43 @@
{# CV Page Builder Sidebar — renders widgets from cvPageConfig.sidebar #}
{% if cvPageConfig.sidebar and cvPageConfig.sidebar.length %}
{% for widget in cvPageConfig.sidebar %}
{% if widget.type == "author-card" %}
{% include "components/widgets/author-card.njk" %}
{% elif widget.type == "social-activity" %}
{% include "components/widgets/social-activity.njk" %}
{% elif widget.type == "github-repos" %}
{% include "components/widgets/github-repos.njk" %}
{% elif widget.type == "funkwhale" %}
{% include "components/widgets/funkwhale.njk" %}
{% elif widget.type == "recent-posts" %}
{% include "components/widgets/recent-posts.njk" %}
{% elif widget.type == "blogroll" %}
{% include "components/widgets/blogroll.njk" %}
{% elif widget.type == "feedland" %}
{% include "components/widgets/feedland.njk" %}
{% elif widget.type == "categories" %}
{% include "components/widgets/categories.njk" %}
{% elif widget.type == "search" %}
{% include "components/widgets/search.njk" %}
{% elif widget.type == "webmentions" %}
{% include "components/widgets/webmentions.njk" %}
{% elif widget.type == "custom-html" %}
{# Custom content widget #}
{% set wConfig = widget.config or {} %}
<is-land on:visible>
<div class="widget">
{% if wConfig.title %}
<h3 class="widget-title">{{ wConfig.title }}</h3>
{% endif %}
{% if wConfig.content %}
<div class="prose dark:prose-invert prose-sm max-w-none">
{{ wConfig.content | safe }}
</div>
{% endif %}
</div>
</is-land>
{% else %}
<!-- Unknown widget type: {{ widget.type }} -->
{% endif %}
{% endfor %}
{% endif %}
@@ -0,0 +1,27 @@
{# Empty collection placeholder — encourages creating content #}
{# Usage: {% include "components/empty-collection.njk" %} with postType set before include #}
{% set typeInfo = null %}
{% for pt in enabledPostTypes %}
{% if pt.type == postType %}{% set typeInfo = pt %}{% endif %}
{% endfor %}
<div class="text-center py-12 px-4">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-surface-100 dark:bg-surface-800 mb-4">
<svg class="w-8 h-8 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-surface-700 dark:text-surface-300 mb-2">No {{ title | lower }} yet</h2>
<p class="text-surface-500 dark:text-surface-400 mb-6 max-w-md mx-auto">
This is where your {{ title | lower }} will appear once you start creating content.
</p>
{% if typeInfo %}
<a href="{{ typeInfo.createUrl }}"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-accent-600 text-white hover:bg-accent-700 transition-colors text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create your first {{ postType }}
</a>
{% endif %}
</div>
@@ -0,0 +1,81 @@
{# Shared fediverse instance picker modal #}
{# Used by post.njk (interact), fediverse-follow.njk (follow), share.njk (share) #}
{# Requires: modalTitle, modalDescription variables set before include #}
<template x-if="showModal">
<div class="fixed inset-0 z-50 flex items-center justify-center p-4" @keydown.escape.window="showModal = false">
{# Backdrop #}
<div class="fixed inset-0 bg-black/40"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showModal = false"></div>
{# Panel #}
<div class="relative bg-surface-50 dark:bg-surface-800 rounded-xl shadow-xl w-full max-w-sm p-6"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.stop>
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-1">{{ modalTitle }}</h3>
<p class="text-sm text-surface-500 dark:text-surface-400 mb-4">{{ modalDescription }}</p>
{# Saved domains list #}
<template x-if="savedDomains.length > 0 && !showInput">
<div>
<div class="flex flex-col gap-2 mb-3">
<template x-for="item in savedDomains" :key="item.domain">
<div class="flex items-center gap-2 rounded-lg bg-surface-50 dark:bg-surface-700 hover:bg-surface-100 dark:hover:bg-surface-600 transition-colors">
<button class="flex-1 px-3 py-2.5 text-left text-sm font-medium text-surface-900 dark:text-surface-100 cursor-pointer"
@click="useSaved(item.domain)"
x-text="item.domain"></button>
<button class="px-2 py-2.5 text-surface-400 hover:text-red-500 transition-colors cursor-pointer"
@click="deleteSaved(item.domain)"
title="Remove">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</template>
</div>
<button class="w-full text-sm text-[#a730b8] hover:text-[#a730b8]/80 cursor-pointer font-medium"
@click="showAddNew()">Use a different instance</button>
<div class="flex mt-3">
<button @click="showModal = false"
class="w-full px-4 py-2 text-sm font-medium text-surface-600 dark:text-surface-300 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
</template>
{# New domain input #}
<template x-if="savedDomains.length === 0 || showInput">
<div>
<input x-ref="instanceInput"
x-model="instance"
@keydown.enter.prevent="confirm()"
type="text"
placeholder="mastodon.social"
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-surface-50 dark:bg-surface-700 text-surface-900 dark:text-surface-100 placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-[#a730b8] focus:border-transparent text-sm">
<template x-if="error">
<p class="text-xs text-red-500 mt-1" x-text="error"></p>
</template>
<div class="flex gap-3 mt-4">
<button @click="showInput ? (showInput = false) : (showModal = false)"
class="flex-1 px-4 py-2 text-sm font-medium text-surface-600 dark:text-surface-300 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 rounded-lg transition-colors"
x-text="showInput && savedDomains.length > 0 ? 'Back' : 'Cancel'">
</button>
<button @click="confirm()"
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-[#a730b8] hover:bg-[#a730b8]/80 rounded-lg transition-colors">
Go
</button>
</div>
</div>
</template>
</div>
</div>
</template>
@@ -0,0 +1,66 @@
{# Stats Summary Cards #}
{% if summary %}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-6 sm:mb-8">
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 text-center">
<span class="text-2xl font-bold text-purple-600 dark:text-purple-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-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 text-center">
<span class="text-2xl font-bold text-purple-600 dark:text-purple-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-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 text-center">
<span class="text-2xl font-bold text-purple-600 dark:text-purple-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-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 text-center">
<span class="text-2xl font-bold text-purple-600 dark:text-purple-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-surface-50 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-3 sm: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" eleventy:ignore>
{% 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 %}
+111
View File
@@ -0,0 +1,111 @@
{# h-card - IndieWeb identity microformat #}
{# See: https://microformats.org/wiki/h-card #}
{#
This is the canonical h-card component for the site.
Include in sidebar widgets, author cards, etc.
#}
{% set id = homepageConfig.identity if (homepageConfig and homepageConfig.identity) else {} %}
{% set authorName = id.name or site.author.name %}
{% set authorAvatar = id.avatar or site.author.avatar %}
{% set authorTitle = id.title or site.author.title %}
{% set authorBio = id.bio or site.author.bio %}
{% set authorUrl = id.url or site.author.url %}
{% set authorPronoun = id.pronoun or site.author.pronoun %}
{% set authorLocality = id.locality or site.author.locality %}
{% set authorCountry = id.country or site.author.country %}
{% set authorLocation = site.author.location %}
{% set authorOrg = id.org or site.author.org %}
{% set authorEmail = id.email or site.author.email %}
{% set authorKeyUrl = id.keyUrl or site.author.keyUrl %}
{% set authorCategories = id.categories if (id.categories and id.categories.length) else site.author.categories %}
{% set socialLinks = id.social if (id.social and id.social.length) else site.social %}
<div class="h-card p-author" itemscope itemtype="http://schema.org/Person">
{# Hidden u-photo for reliable microformat parsing (some parsers struggle with img inside links) #}
<data class="u-photo hidden" value="{{ authorAvatar }}"></data>
<div class="flex items-center gap-4">
<a href="{{ authorUrl }}" class="u-url u-uid" rel="me" itemprop="url">
<img
src="{{ authorAvatar }}"
alt="{{ authorName }}"
class="w-16 h-16 rounded-full object-cover"
loading="lazy"
itemprop="image"
>
</a>
<div>
<a href="{{ authorUrl }}" class="u-url p-name font-bold text-lg block hover:text-accent-600 dark:hover:text-accent-400" itemprop="name">
{{ authorName }}
</a>
{% if authorPronoun %}
<span class="p-pronoun text-xs text-surface-500">({{ authorPronoun }})</span>
{% endif %}
<p class="p-job-title text-sm text-surface-600 dark:text-surface-400" itemprop="jobTitle">{{ authorTitle }}</p>
{# Structured address #}
<p class="p-adr h-adr text-sm text-surface-500 dark:text-surface-500" itemprop="address" itemscope itemtype="http://schema.org/PostalAddress">
{% if authorLocality %}
<span class="p-locality" itemprop="addressLocality">{{ authorLocality }}</span>{% if authorCountry %}, {% endif %}
{% endif %}
{% if authorCountry %}
<span class="p-country-name" itemprop="addressCountry">{{ authorCountry }}</span>
{% endif %}
{# Fallback to legacy location field #}
{% if not authorLocality and authorLocation %}
<span class="p-locality">{{ authorLocation }}</span>
{% endif %}
</p>
</div>
</div>
{# Bio #}
<p class="p-note mt-3 text-sm text-surface-700 dark:text-surface-300" itemprop="description">{{ authorBio }}</p>
{# Organization #}
{% if authorOrg %}
<p class="mt-2 text-sm text-surface-600 dark:text-surface-400">
<span class="p-org" itemprop="worksFor">{{ authorOrg }}</span>
</p>
{% endif %}
{# Email and PGP Key #}
<div class="mt-2 flex flex-wrap gap-3 text-sm">
{% if authorEmail %}
{# Display text obfuscated to deter spam harvesters; href kept plain for browser compatibility #}
<a href="mailto:{{ authorEmail }}" class="u-email text-accent-600 dark:text-accent-400 hover:underline" itemprop="email">
✉️ {{ authorEmail | obfuscateEmail | safe }}
</a>
{% endif %}
{% if authorKeyUrl %}
<a href="{{ authorKeyUrl }}" class="u-key text-surface-500 dark:text-surface-400 hover:underline" rel="pgpkey">
🔐 PGP Key
</a>
{% endif %}
</div>
{# Categories / Skills #}
{% if authorCategories and authorCategories.length %}
<div class="mt-3 flex flex-wrap gap-1">
{% for category in authorCategories %}
<span class="p-category text-xs px-2 py-0.5 bg-surface-100 dark:bg-surface-800 rounded">{{ category }}</span>
{% endfor %}
</div>
{% endif %}
{# Social links with rel="me" - critical for IndieWeb identity verification #}
{% from "components/social-icon.njk" import socialIcon %}
{% if socialLinks and socialLinks.length %}
<nav class="flex flex-wrap gap-3 mt-3" aria-label="Social links">
{% for link in socialLinks %}
<a
href="{{ link.url }}"
rel="{{ link.rel }} noopener"
class="u-url text-surface-500 hover:text-accent-600 dark:hover:text-accent-400 transition-colors"
aria-label="{{ link.name }}"
target="_blank">
{{ socialIcon(link.icon, "w-5 h-5") }}
</a>
{% endfor %}
</nav>
{% endif %}
</div>
@@ -0,0 +1,86 @@
{#
Homepage Builder - renders configured layout, sections, and sidebar
from homepageConfig (written by indiekit-endpoint-homepage plugin)
#}
{% set layout = homepageConfig.layout or "two-column" %}
{% set hasSidebar = homepageConfig.sidebar and homepageConfig.sidebar.length %}
{# Hero — rendered before layout wrapper when enabled #}
{% if homepageConfig.hero and homepageConfig.hero.enabled %}
{% include "components/sections/hero.njk" %}
{% endif %}
{# Layout wrapper #}
{% if layout == "single-column" %}
{# Single column — no sidebar, full width sections #}
<div class="homepage-sections">
{% for section in homepageConfig.sections %}
{% if section.type != "hero" %}
{% include "components/homepage-section.njk" %}
{% endif %}
{% endfor %}
</div>
{% elif layout == "two-column" and hasSidebar %}
{# Two column — sections + sidebar #}
<div class="layout-with-sidebar">
<div class="main-content">
<div class="homepage-sections">
{% for section in homepageConfig.sections %}
{% if section.type != "hero" %}
{% include "components/homepage-section.njk" %}
{% endif %}
{% endfor %}
</div>
</div>
<aside class="sidebar" data-pagefind-ignore>
{% include "components/homepage-sidebar.njk" %}
</aside>
</div>
{% elif layout == "full-width-hero" %}
{# Full width hero (already rendered above), then two-column below #}
{% if hasSidebar %}
<div class="layout-with-sidebar">
<div class="main-content">
<div class="homepage-sections">
{% for section in homepageConfig.sections %}
{% if section.type != "hero" %}
{% include "components/homepage-section.njk" %}
{% endif %}
{% endfor %}
</div>
</div>
<aside class="sidebar" data-pagefind-ignore>
{% include "components/homepage-sidebar.njk" %}
</aside>
</div>
{% else %}
<div class="homepage-sections">
{% for section in homepageConfig.sections %}
{% if section.type != "hero" %}
{% include "components/homepage-section.njk" %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% else %}
{# Fallback — two-column without sidebar, or unknown layout #}
<div class="homepage-sections">
{% for section in homepageConfig.sections %}
{% if section.type != "hero" %}
{% include "components/homepage-section.njk" %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{# Footer — rendered after the main layout, full width #}
{% include "components/homepage-footer.njk" %}
@@ -0,0 +1,26 @@
{# Homepage Builder Footer — renders footer items in a responsive 3-column grid #}
{% if homepageConfig.footer and homepageConfig.footer.length %}
<footer class="homepage-footer mt-8 sm:mt-12 pt-6 sm:pt-8 border-t border-surface-200 dark:border-surface-700">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{% for section in homepageConfig.footer %}
{% if section.type == "custom-html" %}
{% set sectionConfig = section.config or {} %}
<div>
{% if sectionConfig.title %}
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-3">{{ sectionConfig.title }}</h3>
{% endif %}
{% if sectionConfig.content %}
<div class="prose dark:prose-invert prose-sm max-w-none">
{{ sectionConfig.content | safe }}
</div>
{% endif %}
</div>
{% else %}
<div>
{% include "components/homepage-section.njk" %}
</div>
{% endif %}
{% endfor %}
</div>
</footer>
{% endif %}
@@ -0,0 +1,56 @@
{# Homepage Section Dispatcher — maps section.type to the right partial #}
{% if section.type == "featured-posts" %}
{% include "components/sections/featured-posts.njk" %}
{% elif section.type == "recent-posts" %}
{% include "components/sections/recent-posts.njk" %}
{% elif section.type == "custom-html" %}
{% include "components/sections/custom-html.njk" %}
{% elif section.type == "cv-experience" %}
{% include "components/sections/cv-experience.njk" ignore missing %}
{% elif section.type == "cv-projects" %}
{% include "components/sections/cv-projects.njk" ignore missing %}
{% elif section.type == "cv-projects-personal" %}
{% include "components/sections/cv-projects-personal.njk" ignore missing %}
{% elif section.type == "cv-projects-work" %}
{% include "components/sections/cv-projects-work.njk" ignore missing %}
{% elif section.type == "cv-skills" %}
{% include "components/sections/cv-skills.njk" ignore missing %}
{% elif section.type == "cv-education" %}
{% include "components/sections/cv-education.njk" ignore missing %}
{% elif section.type == "cv-interests" %}
{% include "components/sections/cv-interests.njk" ignore missing %}
{% elif section.type == "cv-experience-personal" %}
{% include "components/sections/cv-experience-personal.njk" ignore missing %}
{% elif section.type == "cv-experience-work" %}
{% include "components/sections/cv-experience-work.njk" ignore missing %}
{% elif section.type == "cv-education-personal" %}
{% include "components/sections/cv-education-personal.njk" ignore missing %}
{% elif section.type == "cv-education-work" %}
{% include "components/sections/cv-education-work.njk" ignore missing %}
{% elif section.type == "cv-skills-personal" %}
{% include "components/sections/cv-skills-personal.njk" ignore missing %}
{% elif section.type == "cv-skills-work" %}
{% include "components/sections/cv-skills-work.njk" ignore missing %}
{% elif section.type == "cv-interests-personal" %}
{% include "components/sections/cv-interests-personal.njk" ignore missing %}
{% elif section.type == "cv-interests-work" %}
{% include "components/sections/cv-interests-work.njk" ignore missing %}
{% elif section.type == "cv-languages" %}
{% include "components/sections/cv-languages.njk" ignore missing %}
{% elif section.type == "blogroll" %}
{% include "components/sections/blogroll.njk" ignore missing %}
{% elif section.type == "podroll" %}
{% include "components/sections/podroll.njk" ignore missing %}
{% elif section.type == "github-activity" %}
{% include "components/sections/github-activity.njk" ignore missing %}
{% elif section.type == "youtube" %}
{% include "components/sections/youtube.njk" ignore missing %}
{% elif section.type == "funkwhale" %}
{% include "components/sections/funkwhale.njk" ignore missing %}
{% elif section.type == "lastfm" %}
{% include "components/sections/lastfm.njk" ignore missing %}
{% elif section.type == "posting-activity" %}
{% include "components/sections/posting-activity.njk" ignore missing %}
{% else %}
<!-- Unknown section type: {{ section.type }} -->
{% endif %}
@@ -0,0 +1,137 @@
{# Homepage Builder Sidebar — renders widgets from homepageConfig.sidebar #}
{# Each widget is wrapped in a collapsible container with localStorage persistence #}
{% from "components/icon.njk" import icon %}
{% if homepageConfig.sidebar and homepageConfig.sidebar.length %}
{% for widget in homepageConfig.sidebar %}
{# Resolve widget title #}
{% if widget.type == "search" %}{% set widgetTitle = "Search" %}
{% elif widget.type == "social-activity" %}{% set widgetTitle = "Social Activity" %}
{% elif widget.type == "github-repos" %}{% set widgetTitle = "GitHub" %}
{% elif widget.type == "funkwhale" %}{% set widgetTitle = "Listening" %}
{% elif widget.type == "recent-posts" %}{% set widgetTitle = "Recent Posts" %}
{% elif widget.type == "blogroll" %}{% set widgetTitle = "Blogroll" %}
{% elif widget.type == "feedland" %}{% set widgetTitle = "FeedLand" %}
{% elif widget.type == "categories" %}{% set widgetTitle = "Categories" %}
{% elif widget.type == "webmentions" %}{% set widgetTitle = "Webmentions" %}
{% elif widget.type == "recent-comments" %}{% set widgetTitle = "Recent Comments" %}
{% elif widget.type == "fediverse-follow" %}{% set widgetTitle = "Fediverse" %}
{% elif widget.type == "author-card" %}{% set widgetTitle = "Author" %}
{% elif widget.type == "custom-html" %}{% set widgetTitle = (widget.config.title if widget.config and widget.config.title) or "Custom" %}
{% else %}{% set widgetTitle = widget.type %}
{% endif %}
{# Resolve widget icon and accent border #}
{% if widget.type == "social-activity" %}
{% set widgetIcon = "globe" %}{% set widgetIconClass = "w-5 h-5 text-[#0085ff]" %}{% set widgetBorder = "border-l-[3px] border-l-[#0085ff]" %}
{% elif widget.type == "github-repos" %}
{% set widgetIcon = "github" %}{% set widgetIconClass = "w-5 h-5 text-surface-800 dark:text-surface-200" %}{% set widgetBorder = "border-l-[3px] border-l-surface-400 dark:border-l-surface-500" %}
{% elif widget.type == "funkwhale" %}
{% set widgetIcon = "headphones" %}{% set widgetIconClass = "w-5 h-5 text-purple-500" %}{% set widgetBorder = "border-l-[3px] border-l-purple-400 dark:border-l-purple-500" %}
{% elif widget.type == "blogroll" %}
{% set widgetIcon = "book-open" %}{% set widgetIconClass = "w-5 h-5 text-amber-500" %}{% set widgetBorder = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
{% elif widget.type == "feedland" %}
{% set widgetIcon = "rss" %}{% set widgetIconClass = "w-5 h-5 text-amber-500" %}{% set widgetBorder = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
{% elif widget.type == "subscribe" %}
{% set widgetIcon = "rss" %}{% set widgetIconClass = "w-5 h-5 text-orange-500" %}{% set widgetBorder = "border-l-[3px] border-l-orange-400 dark:border-l-orange-500" %}
{% elif widget.type == "fediverse-follow" %}
{% set widgetIcon = "user-plus" %}{% set widgetIconClass = "w-5 h-5 text-[#a730b8]" %}{% set widgetBorder = "border-l-[3px] border-l-[#a730b8]" %}
{% elif widget.type == "author-card" %}
{% set widgetIcon = "user" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "recent-posts" %}
{% set widgetIcon = "list" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "categories" %}
{% set widgetIcon = "tag" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "recent-comments" %}
{% set widgetIcon = "chat" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "search" %}
{% set widgetIcon = "search" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "webmentions" %}
{% set widgetIcon = "share" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% else %}
{% set widgetIcon = "" %}{% set widgetIconClass = "" %}{% set widgetBorder = "" %}
{% endif %}
{% set widgetKey = "widget-" + widget.type + "-" + loop.index0 %}
{% set defaultOpen = "true" if loop.index0 < 3 else "false" %}
{# Collapsible wrapper — Alpine.js handles toggle, localStorage persists state #}
<div
class="widget-collapsible mb-4"
x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : {{ defaultOpen }} }"
>
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden {{ widgetBorder }}">
<button
class="widget-header w-full p-4"
@click="open = !open; localStorage.setItem('{{ widgetKey }}', open)"
:aria-expanded="open ? 'true' : 'false'"
>
<h3 class="widget-title font-bold text-lg flex items-center gap-2">
{% if widgetIcon %}{{ icon(widgetIcon, widgetIconClass) }}{% endif %}
{{ widgetTitle }}
</h3>
<svg
class="widget-chevron"
:class="open && 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div
x-show="open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
>
{# Widget content — inner .widget provides padding, inner title hidden by CSS #}
{% if widget.type == "author-card" %}
{% include "components/widgets/author-card.njk" %}
{% elif widget.type == "social-activity" %}
{% include "components/widgets/social-activity.njk" %}
{% elif widget.type == "github-repos" %}
{% include "components/widgets/github-repos.njk" %}
{% elif widget.type == "funkwhale" %}
{% include "components/widgets/funkwhale.njk" %}
{% elif widget.type == "recent-posts" %}
{% include "components/widgets/recent-posts.njk" %}
{% elif widget.type == "blogroll" %}
{% include "components/widgets/blogroll.njk" %}
{% elif widget.type == "feedland" %}
{% include "components/widgets/feedland.njk" %}
{% elif widget.type == "categories" %}
{% include "components/widgets/categories.njk" %}
{% elif widget.type == "search" %}
{% include "components/widgets/search.njk" %}
{% elif widget.type == "webmentions" %}
{% include "components/widgets/webmentions.njk" %}
{% elif widget.type == "recent-comments" %}
{% include "components/widgets/recent-comments.njk" %}
{% elif widget.type == "fediverse-follow" %}
{% include "components/widgets/fediverse-follow.njk" %}
{% elif widget.type == "custom-html" %}
{% set wConfig = widget.config or {} %}
<is-land on:visible>
<div class="widget">
{% if wConfig.content %}
<div class="prose dark:prose-invert prose-sm max-w-none">
{{ wConfig.content | safe }}
</div>
{% endif %}
</div>
</is-land>
{% else %}
<!-- Unknown widget type: {{ widget.type }} -->
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% endif %}
+67
View File
@@ -0,0 +1,67 @@
{#
Centralized UI icon macro
Usage: {% from "components/icon.njk" import icon %}
{{ icon("heart", "w-5 h-5 text-red-500") }}
All icons use stroke-width="2" unless they are filled icons.
Default size: w-5 h-5 (override via cssClass parameter)
#}
{% macro icon(name, cssClass) %}
{% set cls = cssClass or "w-5 h-5" %}
{%- if name == "heart" -%}
<svg class="{{ cls }}" 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>
{%- elif name == "bookmark" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
{%- elif name == "repost" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 014-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 01-4 4H3"/></svg>
{%- elif name == "reply" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
{%- elif name == "camera" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>
{%- elif name == "article" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
{%- elif name == "note" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
{%- elif name == "music" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
{%- elif name == "tag" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
{%- elif name == "rss" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 11a9 9 0 019 9"/><path d="M4 4a16 16 0 0116 16"/><circle cx="5" cy="19" r="1"/></svg>
{%- elif name == "chat" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
{%- elif name == "user" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{%- elif name == "search" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
{%- elif name == "star" -%}
<svg class="{{ cls }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{%- elif name == "external-link" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
{%- elif name == "chevron-down" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 9l-7 7-7-7"/></svg>
{%- elif name == "chevron-right" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
{%- elif name == "globe" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
{%- elif name == "github" -%}
<svg class="{{ cls }}" 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"/></svg>
{%- elif name == "list" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
{%- elif name == "share" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
{%- elif name == "book-open" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>
{%- elif name == "headphones" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 18v-6a9 9 0 0118 0v6"/><path d="M21 19a2 2 0 01-2 2h-1a2 2 0 01-2-2v-3a2 2 0 012-2h3zM3 19a2 2 0 002 2h1a2 2 0 002-2v-3a2 2 0 00-2-2H3z"/></svg>
{%- elif name == "mail" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
{%- elif name == "podcast" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17.657 18.657A8 8 0 016.343 7.343"/><path d="M9.879 16.121A3 3 0 1012.015 11L11 17H9c-2 0-3-2-3-3l.879-.879z"/></svg>
{%- elif name == "user-plus" -%}
<svg class="{{ cls }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
{%- else -%}
<!-- Unknown icon: {{ name }} -->
{%- endif -%}
{% endmacro %}
@@ -0,0 +1,103 @@
{# Post Navigation - Previous/Next (image-first, inspired by zachleat.com) #}
{% set _prevPost = collections.posts | previousInCollection(page) %}
{% set _nextPost = collections.posts | nextInCollection(page) %}
{% if _prevPost or _nextPost %}
<nav class="post-navigation mt-8 pt-6 border-t border-surface-200 dark:border-surface-700" aria-label="Post navigation">
<div class="grid grid-cols-2 gap-3 sm:gap-4">
{# ── Previous Post ── #}
{% if _prevPost %}
{% set _prevOgSlug = _prevPost.url | ogSlug %}
{% set _prevHasOg = _prevOgSlug | hasOgImage %}
{% set _prevTitle = _prevPost.data.title or _prevPost.data.name %}
{# Derive display text for non-article post types #}
{% set _likedUrl = _prevPost.data.likeOf or _prevPost.data.like_of %}
{% set _bookmarkedUrl = _prevPost.data.bookmarkOf or _prevPost.data.bookmark_of %}
{% set _repostedUrl = _prevPost.data.repostOf or _prevPost.data.repost_of %}
{% set _replyToUrl = _prevPost.data.inReplyTo or _prevPost.data.in_reply_to %}
{% if not _prevTitle %}
{% if _likedUrl %}
{% set _prevTitle = "Liked " + (_likedUrl | replace("https://", "") | truncate(40)) %}
{% elif _bookmarkedUrl %}
{% set _prevTitle = "Bookmarked " + (_bookmarkedUrl | replace("https://", "") | truncate(35)) %}
{% elif _repostedUrl %}
{% set _prevTitle = "Reposted " + (_repostedUrl | replace("https://", "") | truncate(35)) %}
{% elif _replyToUrl %}
{% set _prevTitle = "Reply to " + (_replyToUrl | replace("https://", "") | truncate(35)) %}
{% else %}
{% set _prevTitle = (_prevPost.templateContent | striptags | truncate(60)) or "Note" %}
{% endif %}
{% endif %}
<a href="{{ _prevPost.url }}" class="group relative block rounded-lg overflow-hidden bg-surface-100 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 hover:border-accent-400 dark:hover:border-accent-600 transition-colors">
{% if _prevHasOg %}
<img src="/og/{{ _prevOgSlug }}.png" alt="{{ _prevTitle }}" class="w-full aspect-[1.91/1] object-cover opacity-85 group-hover:opacity-100 transition-opacity" loading="lazy" decoding="async" eleventy:ignore>
<span class="absolute top-2 left-2 text-[10px] sm:text-xs font-semibold uppercase tracking-wide bg-white/90 dark:bg-surface-900/90 text-surface-700 dark:text-surface-300 px-2 py-0.5 rounded">
&larr; Previous
</span>
{% else %}
<div class="p-4 sm:p-5">
<span class="text-[10px] sm:text-xs font-semibold uppercase tracking-wide text-surface-500 block mb-2">&larr; Previous</span>
<span class="text-sm sm:text-base font-medium text-surface-900 dark:text-surface-100 group-hover:text-accent-600 dark:group-hover:text-accent-400 line-clamp-2 transition-colors">
{{ _prevTitle }}
</span>
<time class="text-xs text-surface-500 mt-1 block" datetime="{{ _prevPost.date | isoDate }}">{{ _prevPost.date | dateDisplay }}</time>
</div>
{% endif %}
</a>
{% else %}
<div class="rounded-lg bg-surface-50 dark:bg-surface-800/50 border border-surface-200/50 dark:border-surface-700/50"></div>
{% endif %}
{# ── Next Post ── #}
{% if _nextPost %}
{% set _nextOgSlug = _nextPost.url | ogSlug %}
{% set _nextHasOg = _nextOgSlug | hasOgImage %}
{% set _nextTitle = _nextPost.data.title or _nextPost.data.name %}
{# Derive display text for non-article post types #}
{% set _likedUrl = _nextPost.data.likeOf or _nextPost.data.like_of %}
{% set _bookmarkedUrl = _nextPost.data.bookmarkOf or _nextPost.data.bookmark_of %}
{% set _repostedUrl = _nextPost.data.repostOf or _nextPost.data.repost_of %}
{% set _replyToUrl = _nextPost.data.inReplyTo or _nextPost.data.in_reply_to %}
{% if not _nextTitle %}
{% if _likedUrl %}
{% set _nextTitle = "Liked " + (_likedUrl | replace("https://", "") | truncate(40)) %}
{% elif _bookmarkedUrl %}
{% set _nextTitle = "Bookmarked " + (_bookmarkedUrl | replace("https://", "") | truncate(35)) %}
{% elif _repostedUrl %}
{% set _nextTitle = "Reposted " + (_repostedUrl | replace("https://", "") | truncate(35)) %}
{% elif _replyToUrl %}
{% set _nextTitle = "Reply to " + (_replyToUrl | replace("https://", "") | truncate(35)) %}
{% else %}
{% set _nextTitle = (_nextPost.templateContent | striptags | truncate(60)) or "Note" %}
{% endif %}
{% endif %}
<a href="{{ _nextPost.url }}" class="group relative block rounded-lg overflow-hidden bg-surface-100 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 hover:border-accent-400 dark:hover:border-accent-600 transition-colors">
{% if _nextHasOg %}
<img src="/og/{{ _nextOgSlug }}.png" alt="{{ _nextTitle }}" class="w-full aspect-[1.91/1] object-cover opacity-85 group-hover:opacity-100 transition-opacity" loading="lazy" decoding="async" eleventy:ignore>
<span class="absolute top-2 right-2 text-[10px] sm:text-xs font-semibold uppercase tracking-wide bg-white/90 dark:bg-surface-900/90 text-surface-700 dark:text-surface-300 px-2 py-0.5 rounded">
Next &rarr;
</span>
{% else %}
<div class="p-4 sm:p-5 text-right">
<span class="text-[10px] sm:text-xs font-semibold uppercase tracking-wide text-surface-500 block mb-2">Next &rarr;</span>
<span class="text-sm sm:text-base font-medium text-surface-900 dark:text-surface-100 group-hover:text-accent-600 dark:group-hover:text-accent-400 line-clamp-2 transition-colors">
{{ _nextTitle }}
</span>
<time class="text-xs text-surface-500 mt-1 block" datetime="{{ _nextPost.date | isoDate }}">{{ _nextPost.date | dateDisplay }}</time>
</div>
{% endif %}
</a>
{% else %}
<div class="rounded-lg bg-surface-50 dark:bg-surface-800/50 border border-surface-200/50 dark:border-surface-700/50"></div>
{% endif %}
</div>
</nav>
{% endif %}
@@ -0,0 +1,74 @@
{# Reply Context Component #}
{# Displays rich context for replies, likes, reposts, and bookmarks #}
{# Uses h-cite microformat for citing external content #}
{# Includes unfurl card for rich link preview (OpenGraph metadata) #}
{# Support both camelCase (Indiekit Eleventy preset) and underscore (legacy) property names #}
{% set replyTo = inReplyTo or in_reply_to %}
{% set likedUrl = likeOf or like_of %}
{% set repostedUrl = repostOf or repost_of %}
{% set bookmarkedUrl = bookmarkOf or bookmark_of %}
{% if replyTo or likedUrl or repostedUrl or bookmarkedUrl %}
<aside class="reply-context mb-6">
{% if replyTo %}
<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>
{% unfurl replyTo %}
<a class="u-url text-xs text-surface-400 dark:text-surface-500 hover:underline break-all" href="{{ replyTo }}">
{{ replyTo }}
</a>
</div>
{% endif %}
{% if likedUrl %}
<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>
{% unfurl likedUrl %}
<a class="u-url text-xs text-surface-400 dark:text-surface-500 hover:underline break-all" href="{{ likedUrl }}">
{{ likedUrl }}
</a>
</div>
{% endif %}
{% if repostedUrl %}
<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>
{% unfurl repostedUrl %}
<a class="u-url text-xs text-surface-400 dark:text-surface-500 hover:underline break-all" href="{{ repostedUrl }}">
{{ repostedUrl }}
</a>
</div>
{% endif %}
{% if bookmarkedUrl %}
<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>
{% unfurl bookmarkedUrl %}
<a class="u-url text-xs text-surface-400 dark:text-surface-500 hover:underline break-all" href="{{ bookmarkedUrl }}">
{{ bookmarkedUrl }}
</a>
</div>
{% endif %}
</aside>
{% endif %}
@@ -0,0 +1,18 @@
{#
Custom HTML Section - freeform HTML/markdown content block
Rendered by homepage-builder when custom-html section is configured
#}
{% set sectionConfig = section.config or {} %}
<section class="mb-8 sm:mb-12">
{% if sectionConfig.title %}
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">
{{ sectionConfig.title }}
</h2>
{% endif %}
<div class="prose dark:prose-invert max-w-none">
{{ sectionConfig.content | safe }}
</div>
</section>
@@ -0,0 +1,2 @@
{% set filterType = "personal" %}
{% include "components/sections/cv-education.njk" %}
@@ -0,0 +1,2 @@
{% set filterType = "work" %}
{% include "components/sections/cv-education.njk" %}
@@ -0,0 +1,88 @@
{#
CV Education Section - collapsible education cards (accordion)
Data fetched from /cv/data.json via homepage plugin
Each card gets a distinct color via cycling palette
#}
{% set hasEducation = cv and cv.education and cv.education.length %}
{% if hasEducation %}
<section class="mb-8 sm:mb-12" id="education" x-data="{ expanded: {} }">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">
{{ section.config.title or "Education" }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-start">
{% for item in cv.education %}
{% if not filterType or item.educationType == filterType or not item.educationType %}
{% set ci = loop.index0 % 8 %}
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 transition-colors overflow-hidden border-l-[3px]
{% if ci == 0 %}border-l-amber-400 dark:border-l-amber-500
{% elif ci == 1 %}border-l-emerald-400 dark:border-l-emerald-500
{% elif ci == 2 %}border-l-sky-400 dark:border-l-sky-500
{% elif ci == 3 %}border-l-rose-400 dark:border-l-rose-500
{% elif ci == 4 %}border-l-purple-400 dark:border-l-purple-500
{% elif ci == 5 %}border-l-orange-400 dark:border-l-orange-500
{% elif ci == 6 %}border-l-teal-400 dark:border-l-teal-500
{% elif ci == 7 %}border-l-indigo-400 dark:border-l-indigo-500
{% endif %}">
{# Summary row — always visible, clickable #}
<button
class="w-full p-4 flex items-center justify-between gap-2 cursor-pointer text-left hover:bg-surface-50 dark:hover:bg-surface-700/50 transition-colors"
@click="expanded[{{ loop.index0 }}] = !expanded[{{ loop.index0 }}]"
:aria-expanded="expanded[{{ loop.index0 }}] ? 'true' : 'false'"
>
<div class="min-w-0 flex-1">
<h3 class="font-semibold text-surface-900 dark:text-surface-100 truncate">{{ item.degree }}</h3>
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">
{{ item.institution }}{% if item.location %} &middot; {{ item.location }}{% endif %}
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
{% if item.startDate %}
<span class="text-xs text-surface-500 hidden sm:inline">
{{ item.startDate }}{% if item.endDate %} {{ item.endDate }}{% else %} Present{% endif %}
</span>
{% elif item.year %}
<span class="text-xs text-surface-500 hidden sm:inline">{{ item.year }}</span>
{% endif %}
<svg
class="w-4 h-4 text-surface-400 transition-transform duration-200"
:class="expanded[{{ loop.index0 }}] && 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</button>
{# Detail section — collapsible #}
<div
x-show="expanded[{{ loop.index0 }}]"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
x-cloak
class="px-4 pb-4"
>
{% if item.startDate %}
<p class="text-xs text-surface-500 mb-1 sm:hidden">
{{ item.startDate }}{% if item.endDate %} {{ item.endDate }}{% else %} Present{% endif %}
</p>
{% elif item.year %}
<p class="text-xs text-surface-500 mb-1 sm:hidden">{{ item.year }}</p>
{% endif %}
{% if item.description %}
<p class="text-sm text-surface-600 dark:text-surface-400">{{ item.description }}</p>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
</section>
{% endif %}
@@ -0,0 +1,2 @@
{% set filterType = "personal" %}
{% include "components/sections/cv-experience.njk" %}
@@ -0,0 +1,2 @@
{% set filterType = "work" %}
{% include "components/sections/cv-experience.njk" %}
@@ -0,0 +1,48 @@
{#
CV Experience Section - work experience timeline
Data fetched from /cv/data.json via homepage plugin
#}
{% set sectionConfig = section.config or {} %}
{% set maxItems = sectionConfig.maxItems or 10 %}
{% set showHighlights = sectionConfig.showHighlights if sectionConfig.showHighlights is defined else true %}
{% if cv and cv.experience and cv.experience.length %}
<section class="mb-8 sm:mb-12" id="experience">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">
{{ sectionConfig.title or "Experience" }}
</h2>
<div class="space-y-4">
{% for item in cv.experience | head(maxItems) %}
{% if not filterType or item.experienceType == filterType or not item.experienceType %}
<div class="relative pl-6 border-l-2 border-accent-300 dark:border-accent-700">
<div class="absolute -left-[7px] top-1 w-3 h-3 rounded-full bg-accent-500"></div>
<h3 class="font-semibold text-surface-900 dark:text-surface-100">{{ item.title }}</h3>
<p class="text-sm text-surface-600 dark:text-surface-400">
{{ item.company }}{% if item.location %} &middot; {{ item.location }}{% endif %}
{% if item.type %} &middot; <span class="capitalize">{{ item.type }}</span>{% endif %}
</p>
{% if item.startDate %}
<p class="text-xs text-surface-500 mt-0.5">
{{ item.startDate }}{% if item.endDate %} {{ item.endDate }}{% else %} Present{% endif %}
</p>
{% endif %}
{% if item.description %}
<p class="text-sm text-surface-700 dark:text-surface-300 mt-2">{{ item.description }}</p>
{% endif %}
{% if showHighlights and item.highlights and item.highlights.length %}
<div class="flex flex-wrap gap-1.5 mt-2">
{% for h in item.highlights %}
<span class="px-2.5 py-1 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-full text-xs text-surface-700 dark:text-surface-300">
{{ h }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</section>
{% endif %}
@@ -0,0 +1,2 @@
{% set filterType = "personal" %}
{% include "components/sections/cv-interests.njk" %}
@@ -0,0 +1,2 @@
{% set filterType = "work" %}
{% include "components/sections/cv-interests.njk" %}
@@ -0,0 +1,50 @@
{#
CV Interests Section - interests grouped by category
Data fetched from /cv/data.json via homepage plugin
Each family gets a distinct color via cycling palette
#}
{% if cv and cv.interests and (cv.interests | dictsort | length) %}
<section class="mb-8 sm:mb-12" id="interests">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">
{{ section.config.title or "Interests" }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
{% for category, items in cv.interests %}
{% if not filterType or (cv.interestTypes and cv.interestTypes[category] == filterType) or not cv.interestTypes or not cv.interestTypes[category] %}
{# Cycle through 8 distinct colors per family using loop.index0 #}
{% set ci = loop.index0 % 8 %}
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
<h3 class="font-semibold text-sm uppercase tracking-wide text-surface-600 dark:text-surface-400 mb-2">
{{ category }}
</h3>
<div class="flex flex-wrap gap-1.5">
{% for interest in items %}
{% if ci == 0 %}
<span class="text-xs px-2 py-1 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full">
{% elif ci == 1 %}
<span class="text-xs px-2 py-1 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 rounded-full">
{% elif ci == 2 %}
<span class="text-xs px-2 py-1 bg-sky-50 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300 rounded-full">
{% elif ci == 3 %}
<span class="text-xs px-2 py-1 bg-rose-50 dark:bg-rose-900/30 text-rose-700 dark:text-rose-300 rounded-full">
{% elif ci == 4 %}
<span class="text-xs px-2 py-1 bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-full">
{% elif ci == 5 %}
<span class="text-xs px-2 py-1 bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full">
{% elif ci == 6 %}
<span class="text-xs px-2 py-1 bg-teal-50 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 rounded-full">
{% elif ci == 7 %}
<span class="text-xs px-2 py-1 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 rounded-full">
{% endif %}
{{ interest }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
</section>
{% endif %}
@@ -0,0 +1,21 @@
{#
CV Languages Section
Data fetched from /cv/data.json via homepage plugin
#}
{% if cv and cv.languages and cv.languages.length %}
<section class="mb-8 sm:mb-12" id="languages">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">
{{ section.config.title or "Languages" }}
</h2>
<div class="flex flex-wrap gap-3">
{% for lang in cv.languages %}
<div class="flex items-center gap-2 px-3 py-1.5 bg-surface-50 dark:bg-surface-800 rounded-full border border-surface-200 dark:border-surface-700">
<span class="font-medium text-sm text-surface-900 dark:text-surface-100">{{ lang.name }}</span>
<span class="text-xs text-surface-500 capitalize">{{ lang.level }}</span>
</div>
{% endfor %}
</div>
</section>
{% endif %}
@@ -0,0 +1,124 @@
{#
CV Personal Projects Section - collapsible project cards (accordion)
Filters projects by projectType == "personal" (or unset)
Data fetched from /cv/data.json via homepage plugin
#}
{% set sectionConfig = section.config or {} %}
{% set maxItems = sectionConfig.maxItems or 10 %}
{% set showTechnologies = sectionConfig.showTechnologies if sectionConfig.showTechnologies is defined else true %}
{% set personalProjects = [] %}
{% if cv and cv.projects %}
{% for item in cv.projects %}
{% if item.projectType == "personal" or not item.projectType %}
{% set personalProjects = (personalProjects.push(item), personalProjects) %}
{% endif %}
{% endfor %}
{% endif %}
{% if personalProjects.length %}
<section class="mb-8 sm:mb-12" id="personal-projects" x-data="{ expanded: {} }">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">
{{ sectionConfig.title or "Personal Projects" }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-start">
{% for item in personalProjects | head(maxItems) %}
{% set ci = loop.index0 % 8 %}
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 transition-colors overflow-hidden border-l-[3px]
{% if ci == 0 %}border-l-amber-400 dark:border-l-amber-500
{% elif ci == 1 %}border-l-emerald-400 dark:border-l-emerald-500
{% elif ci == 2 %}border-l-sky-400 dark:border-l-sky-500
{% elif ci == 3 %}border-l-rose-400 dark:border-l-rose-500
{% elif ci == 4 %}border-l-purple-400 dark:border-l-purple-500
{% elif ci == 5 %}border-l-orange-400 dark:border-l-orange-500
{% elif ci == 6 %}border-l-teal-400 dark:border-l-teal-500
{% elif ci == 7 %}border-l-indigo-400 dark:border-l-indigo-500
{% endif %}">
{# Summary row — always visible, clickable #}
<button
class="w-full p-4 flex items-center justify-between gap-2 cursor-pointer text-left hover:bg-surface-50 dark:hover:bg-surface-700/50 transition-colors"
@click="expanded[{{ loop.index0 }}] = !expanded[{{ loop.index0 }}]"
:aria-expanded="expanded[{{ loop.index0 }}] ? 'true' : 'false'"
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<h3 class="font-semibold text-surface-900 dark:text-surface-100 truncate">
{% if item.url %}
<a href="{{ item.url }}" class="hover:underline" @click.stop>{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
</h3>
{% if item.status %}
<span class="shrink-0 text-xs px-2 py-0.5 rounded-full capitalize
{% if item.status == 'active' %}bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300
{% elif item.status == 'maintained' %}bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300
{% elif item.status == 'archived' %}bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400
{% else %}bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400{% endif %}">
{{ item.status }}
</span>
{% endif %}
</div>
<div class="flex items-center gap-2 shrink-0">
{% if item.startDate %}
<span class="text-xs text-surface-500 hidden sm:inline">
{{ item.startDate }}{% if item.endDate %} {{ item.endDate }}{% else %} Present{% endif %}
</span>
{% endif %}
<svg
class="w-4 h-4 text-surface-400 transition-transform duration-200"
:class="expanded[{{ loop.index0 }}] && 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</button>
{# Detail section — collapsible #}
<div
x-show="expanded[{{ loop.index0 }}]"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
x-cloak
class="px-4 pb-4"
>
{% if item.startDate %}
<p class="text-xs text-surface-500 mb-1 sm:hidden">
{{ item.startDate }}{% if item.endDate %} {{ item.endDate }}{% else %} Present{% endif %}
</p>
{% endif %}
{% if item.description %}
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2">{{ item.description }}</p>
{% endif %}
{% if showTechnologies and item.technologies and item.technologies.length %}
<div class="flex flex-wrap gap-1">
{% for tech in item.technologies %}
<span class="text-xs px-2 py-0.5 rounded
{% if ci == 0 %}bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300
{% elif ci == 1 %}bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300
{% elif ci == 2 %}bg-sky-50 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300
{% elif ci == 3 %}bg-rose-50 dark:bg-rose-900/30 text-rose-700 dark:text-rose-300
{% elif ci == 4 %}bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300
{% elif ci == 5 %}bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300
{% elif ci == 6 %}bg-teal-50 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300
{% elif ci == 7 %}bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300
{% endif %}">
{{ tech }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
@@ -0,0 +1,124 @@
{#
CV Work Projects Section - collapsible project cards (accordion)
Filters projects by projectType == "work"
Data fetched from /cv/data.json via homepage plugin
#}
{% set sectionConfig = section.config or {} %}
{% set maxItems = sectionConfig.maxItems or 10 %}
{% set showTechnologies = sectionConfig.showTechnologies if sectionConfig.showTechnologies is defined else true %}
{% set workProjects = [] %}
{% if cv and cv.projects %}
{% for item in cv.projects %}
{% if item.projectType == "work" %}
{% set workProjects = (workProjects.push(item), workProjects) %}
{% endif %}
{% endfor %}
{% endif %}
{% if workProjects.length %}
<section class="mb-8 sm:mb-12" id="work-projects" x-data="{ expanded: {} }">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">
{{ sectionConfig.title or "Work Projects" }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-start">
{% for item in workProjects | head(maxItems) %}
{% set ci = loop.index0 % 8 %}
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 transition-colors overflow-hidden border-l-[3px]
{% if ci == 0 %}border-l-amber-400 dark:border-l-amber-500
{% elif ci == 1 %}border-l-emerald-400 dark:border-l-emerald-500
{% elif ci == 2 %}border-l-sky-400 dark:border-l-sky-500
{% elif ci == 3 %}border-l-rose-400 dark:border-l-rose-500
{% elif ci == 4 %}border-l-purple-400 dark:border-l-purple-500
{% elif ci == 5 %}border-l-orange-400 dark:border-l-orange-500
{% elif ci == 6 %}border-l-teal-400 dark:border-l-teal-500
{% elif ci == 7 %}border-l-indigo-400 dark:border-l-indigo-500
{% endif %}">
{# Summary row — always visible, clickable #}
<button
class="w-full p-4 flex items-center justify-between gap-2 cursor-pointer text-left hover:bg-surface-50 dark:hover:bg-surface-700/50 transition-colors"
@click="expanded[{{ loop.index0 }}] = !expanded[{{ loop.index0 }}]"
:aria-expanded="expanded[{{ loop.index0 }}] ? 'true' : 'false'"
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<h3 class="font-semibold text-surface-900 dark:text-surface-100 truncate">
{% if item.url %}
<a href="{{ item.url }}" class="hover:underline" @click.stop>{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
</h3>
{% if item.status %}
<span class="shrink-0 text-xs px-2 py-0.5 rounded-full capitalize
{% if item.status == 'active' %}bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300
{% elif item.status == 'maintained' %}bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300
{% elif item.status == 'archived' %}bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400
{% else %}bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400{% endif %}">
{{ item.status }}
</span>
{% endif %}
</div>
<div class="flex items-center gap-2 shrink-0">
{% if item.startDate %}
<span class="text-xs text-surface-500 hidden sm:inline">
{{ item.startDate }}{% if item.endDate %} {{ item.endDate }}{% else %} Present{% endif %}
</span>
{% endif %}
<svg
class="w-4 h-4 text-surface-400 transition-transform duration-200"
:class="expanded[{{ loop.index0 }}] && 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</button>
{# Detail section — collapsible #}
<div
x-show="expanded[{{ loop.index0 }}]"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
x-cloak
class="px-4 pb-4"
>
{% if item.startDate %}
<p class="text-xs text-surface-500 mb-1 sm:hidden">
{{ item.startDate }}{% if item.endDate %} {{ item.endDate }}{% else %} Present{% endif %}
</p>
{% endif %}
{% if item.description %}
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2">{{ item.description }}</p>
{% endif %}
{% if showTechnologies and item.technologies and item.technologies.length %}
<div class="flex flex-wrap gap-1">
{% for tech in item.technologies %}
<span class="text-xs px-2 py-0.5 rounded
{% if ci == 0 %}bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300
{% elif ci == 1 %}bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300
{% elif ci == 2 %}bg-sky-50 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300
{% elif ci == 3 %}bg-rose-50 dark:bg-rose-900/30 text-rose-700 dark:text-rose-300
{% elif ci == 4 %}bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300
{% elif ci == 5 %}bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300
{% elif ci == 6 %}bg-teal-50 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300
{% elif ci == 7 %}bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300
{% endif %}">
{{ tech }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
@@ -0,0 +1,114 @@
{#
CV Projects Section - collapsible project cards (accordion)
Data fetched from /cv/data.json via homepage plugin
#}
{% set sectionConfig = section.config or {} %}
{% set maxItems = sectionConfig.maxItems or 10 %}
{% set showTechnologies = sectionConfig.showTechnologies if sectionConfig.showTechnologies is defined else true %}
{% if cv and cv.projects and cv.projects.length %}
<section class="mb-8 sm:mb-12" id="projects" x-data="{ expanded: {} }">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">
{{ sectionConfig.title or "Projects" }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-start">
{% for item in cv.projects | head(maxItems) %}
{% set ci = loop.index0 % 8 %}
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 transition-colors overflow-hidden border-l-[3px]
{% if ci == 0 %}border-l-amber-400 dark:border-l-amber-500
{% elif ci == 1 %}border-l-emerald-400 dark:border-l-emerald-500
{% elif ci == 2 %}border-l-sky-400 dark:border-l-sky-500
{% elif ci == 3 %}border-l-rose-400 dark:border-l-rose-500
{% elif ci == 4 %}border-l-purple-400 dark:border-l-purple-500
{% elif ci == 5 %}border-l-orange-400 dark:border-l-orange-500
{% elif ci == 6 %}border-l-teal-400 dark:border-l-teal-500
{% elif ci == 7 %}border-l-indigo-400 dark:border-l-indigo-500
{% endif %}">
{# Summary row — always visible, clickable #}
<button
class="w-full p-4 flex items-center justify-between gap-2 cursor-pointer text-left hover:bg-surface-50 dark:hover:bg-surface-700/50 transition-colors"
@click="expanded[{{ loop.index0 }}] = !expanded[{{ loop.index0 }}]"
:aria-expanded="expanded[{{ loop.index0 }}] ? 'true' : 'false'"
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<h3 class="font-semibold text-surface-900 dark:text-surface-100 truncate">
{% if item.url %}
<a href="{{ item.url }}" class="hover:underline" @click.stop>{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
</h3>
{% if item.status %}
<span class="shrink-0 text-xs px-2 py-0.5 rounded-full capitalize
{% if item.status == 'active' %}bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300
{% elif item.status == 'maintained' %}bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300
{% elif item.status == 'archived' %}bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400
{% else %}bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400{% endif %}">
{{ item.status }}
</span>
{% endif %}
</div>
<div class="flex items-center gap-2 shrink-0">
{% if item.startDate %}
<span class="text-xs text-surface-500 hidden sm:inline">
{{ item.startDate }}{% if item.endDate %} {{ item.endDate }}{% else %} Present{% endif %}
</span>
{% endif %}
<svg
class="w-4 h-4 text-surface-400 transition-transform duration-200"
:class="expanded[{{ loop.index0 }}] && 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</button>
{# Detail section — collapsible #}
<div
x-show="expanded[{{ loop.index0 }}]"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
x-cloak
class="px-4 pb-4"
>
{% if item.startDate %}
<p class="text-xs text-surface-500 mb-1 sm:hidden">
{{ item.startDate }}{% if item.endDate %} {{ item.endDate }}{% else %} Present{% endif %}
</p>
{% endif %}
{% if item.description %}
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2">{{ item.description }}</p>
{% endif %}
{% if showTechnologies and item.technologies and item.technologies.length %}
<div class="flex flex-wrap gap-1">
{% for tech in item.technologies %}
<span class="text-xs px-2 py-0.5 rounded
{% if ci == 0 %}bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300
{% elif ci == 1 %}bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300
{% elif ci == 2 %}bg-sky-50 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300
{% elif ci == 3 %}bg-rose-50 dark:bg-rose-900/30 text-rose-700 dark:text-rose-300
{% elif ci == 4 %}bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300
{% elif ci == 5 %}bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300
{% elif ci == 6 %}bg-teal-50 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300
{% elif ci == 7 %}bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300
{% endif %}">
{{ tech }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
@@ -0,0 +1,2 @@
{% set filterType = "personal" %}
{% include "components/sections/cv-skills.njk" %}
@@ -0,0 +1,2 @@
{% set filterType = "work" %}
{% include "components/sections/cv-skills.njk" %}
@@ -0,0 +1,50 @@
{#
CV Skills Section - skills grouped by category
Data fetched from /cv/data.json via homepage plugin
Each family gets a distinct color via cycling palette
#}
{% if cv and cv.skills and (cv.skills | dictsort | length) %}
<section class="mb-8 sm:mb-12" id="skills">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">
{{ section.config.title or "Skills" }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
{% for category, items in cv.skills %}
{% if not filterType or (cv.skillTypes and cv.skillTypes[category] == filterType) or not cv.skillTypes or not cv.skillTypes[category] %}
{# Cycle through 8 distinct colors per family using loop.index0 #}
{% set ci = loop.index0 % 8 %}
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
<h3 class="font-semibold text-sm uppercase tracking-wide text-surface-600 dark:text-surface-400 mb-2">
{{ category }}
</h3>
<div class="flex flex-wrap gap-1.5">
{% for skill in items %}
{% if ci == 0 %}
<span class="text-xs px-2 py-1 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full">
{% elif ci == 1 %}
<span class="text-xs px-2 py-1 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 rounded-full">
{% elif ci == 2 %}
<span class="text-xs px-2 py-1 bg-sky-50 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300 rounded-full">
{% elif ci == 3 %}
<span class="text-xs px-2 py-1 bg-rose-50 dark:bg-rose-900/30 text-rose-700 dark:text-rose-300 rounded-full">
{% elif ci == 4 %}
<span class="text-xs px-2 py-1 bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-full">
{% elif ci == 5 %}
<span class="text-xs px-2 py-1 bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full">
{% elif ci == 6 %}
<span class="text-xs px-2 py-1 bg-teal-50 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 rounded-full">
{% elif ci == 7 %}
<span class="text-xs px-2 py-1 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 rounded-full">
{% endif %}
{{ skill }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
</section>
{% endif %}
@@ -0,0 +1,259 @@
{#
Featured Posts Section - displays curated posts with `featured: true` frontmatter
Rendered by homepage-builder when featured-posts section is configured
Supports type-aware rendering for articles, notes, likes, bookmarks, reposts, replies, photos
#}
{% set sectionConfig = section.config or {} %}
{% set maxItems = sectionConfig.maxItems or 6 %}
{% set showSummary = sectionConfig.showSummary if sectionConfig.showSummary is defined else true %}
{% if collections.featuredPosts and collections.featuredPosts.length %}
<section class="mb-8 sm:mb-12">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6 flex items-center gap-2">
<svg class="w-5 h-5 text-amber-500" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
{{ sectionConfig.title or "Featured" }}
</h2>
<div class="space-y-4">
{% for post in collections.featuredPosts | head(maxItems) %}
{# Detect post type from frontmatter properties #}
{% set likedUrl = post.data.likeOf or post.data.like_of %}
{% set bookmarkedUrl = post.data.bookmarkOf or post.data.bookmark_of %}
{% set repostedUrl = post.data.repostOf or post.data.repost_of %}
{% set replyToUrl = post.data.inReplyTo or post.data.in_reply_to %}
{% set hasPhotos = post.data.photo and post.data.photo.length %}
{# Determine border color by post type #}
{% set borderClass = "" %}
{% if likedUrl %}
{% set borderClass = "border-l-[3px] border-l-red-400 dark:border-l-red-500" %}
{% elif bookmarkedUrl %}
{% set borderClass = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
{% elif repostedUrl %}
{% set borderClass = "border-l-[3px] border-l-green-400 dark:border-l-green-500" %}
{% elif replyToUrl %}
{% set borderClass = "border-l-[3px] border-l-sky-400 dark:border-l-sky-500" %}
{% elif hasPhotos %}
{% set borderClass = "border-l-[3px] border-l-purple-400 dark:border-l-purple-500" %}
{% else %}
{% set borderClass = "border-l-[3px] border-l-surface-300 dark:border-l-surface-600" %}
{% endif %}
<article class="h-entry p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-surface-400 dark:hover:border-surface-500 transition-colors {{ borderClass }}">
{% if likedUrl %}
{# ── Like card ── #}
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 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="flex items-center gap-3 text-xs text-surface-500">
<span class="font-medium text-red-600 dark:text-red-400">Liked</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</div>
{{ likedUrl | unfurlCard | safe }}
<a class="u-like-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ likedUrl }}">
{{ likedUrl }}
</a>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif bookmarkedUrl %}
{# ── Bookmark card ── #}
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 text-xs text-surface-500">
<span class="font-medium text-amber-600 dark:text-amber-400">Bookmarked</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</div>
{% if post.data.title %}
<h3 class="p-name font-semibold mt-1">
<a class="text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400 hover:underline" href="{{ post.url }}">{{ post.data.title }}</a>
</h3>
{% endif %}
{{ bookmarkedUrl | unfurlCard | safe }}
<a class="u-bookmark-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ bookmarkedUrl }}">
{{ bookmarkedUrl }}
</a>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif repostedUrl %}
{# ── Repost card ── #}
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 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">
<div class="flex items-center gap-3 text-xs text-surface-500">
<span class="font-medium text-green-600 dark:text-green-400">Reposted</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</div>
{{ repostedUrl | unfurlCard | safe }}
<a class="u-repost-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ repostedUrl }}">
{{ repostedUrl }}
</a>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif replyToUrl %}
{# ── Reply card ── #}
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 text-sky-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">
<div class="flex items-center gap-3 text-xs text-surface-500">
<span class="font-medium text-sky-600 dark:text-sky-400">In reply to</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</div>
{{ replyToUrl | unfurlCard | safe }}
<a class="u-in-reply-to text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ replyToUrl }}">
{{ replyToUrl }}
</a>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif hasPhotos %}
{# ── Photo card ── #}
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 text-purple-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 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 text-xs text-surface-500">
<span class="font-medium text-purple-600 dark:text-purple-400">Photo</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</div>
<div class="photo-gallery mt-2">
{% for img in post.data.photo | head(2) %}
{% set photoUrl = img.url %}
{% if photoUrl and photoUrl[0] != '/' and 'http' not in photoUrl %}
{% set photoUrl = '/' + photoUrl %}
{% endif %}
<a href="{{ post.url }}" class="photo-link">
<img src="{{ photoUrl }}" alt="{{ img.alt | default('Photo') }}" class="u-photo rounded max-h-48 object-cover" loading="lazy" eleventy:ignore>
</a>
{% endfor %}
</div>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-2">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif post.data.title %}
{# ── Article/Page card ── #}
<h3 class="p-name font-semibold mb-1">
<a href="{{ post.url }}" class="u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400 hover:underline">
{{ post.data.title }}
</a>
</h3>
{% if showSummary and post.templateContent %}
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2 line-clamp-2">
{{ post.templateContent | striptags | truncate(250) }}
</p>
{% endif %}
<div class="flex items-center gap-3 text-xs text-surface-500">
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
{% if post.data.postType %}
<span class="px-2 py-0.5 bg-surface-100 dark:bg-surface-700 rounded">
{{ post.data.postType }}
</span>
{% endif %}
</div>
{% else %}
{# ── Note card ── #}
<div class="flex items-center gap-3 text-xs text-surface-500 mb-2">
<a class="u-url" href="{{ post.url }}">
<time class="dt-published font-medium text-surface-500 dark:text-surface-400" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</a>
{% if post.data.postType %}
<span class="px-2 py-0.5 bg-surface-100 dark:bg-surface-700 rounded">
{{ post.data.postType }}
</span>
{% endif %}
</div>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm max-w-none line-clamp-3">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a href="{{ post.url }}" class="text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block">
Permalink
</a>
{% endif %}
</article>
{% endfor %}
</div>
{% if collections.featuredPosts.length > maxItems %}
<div class="mt-4 text-center">
<a href="/featured/" class="inline-flex items-center gap-1 text-sm text-accent-600 dark:text-accent-400 hover:underline font-medium">
View all {{ collections.featuredPosts.length }} featured posts &rarr;
</a>
</div>
{% endif %}
</section>
{% endif %}
@@ -0,0 +1,66 @@
{#
Hero Section - author intro with avatar, name, title, bio
Rendered by homepage-builder when hero is enabled
#}
{% set heroConfig = homepageConfig.hero or {} %}
{% set id = homepageConfig.identity if (homepageConfig and homepageConfig.identity) else {} %}
{% set authorName = id.name or site.author.name %}
{% set authorAvatar = id.avatar or site.author.avatar %}
{% set authorTitle = id.title or site.author.title %}
{% set authorBio = id.bio or site.author.bio %}
{% set siteDescription = id.description or site.description %}
{% set socialLinks = id.social if (id.social and id.social.length) else site.social %}
<section class="mb-8 sm:mb-12">
<div class="flex flex-col sm:flex-row gap-6 sm:gap-8 items-start">
{# Avatar #}
{% if heroConfig.showAvatar != false %}
<img
src="{{ authorAvatar }}"
alt="{{ authorName }}"
class="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover shadow-lg flex-shrink-0"
loading="eager"
>
{% endif %}
{# Introduction #}
<div class="flex-1 min-w-0">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">
{{ authorName }}
</h1>
<p class="text-lg sm:text-xl text-accent-600 dark:text-accent-400 mb-3 sm:mb-4">
{{ authorTitle }}
</p>
{% if authorBio %}
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mb-4">
{{ authorBio }}
</p>
{% endif %}
{% if siteDescription %}
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mb-4 sm:mb-6">
{{ siteDescription }}
<a href="/about/" class="text-accent-600 dark:text-accent-400 hover:underline font-medium">Read more &rarr;</a>
</p>
{% endif %}
{# Social Links #}
{% from "components/social-icon.njk" import socialIcon, socialIconColorClass %}
{% if heroConfig.showSocial != false and socialLinks %}
<div class="flex flex-wrap gap-3">
{% for link in socialLinks %}
<a
href="{{ link.url }}"
rel="{{ link.rel }} noopener"
class="inline-flex items-center gap-2 px-3 py-2 text-sm text-surface-700 dark:text-surface-300 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
target="_blank"
>
<span class="{{ socialIconColorClass(link.icon) }}">{{ socialIcon(link.icon, "w-5 h-5") }}</span>
<span class="text-sm font-medium">{{ link.name }}</span>
</a>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</section>
@@ -0,0 +1,24 @@
{# Posting Activity Section — configurable post-graph contribution grid #}
{% set sectionConfig = section.config or {} %}
{% set graphTitle = sectionConfig.title or "Posting Activity" %}
{% if collections.posts and collections.posts.length %}
<section class="mb-8 sm:mb-12">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">
{{ graphTitle }}
</h2>
{% set graphOptions = {} %}
{% if sectionConfig.years and sectionConfig.years.length %}
{% set graphOptions = { only: sectionConfig.years } %}
{% elif sectionConfig.limit %}
{% set graphOptions = { limit: sectionConfig.limit } %}
{% endif %}
{% postGraph collections.posts, graphOptions %}
<a href="/graph/" class="text-sm text-accent-600 dark:text-accent-400 hover:underline mt-4 inline-flex items-center gap-1">
View full 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>
</section>
{% endif %}
@@ -0,0 +1,257 @@
{#
Recent Posts Section - displays latest posts from any collection
Rendered by homepage-builder when recent-posts section is configured
Supports type-aware rendering for likes, bookmarks, reposts, replies, photos
#}
{% set sectionConfig = section.config or {} %}
{% set maxItems = sectionConfig.maxItems or 5 %}
{% set showSummary = sectionConfig.showSummary if sectionConfig.showSummary is defined else true %}
{% if collections.posts and collections.posts.length %}
<section class="mb-8 sm:mb-12">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">
{{ sectionConfig.title or "Recent Posts" }}
</h2>
<div class="space-y-4">
{% for post in collections.posts | head(maxItems) %}
{# Detect post type from frontmatter properties #}
{% set likedUrl = post.data.likeOf or post.data.like_of %}
{% set bookmarkedUrl = post.data.bookmarkOf or post.data.bookmark_of %}
{% set repostedUrl = post.data.repostOf or post.data.repost_of %}
{% set replyToUrl = post.data.inReplyTo or post.data.in_reply_to %}
{% set hasPhotos = post.data.photo and post.data.photo.length %}
{# Determine border color by post type #}
{% set borderClass = "" %}
{% if likedUrl %}
{% set borderClass = "border-l-[3px] border-l-red-400 dark:border-l-red-500" %}
{% elif bookmarkedUrl %}
{% set borderClass = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
{% elif repostedUrl %}
{% set borderClass = "border-l-[3px] border-l-green-400 dark:border-l-green-500" %}
{% elif replyToUrl %}
{% set borderClass = "border-l-[3px] border-l-sky-400 dark:border-l-sky-500" %}
{% elif hasPhotos %}
{% set borderClass = "border-l-[3px] border-l-purple-400 dark:border-l-purple-500" %}
{% else %}
{% set borderClass = "border-l-[3px] border-l-surface-300 dark:border-l-surface-600" %}
{% endif %}
<article class="h-entry p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-accent-400 dark:hover:border-accent-600 transition-colors {{ borderClass }}">
{% if likedUrl %}
{# ── Like card ── #}
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 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="flex items-center gap-3 text-xs text-surface-500">
<span class="font-medium text-red-600 dark:text-red-400">Liked</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</div>
{{ likedUrl | unfurlCard | safe }}
<a class="u-like-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ likedUrl }}">
{{ likedUrl }}
</a>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif bookmarkedUrl %}
{# ── Bookmark card ── #}
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 text-xs text-surface-500">
<span class="font-medium text-amber-600 dark:text-amber-400">Bookmarked</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</div>
{% if post.data.title %}
<h3 class="p-name font-semibold text-surface-900 dark:text-surface-100 mt-1">
<a class="hover:text-accent-600 dark:hover:text-accent-400" href="{{ post.url }}">{{ post.data.title }}</a>
</h3>
{% endif %}
{{ bookmarkedUrl | unfurlCard | safe }}
<a class="u-bookmark-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ bookmarkedUrl }}">
{{ bookmarkedUrl }}
</a>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif repostedUrl %}
{# ── Repost card ── #}
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 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">
<div class="flex items-center gap-3 text-xs text-surface-500">
<span class="font-medium text-green-600 dark:text-green-400">Reposted</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</div>
{{ repostedUrl | unfurlCard | safe }}
<a class="u-repost-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ repostedUrl }}">
{{ repostedUrl }}
</a>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif replyToUrl %}
{# ── Reply card ── #}
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 text-sky-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">
<div class="flex items-center gap-3 text-xs text-surface-500">
<span class="font-medium text-sky-600 dark:text-sky-400">In reply to</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</div>
{{ replyToUrl | unfurlCard | safe }}
<a class="u-in-reply-to text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ replyToUrl }}">
{{ replyToUrl }}
</a>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif hasPhotos %}
{# ── Photo card ── #}
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 text-purple-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 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 text-xs text-surface-500">
<span class="font-medium text-purple-600 dark:text-purple-400">Photo</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</div>
<div class="photo-gallery mt-2">
{% for img in post.data.photo | head(2) %}
{% set photoUrl = img.url %}
{% if photoUrl and photoUrl[0] != '/' and 'http' not in photoUrl %}
{% set photoUrl = '/' + photoUrl %}
{% endif %}
<a href="{{ post.url }}" class="photo-link">
<img src="{{ photoUrl }}" alt="{{ img.alt | default('Photo') }}" class="u-photo rounded max-h-48 object-cover" loading="lazy" eleventy:ignore>
</a>
{% endfor %}
</div>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-2">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif post.data.title %}
{# ── Article card ── #}
<h3 class="p-name font-semibold text-surface-900 dark:text-surface-100 mb-1">
<a href="{{ post.url }}" class="u-url hover:text-accent-600 dark:hover:text-accent-400">
{{ post.data.title }}
</a>
</h3>
{% if showSummary and post.templateContent %}
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2 line-clamp-2">
{{ post.templateContent | striptags | truncate(250) }}
</p>
{% endif %}
<div class="flex items-center gap-3 text-xs text-surface-500">
<time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
{% if post.data.postType %}
<span class="px-2 py-0.5 bg-surface-100 dark:bg-surface-700 rounded">
{{ post.data.postType }}
</span>
{% endif %}
</div>
{% else %}
{# ── Note card ── #}
<div class="flex items-center gap-3 text-xs text-surface-500 mb-2">
<a class="u-url" href="{{ post.url }}">
<time class="dt-published font-medium text-surface-500 dark:text-surface-400" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</a>
{% if post.data.postType %}
<span class="px-2 py-0.5 bg-surface-100 dark:bg-surface-700 rounded">
{{ post.data.postType }}
</span>
{% endif %}
</div>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm max-w-none line-clamp-3">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a href="{{ post.url }}" class="text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block">
Permalink
</a>
{% endif %}
</article>
{% endfor %}
</div>
{% if sectionConfig.showViewAll != false %}
<a href="{{ sectionConfig.viewAllUrl or '/blog/' }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline mt-4 inline-flex items-center gap-1">
{{ sectionConfig.viewAllText or "View all posts" }}
<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>
{% endif %}
</section>
{% endif %}
+280
View File
@@ -0,0 +1,280 @@
{# Sidebar — for blog listing pages (/blog/, /notes/, /articles/...) #}
{# Data-driven when homepageConfig.blogListingSidebar is configured, otherwise falls back to default widgets #}
{# Each widget is wrapped in a collapsible container with localStorage persistence #}
{% from "components/icon.njk" import icon %}
{% if homepageConfig and homepageConfig.blogListingSidebar and homepageConfig.blogListingSidebar.length %}
{# === Data-driven mode: render configured widgets === #}
{% for widget in homepageConfig.blogListingSidebar %}
{# Resolve widget title #}
{% if widget.type == "search" %}{% set widgetTitle = "Search" %}
{% elif widget.type == "social-activity" %}{% set widgetTitle = "Social Activity" %}
{% elif widget.type == "github-repos" %}{% set widgetTitle = "GitHub" %}
{% elif widget.type == "funkwhale" %}{% set widgetTitle = "Listening" %}
{% elif widget.type == "recent-posts" %}{% set widgetTitle = "Recent Posts" %}
{% elif widget.type == "blogroll" %}{% set widgetTitle = "Blogroll" %}
{% elif widget.type == "feedland" %}{% set widgetTitle = "FeedLand" %}
{% elif widget.type == "categories" %}{% set widgetTitle = "Categories" %}
{% elif widget.type == "webmentions" %}{% set widgetTitle = "Webmentions" %}
{% elif widget.type == "recent-comments" %}{% set widgetTitle = "Recent Comments" %}
{% elif widget.type == "fediverse-follow" %}{% set widgetTitle = "Fediverse" %}
{% elif widget.type == "author-card" %}{% set widgetTitle = "Author" %}
{% elif widget.type == "author-card-compact" %}{% set widgetTitle = "Author" %}
{% elif widget.type == "subscribe" %}{% set widgetTitle = "Subscribe" %}
{% elif widget.type == "custom-html" %}{% set widgetTitle = (widget.config.title if widget.config and widget.config.title) or "Custom" %}
{% else %}{% set widgetTitle = widget.type %}
{% endif %}
{# Resolve widget icon and accent border #}
{% if widget.type == "social-activity" %}
{% set widgetIcon = "globe" %}{% set widgetIconClass = "w-5 h-5 text-[#0085ff]" %}{% set widgetBorder = "border-l-[3px] border-l-[#0085ff]" %}
{% elif widget.type == "github-repos" %}
{% set widgetIcon = "github" %}{% set widgetIconClass = "w-5 h-5 text-surface-800 dark:text-surface-200" %}{% set widgetBorder = "border-l-[3px] border-l-surface-400 dark:border-l-surface-500" %}
{% elif widget.type == "funkwhale" %}
{% set widgetIcon = "headphones" %}{% set widgetIconClass = "w-5 h-5 text-purple-500" %}{% set widgetBorder = "border-l-[3px] border-l-purple-400 dark:border-l-purple-500" %}
{% elif widget.type == "blogroll" %}
{% set widgetIcon = "book-open" %}{% set widgetIconClass = "w-5 h-5 text-amber-500" %}{% set widgetBorder = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
{% elif widget.type == "feedland" %}
{% set widgetIcon = "rss" %}{% set widgetIconClass = "w-5 h-5 text-amber-500" %}{% set widgetBorder = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
{% elif widget.type == "subscribe" %}
{% set widgetIcon = "rss" %}{% set widgetIconClass = "w-5 h-5 text-orange-500" %}{% set widgetBorder = "border-l-[3px] border-l-orange-400 dark:border-l-orange-500" %}
{% elif widget.type == "fediverse-follow" %}
{% set widgetIcon = "user-plus" %}{% set widgetIconClass = "w-5 h-5 text-[#a730b8]" %}{% set widgetBorder = "border-l-[3px] border-l-[#a730b8]" %}
{% elif widget.type == "author-card" or widget.type == "author-card-compact" %}
{% set widgetIcon = "user" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "recent-posts" %}
{% set widgetIcon = "list" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "categories" %}
{% set widgetIcon = "tag" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "recent-comments" %}
{% set widgetIcon = "chat" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "search" %}
{% set widgetIcon = "search" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% elif widget.type == "webmentions" %}
{% set widgetIcon = "share" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
{% else %}
{% set widgetIcon = "" %}{% set widgetIconClass = "" %}{% set widgetBorder = "" %}
{% endif %}
{% set widgetKey = "listing-widget-" + widget.type + "-" + loop.index0 %}
{% set defaultOpen = "true" if loop.index0 < 3 else "false" %}
{# Collapsible wrapper — Alpine.js handles toggle, localStorage persists state #}
<div
class="widget-collapsible mb-4"
x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : {{ defaultOpen }} }"
>
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden {{ widgetBorder }}">
<button
class="widget-header w-full p-4"
@click="open = !open; localStorage.setItem('{{ widgetKey }}', open)"
:aria-expanded="open ? 'true' : 'false'"
>
<h3 class="widget-title font-bold text-lg flex items-center gap-2">
{% if widgetIcon %}{{ icon(widgetIcon, widgetIconClass) }}{% endif %}
{{ widgetTitle }}
</h3>
<svg
class="widget-chevron"
:class="open && 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div
x-show="open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
>
{# Widget content — inner .widget provides padding, inner title hidden by CSS #}
{% if widget.type == "author-card" %}
{% include "components/widgets/author-card.njk" %}
{% elif widget.type == "author-card-compact" %}
{% include "components/widgets/author-card-compact.njk" %}
{% elif widget.type == "social-activity" %}
{% include "components/widgets/social-activity.njk" %}
{% elif widget.type == "github-repos" %}
{% include "components/widgets/github-repos.njk" %}
{% elif widget.type == "funkwhale" %}
{% include "components/widgets/funkwhale.njk" %}
{% elif widget.type == "recent-posts" %}
{% include "components/widgets/recent-posts.njk" %}
{% elif widget.type == "blogroll" %}
{% if blogrollStatus and blogrollStatus.source == "indiekit" %}
{% include "components/widgets/blogroll.njk" %}
{% endif %}
{% elif widget.type == "feedland" %}
{% include "components/widgets/feedland.njk" %}
{% elif widget.type == "categories" %}
{% include "components/widgets/categories.njk" %}
{% elif widget.type == "subscribe" %}
{% include "components/widgets/subscribe.njk" %}
{% elif widget.type == "recent-comments" %}
{% include "components/widgets/recent-comments.njk" %}
{% elif widget.type == "search" %}
{% include "components/widgets/search.njk" %}
{% elif widget.type == "webmentions" %}
{% include "components/widgets/webmentions.njk" %}
{% elif widget.type == "fediverse-follow" %}
{% include "components/widgets/fediverse-follow.njk" %}
{% elif widget.type == "custom-html" %}
{% set wConfig = widget.config or {} %}
<is-land on:visible>
<div class="widget">
{% if wConfig.content %}
<div class="prose dark:prose-invert prose-sm max-w-none">
{{ wConfig.content | safe }}
</div>
{% endif %}
</div>
</is-land>
{% else %}
<!-- Unknown widget type: {{ widget.type }} -->
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
{# === Fallback: current hardcoded sidebar (backward compatibility) === #}
{# Each widget wrapped in collapsible container #}
{# Author Card (h-card) — always shown #}
{% set widgetKey = "listing-fb-author-card" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : true }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("user", "w-5 h-5 text-surface-500") }} Author</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/author-card.njk" %}
</div>
</div>
</div>
{# Social Activity — Bluesky/Mastodon feeds #}
{% set widgetKey = "listing-fb-social-activity" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : true }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden border-l-[3px] border-l-[#0085ff]">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("globe", "w-5 h-5 text-[#0085ff]") }} Social Activity</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/social-activity.njk" %}
</div>
</div>
</div>
{# GitHub Repos #}
{% set widgetKey = "listing-fb-github-repos" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : true }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden border-l-[3px] border-l-surface-400 dark:border-l-surface-500">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("github", "w-5 h-5 text-surface-800 dark:text-surface-200") }} GitHub</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/github-repos.njk" %}
</div>
</div>
</div>
{# Funkwhale — Now Playing / Listening Stats #}
{% set widgetKey = "listing-fb-funkwhale" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : false }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden border-l-[3px] border-l-purple-400 dark:border-l-purple-500">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("headphones", "w-5 h-5 text-purple-500") }} Listening</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/funkwhale.njk" %}
</div>
</div>
</div>
{# Recent Posts #}
{% set widgetKey = "listing-fb-recent-posts" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : false }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("list", "w-5 h-5 text-surface-500") }} Recent Posts</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/recent-posts.njk" %}
</div>
</div>
</div>
{# Blogroll — only when backend is available #}
{% if blogrollStatus and blogrollStatus.source == "indiekit" %}
{% set widgetKey = "listing-fb-blogroll" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : false }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden border-l-[3px] border-l-amber-400 dark:border-l-amber-500">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("book-open", "w-5 h-5 text-amber-500") }} Blogroll</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/blogroll.njk" %}
</div>
</div>
</div>
{% endif %}
{# FeedLand — only when backend is available #}
{% if blogrollStatus and blogrollStatus.source == "indiekit" %}
{% set widgetKey = "listing-fb-feedland" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : false }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden border-l-[3px] border-l-amber-400 dark:border-l-amber-500">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("rss", "w-5 h-5 text-amber-500") }} FeedLand</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/feedland.njk" %}
</div>
</div>
</div>
{% endif %}
{# Recent Comments #}
{% set widgetKey = "listing-fb-recent-comments" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : false }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("chat", "w-5 h-5 text-surface-500") }} Recent Comments</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/recent-comments.njk" %}
</div>
</div>
</div>
{# Categories/Tags #}
{% set widgetKey = "listing-fb-categories" %}
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : false }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("tag", "w-5 h-5 text-surface-500") }} Categories</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
{% include "components/widgets/categories.njk" %}
</div>
</div>
</div>
{% endif %}
+131
View File
@@ -0,0 +1,131 @@
{#
Social Icon Macro
Usage: {% from "components/social-icon.njk" import socialIcon, socialIconColorClass %}
{{ socialIcon("github", "w-5 h-5") }}
<span class="{{ socialIconColorClass('github') }}">{{ socialIcon("github", "w-5 h-5") }}</span>
SVG paths sourced from Simple Icons (simpleicons.org) - CC0 1.0 Universal
All icons render at 24x24 viewBox with fill="currentColor"
Brand colors from official brand guidelines
#}
{# Returns Tailwind color classes for an icon's brand color (light + dark) #}
{% macro socialIconColorClass(name) %}
{%- if name == "activitypub" -%}text-[#f1027e]
{%- elif name == "github" -%}text-[#181717] dark:text-[#e6edf3]
{%- elif name == "gitlab" -%}text-[#FC6D26]
{%- elif name == "forgejo" -%}text-[#609926]
{%- elif name == "codeberg" -%}text-[#2185D0]
{%- elif name == "mastodon" -%}text-[#6364FF]
{%- elif name == "bluesky" -%}text-[#0085FF]
{%- elif name == "pixelfed" -%}text-[#6C42C9]
{%- elif name == "linkedin" -%}text-[#0A66C2]
{%- elif name == "twitter" -%}text-[#000000] dark:text-[#e7e9ea]
{%- elif name == "threads" -%}text-[#000000] dark:text-[#f5f5f5]
{%- elif name == "youtube" -%}text-[#FF0000]
{%- elif name == "twitch" -%}text-[#9146FF]
{%- elif name == "spotify" -%}text-[#1DB954]
{%- elif name == "bandcamp" -%}text-[#629aa9]
{%- elif name == "soundcloud" -%}text-[#FF5500]
{%- elif name == "rss" -%}text-[#F26522]
{%- elif name == "discord" -%}text-[#5865F2]
{%- elif name == "signal" -%}text-[#3A76F0]
{%- elif name == "telegram" -%}text-[#26A5E4]
{%- elif name == "matrix" -%}text-[#000000] dark:text-[#e6e6e6]
{%- elif name == "reddit" -%}text-[#FF4500]
{%- elif name == "hackernews" -%}text-[#FF6600]
{%- elif name == "funkwhale" -%}text-[#0D47A1]
{%- elif name == "lastfm" -%}text-[#D51007]
{%- elif name == "peertube" -%}text-[#F1680D]
{%- elif name == "bookwyrm" -%}text-[#002200] dark:text-[#78b578]
{%- elif name == "indieweb" -%}text-[#FF5C00]
{%- elif name == "email" -%}text-surface-600 dark:text-surface-400
{%- elif name == "website" -%}text-surface-600 dark:text-surface-400
{%- elif name == "keybase" -%}text-[#33A0FF]
{%- elif name == "orcid" -%}text-[#A6CE39]
{%- elif name == "flickr" -%}text-[#0063DC]
{%- elif name == "xmpp" -%}text-[#002B5C] dark:text-[#5badff]
{%- elif name == "sourcehut" -%}text-[#000000] dark:text-[#e0e0e0]
{%- elif name == "facebook" -%}text-[#0866FF]
{%- elif name == "instagram" -%}text-[#E4405F]
{%- else -%}text-surface-600 dark:text-surface-400
{%- endif -%}
{% endmacro %}
{% macro socialIcon(name, cssClass) %}
{%- if name == "github" -%}
<svg class="{{ cssClass }}" 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 name == "gitlab" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="m23.6 9.593-.033-.086L20.3.98a.851.851 0 0 0-.336-.382.859.859 0 0 0-.996.06.858.858 0 0 0-.285.398l-2.212 6.777H7.53L5.317 1.056a.857.857 0 0 0-.285-.398.86.86 0 0 0-.997-.06.854.854 0 0 0-.335.382L.433 9.502l-.032.09a6.062 6.062 0 0 0 2.012 7.003l.01.008.028.02 4.984 3.73 2.466 1.866 1.502 1.135a1.012 1.012 0 0 0 1.22 0l1.503-1.135 2.465-1.866 5.012-3.75.013-.01a6.065 6.065 0 0 0 2.005-6.998z"></path></svg>
{%- elif name == "forgejo" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M16.7773 0c1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.1205 0-2.093-.6354-2.5764-1.5652H12.58c-1.7857 0-3.2323 1.4466-3.2323 3.2324v2.3813a6.1532 6.1532 0 0 1 3.2323-.9143h1.6209c.4834-.9298 1.456-1.5652 2.5764-1.5652 1.6018 0 2.9004 1.2986 2.9004 2.9004 0 1.6019-1.2986 2.9005-2.9004 2.9005-1.1205 0-2.093-.6354-2.5764-1.5653H12.58a3.2331 3.2331 0 0 0-3.2323 3.2324v.4648a2.9004 2.9004 0 1 1-1.7704 0v-7.499a3.222 3.222 0 0 0-.4747-1.6674A2.8932 2.8932 0 0 1 4.3223 2.9005C4.3223 1.2986 5.621 0 7.2228 0c1.6019 0 2.9004 1.2986 2.9004 2.9005 0 1.1303-.6474 2.1101-1.5908 2.588a3.232 3.232 0 0 0 1.0156.2771V5.801h2.0323c.4834-.9298 1.456-1.5652 2.5764-1.5652h1.6209C16.2597.6354 17.2323 0 18.3528 0zM7.2228 1.1302a1.7703 1.7703 0 1 0 0 3.5406 1.7703 1.7703 0 0 0 0-3.5406zm9.5545 0a1.7703 1.7703 0 1 0 0 3.5406 1.7703 1.7703 0 0 0 0-3.5406zm0 6.2389a1.7703 1.7703 0 1 0 0 3.5406 1.7703 1.7703 0 0 0 0-3.5406zM8.1079 19.329a1.7703 1.7703 0 1 0-1.7703 1.7703A1.7703 1.7703 0 0 0 8.108 19.329z"></path></svg>
{%- elif name == "codeberg" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M11.955.49A12 12 0 0 0 0 12.49a12 12 0 0 0 1.832 6.373L11.838 5.928a.187.187 0 0 1 .324 0l10.006 12.935A12 12 0 0 0 24 12.49a12 12 0 0 0-12-12 12 12 0 0 0-.045 0zm.375 6.467 4.416 16.553a12 12 0 0 0 5.137-4.213z"></path></svg>
{%- elif name == "sourcehut" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm0 2.4c5.304 0 9.6 4.296 9.6 9.6s-4.296 9.6-9.6 9.6S2.4 17.304 2.4 12 6.696 2.4 12 2.4zm0 1.872A7.728 7.728 0 0 0 4.272 12 7.728 7.728 0 0 0 12 19.728 7.728 7.728 0 0 0 19.728 12 7.728 7.728 0 0 0 12 4.272z"></path></svg>
{%- elif name == "linkedin" -%}
<svg class="{{ cssClass }}" 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 name == "bluesky" -%}
<svg class="{{ cssClass }}" 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 name == "mastodon" -%}
<svg class="{{ cssClass }}" 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>
{%- elif name == "activitypub" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M13.09 4.43L24 10.73v2.51L13.09 19.58v-2.51L21.83 12 13.09 6.98v-2.55zM13.09 9.49L17.44 12l-4.35 2.51V9.49z"/><path d="M10.91 4.43L0 10.73v2.51l8.74-5.03v10.09l2.18 1.28V4.43zM6.56 12L2.18 14.51l4.35 2.51V12z"/></svg>
{%- elif name == "pixelfed" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 14.842c-.46 2.838-3.074 4.742-5.912 4.282a5.15 5.15 0 0 1-1.297-.416v1.55a.615.615 0 0 1-.615.615H7.363a.615.615 0 0 1-.615-.615V8.435a.615.615 0 0 1 .615-.615h2.707a.615.615 0 0 1 .615.615v.34a5.15 5.15 0 0 1 1.297-.416c2.838-.46 5.452 1.444 5.912 4.282a5.152 5.152 0 0 1 0 2.201zm-4.037-.474a2.36 2.36 0 1 0-.742-4.662 2.36 2.36 0 0 0 .742 4.662z"></path></svg>
{%- elif name == "twitter" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"></path></svg>
{%- elif name == "facebook" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 1.09.044 1.613.115V7.98c-.164-.018-.46-.027-.824-.027-1.171 0-1.623.443-1.623 1.596v2.495h2.332l-.4 3.667h-1.932v7.98z"></path></svg>
{%- elif name == "instagram" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.668 1.0745-1.3364 1.3803-2.1272.2954-.7642.4957-1.6362.552-2.9141.0564-1.2776.0689-1.6882.0626-4.9473-.0062-3.2586-.02-3.6672-.0826-4.9473-.0607-1.2767-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1208 16.9244.0645 15.6471.0083 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.56-.2177-.96-.4774-1.3802-.8952-.4178-.4178-.6774-.8186-.8953-1.378-.1625-.4234-.3567-1.0587-.408-2.2282-.0553-1.2654-.0678-1.6455-.0694-4.851-.0015-3.2053.0104-3.5854.0626-4.852.051-1.17.2453-1.8053.408-2.2287.218-.5606.4774-.9599.8952-1.3802.4178-.4178.8186-.6774 1.378-.8952.4235-.1625 1.0588-.3567 2.2282-.408 1.2654-.0554 1.6456-.068 4.8513-.0694 3.2053-.0016 3.5854.0104 4.8519.0626 1.1696.051 1.8053.2452 2.2282.408.5609.218.96.4774 1.3807.8952.4178.4179.6774.8186.8952 1.3782.163.4234.357 1.0587.408 2.2282.0554 1.2654.0679 1.6456.0695 4.852.0015 3.2053-.0104 3.5854-.0626 4.852-.0512 1.17-.2454 1.8053-.4081 2.2288-.2177.5605-.4773.96-.8952 1.3802-.4178.4178-.8185.6774-1.3781.8952-.4235.1625-1.0588.3566-2.2283.408-1.2654.0553-1.6455.068-4.8512.0694-3.2057.0015-3.5858-.0104-4.852-.0627"></path></svg>
{%- elif name == "threads" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.59 12c.025 3.083.718 5.496 2.057 7.164 1.432 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.96-.065-1.187.408-2.264 1.332-3.03.793-.657 1.858-1.054 3.166-1.18.938-.09 1.864-.035 2.773.162-.034-.75-.173-1.355-.414-1.814-.34-.645-.93-1.007-1.753-1.075-.683-.057-1.324.073-1.758.357a1.75 1.75 0 0 0-.612.594l-1.826-1.03c.397-.66 1.002-1.195 1.752-1.547.95-.446 2.09-.636 3.168-.527 1.422.149 2.534.72 3.305 1.699.637.81.988 1.86 1.053 3.13.365.194.706.414 1.02.66 1.183.93 2.04 2.2 2.48 3.686.627 2.128.445 4.582-1.265 6.278-1.845 1.83-4.175 2.59-7.33 2.61z"></path></svg>
{%- elif name == "youtube" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><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"></path></svg>
{%- elif name == "twitch" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z"></path></svg>
{%- elif name == "flickr" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M0 12c0-3.314 2.686-6 6-6s6 2.686 6 6-2.686 6-6 6-6-2.686-6-6zm12 0c0-3.314 2.686-6 6-6s6 2.686 6 6-2.686 6-6 6-6-2.686-6-6z"></path></svg>
{%- elif name == "spotify" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"></path></svg>
{%- elif name == "bandcamp" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M0 18.75l7.437-13.5H24l-7.438 13.5z"></path></svg>
{%- elif name == "soundcloud" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M1.175 12.225c-.051 0-.094.046-.101.1l-.233 2.154.233 2.105c.007.058.05.098.101.098.05 0 .09-.04.099-.098l.255-2.105-.27-2.154c-.009-.06-.05-.1-.1-.1m-.899.828c-.06 0-.091.037-.104.094L0 14.479l.172 1.308c.013.06.045.094.104.094.057 0 .09-.037.104-.093l.2-1.31-.2-1.327c-.015-.06-.047-.096-.104-.096m1.79-1.29c-.065 0-.109.048-.116.109l-.222 2.6.222 2.507c.007.065.051.107.116.107s.109-.042.116-.107l.253-2.507-.253-2.6c-.007-.065-.051-.109-.116-.109m.9-.435c-.073 0-.121.05-.129.119l-.21 3.034.21 2.768c.008.073.056.12.13.12.072 0 .12-.047.128-.12l.237-2.768-.237-3.034c-.008-.073-.056-.12-.128-.12m.903-.19c-.082 0-.133.056-.14.134l-.2 3.225.2 2.882c.008.081.06.136.14.136.077 0 .131-.055.139-.136l.228-2.882-.228-3.225c-.008-.082-.062-.134-.14-.134m.895-.155c-.09 0-.145.063-.15.15l-.193 3.38.193 2.962c.006.09.06.152.15.152.09 0 .147-.063.152-.152l.217-2.962-.217-3.38c-.005-.09-.062-.15-.152-.15m.905-.13c-.098 0-.155.068-.16.163l-.183 3.51.183 3.015c.005.098.062.163.16.163.096 0 .156-.065.16-.163l.207-3.015-.207-3.51c-.004-.098-.064-.163-.16-.163m.902-.104c-.105 0-.167.073-.171.176l-.172 3.614.172 3.049c.004.105.066.176.17.176.107 0 .169-.071.175-.176l.194-3.049-.194-3.614c-.006-.105-.068-.176-.175-.176m.912-.062c-.112 0-.176.08-.181.19l-.163 3.677.163 3.065c.005.112.07.189.18.189.112 0 .177-.077.184-.189l.183-3.065-.183-3.677c-.007-.112-.072-.19-.184-.19m.907-.042c-.12 0-.186.087-.191.203l-.153 3.719.153 3.073c.005.12.071.203.19.203.12 0 .189-.083.194-.203l.175-3.073-.174-3.719c-.006-.12-.074-.203-.194-.203m5.068 1.145c-.598 0-1.157.164-1.638.449-.263-2.96-2.782-5.27-5.834-5.27-.59 0-1.163.097-1.697.269-.211.068-.267.137-.267.27v10.379c0 .14.098.252.232.268h9.204c1.645 0 2.977-1.328 2.977-2.97.002-1.643-1.33-2.97-2.977-2.97"></path></svg>
{%- elif name == "rss" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M19.199 24C19.199 13.467 10.533 4.8 0 4.8V0c13.165 0 24 10.835 24 24h-4.801zM3.291 17.415c1.814 0 3.293 1.479 3.293 3.295 0 1.813-1.485 3.29-3.301 3.29C1.47 24 0 22.526 0 20.71s1.475-3.294 3.291-3.295zM15.909 24h-4.665c0-6.169-5.075-11.245-11.244-11.245V8.09c8.727 0 15.909 7.184 15.909 15.91z"></path></svg>
{%- elif name == "matrix" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.488.32.45.214.773.553.96 1.016.293-.382.673-.717 1.14-1.006.468-.288.986-.432 1.548-.432.425 0 .819.07 1.182.21.365.14.675.363.93.662.256.3.456.67.6 1.11.144.445.216.98.216 1.61v6.33h-2.07v-5.45c0-.392-.015-.745-.048-1.054a2.078 2.078 0 0 0-.225-.76 1.096 1.096 0 0 0-.486-.46c-.212-.1-.49-.155-.836-.155-.346 0-.628.07-.848.21-.22.143-.39.328-.513.555a2.252 2.252 0 0 0-.268.735 5.013 5.013 0 0 0-.072.85v5.53h-2.07v-5.27c0-.384-.008-.742-.027-1.073a2.354 2.354 0 0 0-.182-.787c-.105-.238-.268-.418-.486-.548-.22-.128-.508-.195-.868-.195-.138 0-.318.04-.54.12a1.663 1.663 0 0 0-.58.375 2.04 2.04 0 0 0-.46.66c-.12.27-.18.607-.18 1.013v5.705h-2.07V7.81zm16.045 15.64V.55H22.05V0H24v24h-2.28v-.55z"></path></svg>
{%- elif name == "discord" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"></path></svg>
{%- elif name == "signal" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 0C5.373 0 0 5.373 0 12c0 2.917 1.04 5.59 2.77 7.67l-.93 3.41 3.52-.93A11.95 11.95 0 0 0 12 24c6.627 0 12-5.373 12-12S18.627 0 12 0zm5.95 16.77c-.247.694-1.424 1.282-1.985 1.342-.506.054-1.14.076-1.84-.115a16.86 16.86 0 0 1-1.666-.615c-2.932-1.265-4.847-4.222-4.994-4.418-.147-.196-1.2-1.596-1.2-3.044 0-1.449.759-2.161 1.028-2.457.269-.296.586-.37.782-.37s.391.004.562.01c.18.008.423-.069.661.504.247.593.838 2.05.912 2.198.073.148.122.32.024.517-.098.196-.147.32-.294.492-.147.172-.31.385-.443.516-.147.148-.3.308-.13.604.173.296.767 1.266 1.648 2.05 1.131.408 1.987.752 2.273.836.286.084.453.05.619-.03.167-.08.712-.412.812-.812s.2-.742.133-.812c-.068-.07-.25-.137-.524-.275s-1.622-.8-1.874-.892c-.252-.09-.436-.136-.619.137-.184.272-.712.892-.872 1.074-.16.182-.321.204-.595.068-.274-.136-1.157-.426-2.204-1.36-.814-.726-1.364-1.623-1.524-1.896-.16-.272-.017-.42.12-.555.123-.121.274-.316.412-.474.137-.159.183-.272.274-.454.091-.181.046-.34-.023-.476-.068-.136-.619-1.492-.849-2.042-.224-.537-.45-.464-.619-.473-.16-.007-.343-.01-.526-.01z"></path></svg>
{%- elif name == "telegram" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"></path></svg>
{%- elif name == "xmpp" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M3.753 3.094c.006 1.258.106 3.48.906 5.988.467 1.462 1.237 2.944 2.059 4.281C5.591 15.05 4.237 16.972 3.481 18.9c-.8 2.039-1.058 3.726-1.097 5.006.5-.12.985-.274 1.46-.447.05-1.08.326-2.486.964-4.06.544-1.342 1.45-2.79 2.434-4.152.452.594.916 1.166 1.39 1.674 1.397 1.503 2.862 2.58 4.368 3.32 1.506-.74 2.971-1.817 4.368-3.32.474-.508.938-1.08 1.39-1.674.984 1.362 1.89 2.81 2.434 4.152.638 1.574.914 2.98.964 4.06.475.173.96.326 1.46.448-.039-1.28-.298-2.968-1.097-5.007-.756-1.928-2.11-3.85-3.237-5.537.822-1.337 1.592-2.82 2.059-4.28.8-2.509.9-4.731.906-5.989a17.2 17.2 0 0 0-1.494.518c-.017 1.254-.203 3.09-.84 5.088-.395 1.24-1.07 2.539-1.79 3.759a26 26 0 0 0-1.39-1.75C14.39 9.494 13.095 8.2 12 7.47c-1.095.73-2.39 2.024-3.52 3.542-.468.626-.934 1.282-1.39 1.75-.72-1.22-1.395-2.52-1.79-3.759-.637-1.999-.823-3.834-.84-5.088A17.2 17.2 0 0 0 2.966 3.4c.265-.1.527-.207.787-.306z"></path></svg>
{%- elif name == "reddit" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm5.99 13.915c-.03.412-.18.804-.44 1.122A4.612 4.612 0 0 1 12 18.2a4.612 4.612 0 0 1-5.55-3.163c-.26-.318-.41-.71-.44-1.122a1.834 1.834 0 0 1 1.39-1.863 1.834 1.834 0 0 1 1.803.406 6.07 6.07 0 0 1 2.797-.69 6.07 6.07 0 0 1 2.797.69 1.834 1.834 0 0 1 3.193 1.457zm-8.617 1.29a1.318 1.318 0 1 0 0-2.636 1.318 1.318 0 0 0 0 2.636zm4.053 1.546a3.39 3.39 0 0 1-2.426.773 3.39 3.39 0 0 1-2.426-.773.4.4 0 1 1 .506-.62 2.59 2.59 0 0 0 1.92.592 2.59 2.59 0 0 0 1.92-.592.4.4 0 0 1 .506.62zm.564-1.546a1.318 1.318 0 1 0 0-2.636 1.318 1.318 0 0 0 0 2.636zM16.9 7.833a1.364 1.364 0 1 1 0-2.728 1.364 1.364 0 0 1 0 2.728zm-2.247-.94L12.7 5.2a.658.658 0 0 0-.767-.11l-2.459 1.23a.658.658 0 0 0 .585 1.178l2.092-1.046 1.564 1.377a.658.658 0 0 0 .938-.936Z"></path></svg>
{%- elif name == "hackernews" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M0 24V0h24v24H0zM6.951 5.896l4.112 7.708v5.064h1.583v-4.972l4.148-7.799h-1.749l-2.457 4.875c-.372.745-.688 1.434-.688 1.434s-.297-.708-.651-1.434L8.831 5.896z"></path></svg>
{%- elif name == "keybase" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M10.446 21.371c0 .528-.428.957-.957.957s-.957-.43-.957-.957.428-.957.957-.957.957.43.957.957zm5.922-.957a.958.958 0 0 0-.957.957c0 .528.429.957.957.957s.957-.43.957-.957a.958.958 0 0 0-.957-.957zm-5.922-4.471c-.528 0-.957.43-.957.957s.428.957.957.957.957-.43.957-.957-.428-.957-.957-.957zm5.922 0a.958.958 0 0 0-.957.957c0 .528.429.957.957.957s.957-.43.957-.957a.958.958 0 0 0-.957-.957zm4.79-10.835a3.467 3.467 0 0 0-.64-.047c-1.222 0-2.283.62-2.896 1.583a.958.958 0 0 1-1.643-.683V3.093A3.093 3.093 0 0 0 12.907 0h-.041C10.824.014 9 1.87 9 3.912v2.049a.957.957 0 0 1-1.643.682C6.744 5.58 5.683 4.96 4.461 4.96c-.223 0-.44.018-.649.047A3.463 3.463 0 0 0 .966 8.44c0 .116.005.232.016.347a.958.958 0 0 0 1.907-.194 1.55 1.55 0 0 1-.007-.153c0-.856.7-1.547 1.556-1.547.261 0 .507.064.722.178.428.226.722.67.722 1.178v.57a.96.96 0 0 0 .957.958h.046c.527 0 .955-.428.955-.956v-.572c0-.508.294-.952.722-1.178a1.551 1.551 0 0 1 2.278 1.37v1.381a.958.958 0 0 0 1.915 0V9.44a1.551 1.551 0 0 1 2.278-1.37c.428.226.722.67.722 1.178v.572a.957.957 0 0 0 1.915-.001v-.57c0-.508.295-.952.722-1.178.216-.114.462-.178.722-.178.857 0 1.556.691 1.556 1.547 0 .052-.003.103-.007.153a.958.958 0 0 0 1.907.194c.01-.115.016-.231.016-.347a3.463 3.463 0 0 0-2.846-3.433z"></path></svg>
{%- elif name == "orcid" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 0C5.372 0 0 5.372 0 12s5.372 12 12 12 12-5.372 12-12S18.628 0 12 0zM7.369 4.378c.525 0 .947.431.947.947s-.422.947-.947.947a.95.95 0 0 1-.947-.947c0-.525.422-.947.947-.947zm-.722 3.038h1.444v10.041H6.647V7.416zm3.562 0h3.9c3.712 0 5.344 2.653 5.344 5.025 0 2.578-2.016 5.025-5.325 5.025h-3.919V7.416zm1.444 1.303v7.444h2.297c3.272 0 4.05-2.381 4.05-3.722 0-2.016-1.397-3.722-3.975-3.722h-2.372z"></path></svg>
{%- elif name == "indieweb" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12.766 7.051v3.718h3.372v1.885h-3.372v4.278l3.781-4.278h2.537l-4.908 5.376L19.084 24h-2.59l-3.728-4.337V24H10.77v-4.337L7.042 24H4.452l4.908-5.97-4.908-5.376h2.537l3.781 4.278V9.654H7.398V7.77h3.372V3.42L7.042 7.77H4.452L12.766 0l8.314 7.77h-2.59z"></path></svg>
{%- elif name == "website" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"></path></svg>
{%- elif name == "email" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"></path></svg>
{%- elif name == "funkwhale" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm4.243 16.243a6 6 0 1 1 0-8.486 1 1 0 0 1-1.414 1.414 4 4 0 1 0 0 5.658 1 1 0 0 1 1.414 1.414z"></path></svg>
{%- elif name == "lastfm" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M10.584 17.21l-.88-2.392s-1.43 1.594-3.573 1.594c-1.897 0-3.244-1.649-3.244-4.288 0-3.382 1.704-4.591 3.381-4.591 2.42 0 3.189 1.567 3.849 3.574l.88 2.749c.88 2.666 2.529 4.81 7.284 4.81 3.409 0 5.718-1.044 5.718-3.793 0-2.227-1.265-3.381-3.63-3.931l-1.758-.385c-1.21-.275-1.567-.77-1.567-1.594 0-.935.742-1.484 1.952-1.484 1.32 0 2.034.495 2.144 1.677l2.749-.33c-.22-2.474-1.924-3.492-4.729-3.492-2.474 0-4.893.935-4.893 3.932 0 1.87.907 3.051 3.189 3.601l1.87.44c1.402.33 1.869.825 1.869 1.648 0 1.044-.99 1.484-2.86 1.484-2.776 0-3.932-1.457-4.59-3.464l-.907-2.75c-1.155-3.573-2.997-4.893-6.653-4.893C2.144 5.333 0 7.89 0 12.233c0 4.18 2.144 6.434 5.993 6.434 3.106 0 4.591-1.457 4.591-1.457z"></path></svg>
{%- elif name == "peertube" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 0L1.104 6v12L12 24l10.896-6V6zm0 2.416L20.584 7.5v9L12 21.584 3.416 16.5v-9z"></path><path d="M12 6.832L7.208 9.5v5L12 17.168l4.792-2.668v-5z"></path></svg>
{%- elif name == "bookwyrm" -%}
<svg class="{{ cssClass }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12.01 2.4C8.574 2.4 5.41 3.837 3.86 5.614c.052.052.098.113.138.182L6.76 10.99c.33.57.121 1.298-.45 1.628s-1.298.121-1.628-.45L1.92 6.974c-.019-.032-.033-.065-.048-.098C.714 8.63 0 10.598 0 12.36c0 1.254.332 2.3.977 3.14.62.808 1.52 1.406 2.606 1.808a.478.478 0 0 1 .295.606.478.478 0 0 1-.606.295C1.94 17.7.87 16.976.134 16.018c-.03.167-.044.337-.044.51 0 .774.239 1.473.687 2.031.46.573 1.131 1.002 1.95 1.258a.478.478 0 0 1 .318.597.478.478 0 0 1-.597.318c-.555-.174-1.038-.422-1.445-.733C2.237 21.836 4.937 23 8.27 23c2.662 0 4.836-.659 6.314-1.884l-.135-.04c-.48-.136-.868-.418-1.126-.81a2.008 2.008 0 0 1-.2-1.5l1.162-4.127a2.007 2.007 0 0 1 .966-1.19 2.008 2.008 0 0 1 1.53-.18l.72.203 1.16-4.118a2.008 2.008 0 0 1 .967-1.19 2.008 2.008 0 0 1 1.529-.18l.254.072c-.38-1.318-1.036-2.48-1.94-3.373C16.797 3.012 14.53 2.4 12.01 2.4z"></path></svg>
{%- endif -%}
{% endmacro %}
+206
View File
@@ -0,0 +1,206 @@
{# Webmentions Component #}
{# Displays likes, reposts, and replies for a post #}
{# Also checks legacy URLs from micro.blog and old blog for historical webmentions #}
{# Client-side JS supplements build-time data with real-time fetches #}
{% set mentions = webmentions | webmentionsForUrl(page.url, urlAliases, conversationMentions) %}
{% set absoluteUrl = site.url + page.url %}
{% set buildTimestamp = "" | timestamp %}
{# Data container for client-side JS to fetch new webmentions #}
<div data-webmentions
data-target="{{ absoluteUrl }}"
data-domain="{{ site.webmentions.domain }}"
data-buildtime="{{ buildTimestamp }}"
class="hidden"></div>
{% 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>
<is-land on:visible>
<div class="facepile">
{% for like in likes %}
<a href="{{ like.author.url }}"
class="facepile-avatar"
title="{{ like.author.name }}"
target="_blank"
rel="noopener">
<img
src="{{ like.author.photo or '/images/default-avatar.svg' }}"
alt="{{ like.author.name }}"
class="w-8 h-8 rounded-full ring-2 ring-white dark:ring-surface-900"
loading="lazy"
>
</a>
{% endfor %}
</div>
</is-land>
</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>
<is-land on:visible>
<div class="facepile">
{% for repost in reposts %}
<a href="{{ repost.author.url }}"
class="facepile-avatar"
title="{{ repost.author.name }}"
target="_blank"
rel="noopener">
<img
src="{{ repost.author.photo or '/images/default-avatar.svg' }}"
alt="{{ repost.author.name }}"
class="w-8 h-8 rounded-full ring-2 ring-white dark:ring-surface-900"
loading="lazy"
>
</a>
{% endfor %}
</div>
</is-land>
</div>
{% endif %}
{# Bookmarks #}
{% set bookmarks = mentions | webmentionsByType('bookmarks') %}
{% if bookmarks.length %}
<div class="webmention-bookmarks mb-6">
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">
{{ bookmarks.length }} Bookmark{% if bookmarks.length != 1 %}s{% endif %}
</h3>
<is-land on:visible>
<div class="facepile">
{% for bookmark in bookmarks %}
<a href="{{ bookmark.author.url }}"
class="facepile-avatar"
title="{{ bookmark.author.name }}"
target="_blank"
rel="noopener">
<img
src="{{ bookmark.author.photo or '/images/default-avatar.svg' }}"
alt="{{ bookmark.author.name }}"
class="w-8 h-8 rounded-full ring-2 ring-white dark:ring-surface-900"
loading="lazy"
>
</a>
{% endfor %}
</div>
</is-land>
</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.svg' }}"
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-accent-600 dark:text-accent-400 hover:underline"
target="_blank"
rel="noopener">
{{ mention.author.name }} mentioned this on <time datetime="{{ mention.published }}">{{ mention.published | date("MMM d, yyyy") }}</time>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</section>
{% endif %}
{# Webmention send form — collapsed by default #}
<details class="mt-8">
<summary class="text-sm font-semibold text-surface-600 dark:text-surface-400 cursor-pointer hover:text-surface-700 dark:hover:text-surface-300 list-none [&::-webkit-details-marker]:hidden flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 transition-transform [[open]>&]:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
Send a Webmention
</summary>
<div class="mt-3 p-4 bg-surface-100 dark:bg-surface-800 rounded-lg">
<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-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded focus:outline-none focus:ring-2 focus:ring-accent-500"
>
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-accent-600 hover:bg-accent-700 rounded transition-colors">
Send
</button>
</form>
</div>
</details>
@@ -0,0 +1,30 @@
{# Author Compact Card - h-card microformat (compact version for blog sidebars) #}
<is-land on:visible>
<div class="widget">
<div class="h-card p-author flex items-center gap-3">
{# Hidden u-photo for reliable microformat parsing #}
<data class="u-photo hidden" value="{{ site.author.avatar }}"></data>
<a href="{{ site.author.url }}" class="u-url u-uid" rel="me" itemprop="url">
<img
src="{{ site.author.avatar }}"
alt="{{ site.author.name }}"
class="w-12 h-12 rounded-full object-cover"
loading="lazy"
>
</a>
<div>
<a href="{{ site.author.url }}" class="u-url p-name font-medium text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">
{{ site.author.name }}
</a>
<p class="p-job-title text-xs text-surface-500">{{ site.author.title }}</p>
{% if site.author.locality %}
<p class="p-locality text-xs text-surface-500">{{ site.author.locality }}{% if site.author.country %}, <span class="p-country-name">{{ site.author.country }}</span>{% endif %}</p>
{% endif %}
</div>
</div>
{# Hidden but present for microformat completeness #}
<p class="p-note hidden">{{ site.author.bio }}</p>
{% if site.author.email %}<data class="u-email hidden" value="{{ site.author.email }}"></data>{% endif %}
{% if site.author.org %}<data class="p-org hidden" value="{{ site.author.org }}"></data>{% endif %}
</div>
</is-land>
@@ -0,0 +1,6 @@
{# Author Card Widget - includes the canonical h-card component #}
<is-land on:visible>
<div class="widget">
{% include "components/h-card.njk" %}
</div>
</is-land>
@@ -0,0 +1,110 @@
{# Blogroll Widget - Dynamic loading from API with source tabs #}
<is-land on:visible>
<div class="widget" x-data="blogrollWidget()" x-init="init()">
<h3 class="widget-title flex items-center gap-2">
<svg class="w-5 h-5 text-accent-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/>
</svg>
<a href="/blogroll/" class="hover:text-accent-600 dark:hover:text-accent-400">Blogroll</a>
</h3>
{# Source tabs - only shown when multiple sources exist #}
<div x-show="tabs.length > 1" class="flex gap-1 mt-3 mb-2 border-b border-surface-200 dark:border-surface-700">
<template x-for="tab in tabs" :key="tab.key">
<button
@click="activeTab = tab.key"
:class="activeTab === tab.key
? 'border-b-2 border-accent-600 text-accent-600 dark:text-accent-400 dark:border-accent-400'
: 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-2 py-1 text-xs font-medium transition-colors -mb-px"
x-text="tab.label + ' (' + tab.count + ')'"
></button>
</template>
</div>
<ul x-show="filteredBlogs.length > 0" class="space-y-2" :class="tabs.length > 1 ? '' : 'mt-3'">
<template x-for="blog in filteredBlogs.slice(0, 8)" :key="blog.id">
<li>
<a
:href="blog.siteUrl || blog.feedUrl"
class="flex items-center gap-2 text-sm text-surface-700 dark:text-surface-300 hover:text-accent-600 dark:hover:text-accent-400 transition-colors"
target="_blank"
rel="noopener"
>
<span class="w-5 h-5 rounded bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center flex-shrink-0">
<span class="text-white text-xs font-bold" x-text="blog.title?.charAt(0)?.toUpperCase()"></span>
</span>
<span class="truncate" x-text="blog.title"></span>
</a>
</li>
</template>
</ul>
<div x-show="filteredBlogs.length === 0 && !loading" class="text-sm text-surface-500 py-2">
No blogs loaded yet.
</div>
<a x-show="allBlogs.length > 0" href="/blogroll/" class="text-sm text-accent-600 dark:text-accent-400 hover:underline mt-3 inline-flex items-center gap-1">
View all <span x-text="allBlogs.length"></span> blogs
<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>
<script>
function blogrollWidget() {
return {
allBlogs: [],
activeTab: 'all',
tabs: [],
loading: true,
get filteredBlogs() {
if (this.activeTab === 'all') return this.allBlogs;
return this.allBlogs.filter(b => (b.source || 'other') === this.activeTab);
},
async init() {
try {
const res = await fetch('/blogrollapi/api/blogs?sort=recent&limit=200').then(r => r.json());
this.allBlogs = res.items || [];
this.buildTabs();
} catch (err) {
console.error('Blogroll widget error:', err);
} finally {
this.loading = false;
}
},
buildTabs() {
const counts = {};
for (const blog of this.allBlogs) {
const src = blog.source || 'other';
counts[src] = (counts[src] || 0) + 1;
}
const labels = {
microsub: 'Microsub',
feedland: 'FeedLand',
other: 'Other',
};
const sources = Object.keys(counts);
if (sources.length <= 1) {
this.tabs = [];
this.activeTab = 'all';
return;
}
this.tabs = sources.map(key => ({
key,
label: labels[key] || key,
count: counts[key],
}));
// Default to the first tab
this.activeTab = this.tabs[0].key;
}
};
}
</script>
</is-land>
@@ -0,0 +1,15 @@
{# Categories/Tags Widget #}
{% if categories and categories.length %}
<is-land on:visible>
<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>
</is-land>
{% endif %}
@@ -0,0 +1,38 @@
{# Fediverse Follow Me Widget — uses the fediverseInteract Alpine.js component #}
{# Requires fediverse-interact.js loaded in base.njk (already present) #}
{# Determines actor URI from site social links: prefers self-hosted AP, falls back to Mastodon #}
{% set actorUrl = "" %}
{% for link in site.social %}
{% if link.icon == "activitypub" and not actorUrl %}
{% set actorUrl = link.url %}
{% endif %}
{% endfor %}
{% if not actorUrl %}
{% for link in site.social %}
{% if link.icon == "mastodon" and not actorUrl %}
{% set actorUrl = link.url %}
{% endif %}
{% endfor %}
{% endif %}
{% if actorUrl %}
<is-land on:visible>
<div class="widget" x-data="fediverseInteract('{{ actorUrl }}', 'interact')">
<h3 class="widget-title">Follow Me</h3>
<p class="text-sm text-surface-500 dark:text-surface-400 mb-3">Follow me from your fediverse instance.</p>
<a href="{{ actorUrl }}"
@click="handleClick($event)"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[#a730b8]/10 text-[#a730b8] hover:bg-[#a730b8]/20 transition-colors text-sm font-medium cursor-pointer"
title="Follow from your fediverse instance (Shift+click to change)">
<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="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/>
</svg>
<span>Follow on the Fediverse</span>
</a>
{% set modalTitle = "Follow on the Fediverse" %}
{% set modalDescription = "Choose your instance to follow this account." %}
{% include "components/fediverse-modal.njk" %}
</div>
</is-land>
{% endif %}
@@ -0,0 +1,383 @@
{# FeedLand Widget - Matches Dave Winer's blogroll.js visual rendering #}
{# Uses Alpine.js + blogroll API instead of jQuery + external blogroll.js #}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Rancho&family=Ubuntu:wght@400;700&display=swap" rel="stylesheet">
<style>
.fl-wrap {
border: 1px solid gainsboro;
padding: 5px 10px;
font-family: Ubuntu, sans-serif;
font-size: 15px;
box-sizing: border-box;
}
.fl-wrap:focus {
border-color: rgba(82, 168, 236, 0.8);
outline: 0;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
background-color: white;
}
.fl-title {
font-family: "Rancho", cursive;
font-size: 36px;
font-weight: bold;
letter-spacing: -1px;
text-align: center;
margin: 5px 0;
line-height: 1.1;
}
.fl-title a {
color: inherit;
text-decoration: none;
}
.fl-title a:hover {
text-decoration: underline;
}
.fl-sort {
display: flex;
justify-content: space-between;
font-size: 12px;
line-height: 14px;
padding: 3px 0;
}
.fl-sort span {
cursor: pointer;
}
.fl-sort .selected {
font-weight: bold;
}
.fl-row {
display: flex;
align-items: baseline;
padding: 4px 0;
line-height: normal;
cursor: pointer;
}
.fl-row:hover {
background-color: whitesmoke;
}
.fl-row.fl-selected {
background-color: #e8f0fe;
}
.fl-caret {
width: 14px;
flex-shrink: 0;
padding-right: 4px;
}
.fl-caret-dark { opacity: .9; }
.fl-caret-light { opacity: .2; }
.fl-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fl-name a {
color: inherit;
text-decoration: none;
}
.fl-name a:hover {
text-decoration: underline;
}
.fl-when {
flex-shrink: 0;
font-size: 12px;
padding-left: 6px;
white-space: nowrap;
opacity: 0.7;
}
/* Expanded items */
.fl-items {
padding: 2px 0 4px 18px;
}
.fl-items ul {
list-style: none;
margin: 0;
padding: 0;
}
.fl-items li {
padding: 2px 0;
font-size: 13px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fl-items li a {
color: #1a73e8;
text-decoration: none;
}
.fl-items li a:hover {
text-decoration: underline;
}
.fl-items .fl-item-when {
font-size: 11px;
opacity: 0.6;
margin-left: 4px;
}
.fl-items .fl-loading {
font-size: 12px;
opacity: 0.5;
padding: 4px 0;
}
.fl-footer {
text-align: center;
font-size: 13px;
border-top: 1px solid gainsboro;
margin-top: 13px;
padding-top: 4px;
opacity: 0.5;
}
.fl-footer a {
color: inherit;
text-decoration: none;
}
.fl-footer a:hover {
text-decoration: underline;
}
/* 3-dot menu */
.fl-header {
position: relative;
display: flex;
align-items: flex-start;
}
.fl-header .fl-title {
flex: 1;
}
.fl-menu-btn {
background: none;
border: none;
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 4px 2px;
opacity: 0.5;
color: inherit;
}
.fl-menu-btn:hover {
opacity: 1;
}
.fl-menu {
position: absolute;
right: 0;
top: 100%;
background: white;
border: 1px solid gainsboro;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
z-index: 10;
min-width: 180px;
padding: 4px 0;
font-size: 13px;
}
.fl-menu a {
display: block;
padding: 5px 12px;
color: inherit;
text-decoration: none;
white-space: nowrap;
}
.fl-menu a:hover {
background-color: whitesmoke;
}
.fl-menu hr {
margin: 4px 0;
border: none;
border-top: 1px solid gainsboro;
}
.dark .fl-menu {
background: #2a2a2a;
border-color: #444;
}
.dark .fl-menu a:hover {
background-color: #333;
}
.dark .fl-menu hr {
border-top-color: #444;
}
@media screen and (max-width: 576px) {
.fl-title { display: none; }
.fl-name { font-size: 14px; }
}
/* Dark mode */
.dark .fl-wrap {
border-color: #444;
color: #e0e0e0;
}
.dark .fl-wrap:focus {
background-color: #1a1a1a;
}
.dark .fl-row:hover {
background-color: #2a2a2a;
}
.dark .fl-row.fl-selected {
background-color: #1e3a5f;
}
.dark .fl-items li a {
color: #8ab4f8;
}
.dark .fl-footer {
border-top-color: #444;
}
</style>
<is-land on:visible>
<div class="widget" x-data="feedlandWidget()" x-init="init()">
<div class="fl-wrap" tabindex="0">
{# Title + menu #}
<div class="fl-header">
<div class="fl-title">
<a :href="riverUrl" target="_blank" rel="noopener" x-text="title"></a>
</div>
<button class="fl-menu-btn" @click="menuOpen = !menuOpen" aria-label="Menu">&#x22EE;</button>
<div class="fl-menu" x-show="menuOpen" @click.away="menuOpen = false" x-cloak>
<a href="/blogroll/">Blogroll page</a>
<a href="/blogrollapi/api/opml" target="_blank" rel="noopener">View as OPML</a>
<hr>
<a :href="riverUrl" target="_blank" rel="noopener">View in FeedLand</a>
</div>
</div>
{# Sort links #}
<div class="fl-sort">
<span :class="sortBy === 'title' ? 'selected' : ''" @click="sortBy = 'title'">Title</span>
<span :class="sortBy === 'when' ? 'selected' : ''" @click="sortBy = 'when'">When</span>
</div>
{# Feed list — pure divs, no table #}
<template x-for="blog in sortedBlogs" :key="blog.id">
<div>
<div class="fl-row"
:class="selectedId === blog.id ? 'fl-selected' : ''"
@click="handleRowClick(blog)">
<span class="fl-caret"
:class="expandedId === blog.id ? 'fl-caret-dark' : (selectedId === blog.id ? 'fl-caret-dark' : 'fl-caret-light')"
x-text="expandedId === blog.id ? '\u25BC' : '\u25B6'"
@click.stop="toggleExpand(blog)"></span>
<span class="fl-name">
<a :href="blog.siteUrl || blog.feedUrl" target="_blank" rel="noopener"
x-text="blog.title" @click.stop></a>
</span>
<span class="fl-when" x-text="relativeTime(blog.lastItemAt || blog.lastFetchAt)"></span>
</div>
{# Expanded items #}
<div class="fl-items" x-show="expandedId === blog.id" x-collapse>
<template x-if="blog._loadingItems">
<div class="fl-loading">Loading…</div>
</template>
<template x-if="!blog._loadingItems && blog._items && blog._items.length > 0">
<ul>
<template x-for="item in blog._items" :key="item.id">
<li>
<a :href="item.url" target="_blank" rel="noopener"
x-text="truncate(item.title || item.summary || item.url, 80)"></a>
<span class="fl-item-when" x-text="relativeTime(item.published)"></span>
</li>
</template>
</ul>
</template>
<template x-if="!blog._loadingItems && (!blog._items || blog._items.length === 0)">
<div class="fl-loading">No recent items</div>
</template>
</div>
</div>
</template>
{# Footer #}
<div class="fl-footer">
<a :href="riverUrl" target="_blank" rel="noopener">Powered by FeedLand</a>
</div>
</div>
</div>
<script>
function feedlandWidget() {
return {
blogs: [],
sortBy: 'when',
title: 'FeedLand',
riverUrl: 'https://feedland.com',
loading: true,
selectedId: null,
expandedId: null,
menuOpen: false,
get sortedBlogs() {
const sorted = [...this.blogs];
if (this.sortBy === 'title') {
sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
} else {
sorted.sort((a, b) => {
const da = new Date(a.lastItemAt || a.lastFetchAt || 0);
const db = new Date(b.lastItemAt || b.lastFetchAt || 0);
return db - da;
});
}
return sorted;
},
handleRowClick(blog) {
if (this.selectedId !== blog.id) {
this.selectedId = blog.id;
} else {
this.toggleExpand(blog);
}
},
async toggleExpand(blog) {
this.selectedId = blog.id;
if (this.expandedId === blog.id) {
this.expandedId = null;
return;
}
this.expandedId = blog.id;
if (!blog._items) {
blog._loadingItems = true;
try {
const res = await fetch('/blogrollapi/api/blogs/' + blog.id);
const data = await res.json();
blog._items = (data.items || []).slice(0, 5);
} catch (err) {
console.error('FeedLand: failed to load items for', blog.title, err);
blog._items = [];
} finally {
blog._loadingItems = false;
}
}
},
truncate(str, max) {
if (!str) return '';
return str.length > max ? str.slice(0, max) + '…' : str;
},
relativeTime(iso) {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return mins + 'm';
const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs + 'h';
const days = Math.floor(hrs / 24);
return days + 'd';
},
async init() {
try {
const res = await fetch('/blogrollapi/api/blogs?source=feedland&sort=recent&limit=100');
const data = await res.json();
this.blogs = (data.items || []).map(b => ({
...b,
_items: null,
_loadingItems: false,
}));
} catch (err) {
console.error('FeedLand widget error:', err);
} finally {
this.loading = false;
}
}
};
}
</script>
</is-land>
@@ -0,0 +1,115 @@
{# Listening Widget — combined Funkwhale + Last.fm recent tracks #}
{% set hasListening = (funkwhaleActivity and (funkwhaleActivity.nowPlaying or funkwhaleActivity.listenings.length)) or (lastfmActivity and (lastfmActivity.nowPlaying or lastfmActivity.scrobbles.length)) %}
{% if hasListening %}
<is-land on:visible>
<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 — show if either source is actively playing #}
{% set fwNow = funkwhaleActivity.nowPlaying if funkwhaleActivity and funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.status == 'now-playing' else null %}
{% set lfmNow = lastfmActivity.nowPlaying if lastfmActivity and lastfmActivity.nowPlaying and lastfmActivity.nowPlaying.status == 'now-playing' else null %}
{% if fwNow or lfmNow %}
{% set np = fwNow or lfmNow %}
{% set npSource = "Funkwhale" if fwNow else "Last.fm" %}
{% set npColor = "purple" if fwNow else "red" %}
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 mb-3">
<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
<span class="text-{{ npColor }}-600 dark:text-{{ npColor }}-400 ml-1">({{ npSource }})</span>
</div>
<div class="flex items-center gap-3">
{% if np.coverUrl %}
<img src="{{ np.coverUrl }}" alt="" class="w-10 h-10 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore>
{% endif %}
<div class="min-w-0 flex-1">
<p class="font-medium text-sm text-surface-900 dark:text-surface-100 truncate">
{% if np.trackUrl %}
<a href="{{ np.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ np.track }}</a>
{% else %}
{{ np.track }}
{% endif %}
</p>
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ np.artist }}</p>
</div>
</div>
</div>
{% endif %}
{# Recent tracks — 2 from each source #}
<ul class="space-y-2">
{% if funkwhaleActivity and funkwhaleActivity.listenings.length %}
{% for listening in funkwhaleActivity.listenings | head(2) %}
<li class="flex items-center gap-2">
{% if listening.coverUrl %}
<img src="{{ listening.coverUrl }}" alt="" class="w-8 h-8 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore>
{% else %}
<div class="w-8 h-8 rounded bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13"/>
</svg>
</div>
{% endif %}
<div class="min-w-0 flex-1">
<p class="text-sm text-surface-900 dark:text-surface-100 truncate">
{% if listening.trackUrl %}
<a href="{{ listening.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ listening.track }}</a>
{% else %}
{{ listening.track }}
{% endif %}
</p>
<p class="text-xs text-surface-500 truncate">{{ listening.artist }}
<span class="text-purple-500 ml-1">Funkwhale</span>
</p>
</div>
</li>
{% endfor %}
{% endif %}
{% if lastfmActivity and lastfmActivity.scrobbles.length %}
{% for scrobble in lastfmActivity.scrobbles | head(2) %}
<li class="flex items-center gap-2">
{% if scrobble.coverUrl %}
<img src="{{ scrobble.coverUrl }}" alt="" class="w-8 h-8 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore>
{% else %}
<div class="w-8 h-8 rounded bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13"/>
</svg>
</div>
{% endif %}
<div class="min-w-0 flex-1">
<p class="text-sm text-surface-900 dark:text-surface-100 truncate">
{% if scrobble.trackUrl %}
<a href="{{ scrobble.trackUrl }}" class="hover:text-red-600 dark:hover:text-red-400" target="_blank" rel="noopener">{{ scrobble.track }}</a>
{% else %}
{{ scrobble.track }}
{% endif %}
{% if scrobble.loved %}<span class="text-red-500 ml-0.5">&#9829;</span>{% endif %}
</p>
<p class="text-xs text-surface-500 truncate">{{ scrobble.artist }}
<span class="text-red-500 ml-1">Last.fm</span>
</p>
</div>
</li>
{% endfor %}
{% endif %}
</ul>
<a href="/listening/" class="text-sm text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1 mt-3">
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>
</is-land>
{% endif %}
@@ -0,0 +1,213 @@
{# GitHub Activity Widget - Tabbed Commits/Repos/Featured/PRs with live API data #}
<is-land on:visible>
<div class="widget" x-data="githubWidget('{{ site.feeds.github }}')" x-init="init()">
<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
</h3>
{# Tab buttons — order: Commits, Repos, Featured, PRs #}
<div class="flex gap-1 mb-4 border-b border-surface-200 dark:border-surface-700">
<button
@click="activeTab = 'commits'"
:class="activeTab === 'commits' ? 'border-b-2 border-accent-500 text-accent-600 dark:text-accent-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="flex items-center gap-1.5 px-2 py-2 text-xs font-medium transition-colors -mb-px"
>
Commits
</button>
<button
@click="activeTab = 'repos'"
:class="activeTab === 'repos' ? 'border-b-2 border-accent-500 text-accent-600 dark:text-accent-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="flex items-center gap-1.5 px-2 py-2 text-xs font-medium transition-colors -mb-px"
>
Repos
</button>
<button
@click="activeTab = 'featured'"
:class="activeTab === 'featured' ? 'border-b-2 border-accent-500 text-accent-600 dark:text-accent-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="flex items-center gap-1.5 px-2 py-2 text-xs font-medium transition-colors -mb-px"
>
Featured
</button>
<button
@click="activeTab = 'prs'"
:class="activeTab === 'prs' ? 'border-b-2 border-accent-500 text-accent-600 dark:text-accent-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="flex items-center gap-1.5 px-2 py-2 text-xs font-medium transition-colors -mb-px"
>
PRs
</button>
</div>
{# Tab content — fixed height container to prevent layout shift #}
<div class="h-[420px] overflow-y-auto">
{# Loading state #}
<div x-show="loading" class="text-sm text-surface-500 py-4 text-center">
Loading...
</div>
{# Commits Tab #}
<div x-show="activeTab === 'commits' && !loading" x-cloak>
<ul x-show="commits.length > 0" class="space-y-3">
<template x-for="commit in commits.slice(0, 5)" :key="commit.sha">
<li class="border-b border-surface-200 dark:border-surface-700 pb-3 last:border-0">
<a :href="commit.url" target="_blank" rel="noopener" class="block group">
<p class="text-sm text-surface-700 dark:text-surface-300 group-hover:text-surface-900 dark:group-hover:text-surface-100 transition-colors line-clamp-2" x-text="commit.message"></p>
<div class="flex items-center gap-2 mt-1.5 text-xs text-surface-500">
<code class="text-xs font-mono bg-surface-100 dark:bg-surface-800 px-1 py-0.5 rounded" x-text="commit.sha"></code>
<span class="truncate" x-text="commit.repo?.split('/')[1] || commit.repo"></span>
<span x-text="formatDate(commit.date)"></span>
</div>
</a>
</li>
</template>
</ul>
<div x-show="commits.length === 0" class="text-sm text-surface-500 py-2">No recent commits.</div>
</div>
{# Repos Tab #}
<div x-show="activeTab === 'repos' && !loading" x-cloak>
<ul x-show="repos.length > 0" class="space-y-3">
<template x-for="repo in repos.slice(0, 5)" :key="repo.name">
<li class="border-b border-surface-200 dark:border-surface-700 pb-3 last:border-0">
<a :href="repo.html_url" target="_blank" rel="noopener" class="block group">
<div class="flex items-center gap-2">
<span class="font-medium text-sm text-surface-700 dark:text-surface-300 group-hover:underline truncate" x-text="repo.name"></span>
<span x-show="repo.language" class="text-xs px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-400 flex-shrink-0" x-text="repo.language"></span>
</div>
<p x-show="repo.description" class="text-xs text-surface-600 dark:text-surface-400 mt-1 line-clamp-2" x-text="repo.description"></p>
<div class="flex items-center gap-3 mt-1.5 text-xs text-surface-500">
<span x-show="repo.stargazers_count > 0" 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>
<span x-text="repo.stargazers_count"></span>
</span>
<span x-text="formatDate(repo.updated_at)"></span>
</div>
</a>
</li>
</template>
</ul>
<div x-show="repos.length === 0" class="text-sm text-surface-500 py-2">No repositories found.</div>
</div>
{# Featured Tab #}
<div x-show="activeTab === 'featured' && !loading" x-cloak>
<ul x-show="featured.length > 0" class="space-y-3">
<template x-for="repo in featured.slice(0, 5)" :key="repo.fullName || repo.name">
<li class="border-b border-surface-200 dark:border-surface-700 pb-3 last:border-0">
<a :href="repo.url" target="_blank" rel="noopener" class="block group">
<div class="flex items-center gap-2">
<span class="font-medium text-sm text-surface-700 dark:text-surface-300 group-hover:underline truncate" x-text="repo.name"></span>
<span x-show="repo.language" class="text-xs px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-400 flex-shrink-0" x-text="repo.language"></span>
</div>
<p x-show="repo.description" class="text-xs text-surface-600 dark:text-surface-400 mt-1 line-clamp-2" x-text="repo.description"></p>
<div class="flex items-center gap-3 mt-1.5 text-xs text-surface-500">
<span x-show="repo.stars > 0" 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>
<span x-text="repo.stars"></span>
</span>
<span x-show="repo.forks > 0" class="flex items-center gap-1">
<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="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"/></svg>
<span x-text="repo.forks"></span>
</span>
</div>
</a>
</li>
</template>
</ul>
<div x-show="featured.length === 0" class="text-sm text-surface-500 py-2">No featured projects.</div>
</div>
{# PRs Tab #}
<div x-show="activeTab === 'prs' && !loading" x-cloak>
<ul x-show="contributions.length > 0" class="space-y-3">
<template x-for="item in contributions.slice(0, 5)" :key="item.url">
<li class="border-b border-surface-200 dark:border-surface-700 pb-3 last:border-0">
<a :href="item.url" target="_blank" rel="noopener" class="block group">
<div class="flex items-center gap-2">
<span
class="flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center"
:class="item.type === 'pr' ? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400' : 'bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-400'"
>
<svg x-show="item.type === 'pr'" 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="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>
<svg x-show="item.type === 'issue'" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke-width="2"/><line x1="12" y1="8" x2="12" y2="12" stroke-width="2"/><line x1="12" y1="16" x2="12.01" y2="16" stroke-width="2"/></svg>
</span>
<span class="text-sm text-surface-700 dark:text-surface-300 group-hover:text-surface-900 dark:group-hover:text-surface-100 transition-colors truncate" x-text="item.title"></span>
</div>
<div class="flex items-center gap-2 mt-1.5 text-xs text-surface-500 pl-6">
<span x-text="item.repo?.split('/')[1] || item.repo"></span>
<span x-show="item.number" x-text="'#' + item.number"></span>
<span x-text="formatDate(item.date)"></span>
</div>
</a>
</li>
</template>
</ul>
<div x-show="contributions.length === 0" class="text-sm text-surface-500 py-2">No recent PRs or issues.</div>
</div>
</div>
{# Footer link #}
{% if site.feeds.github %}
<a href="https://github.com/{{ site.feeds.github }}" target="_blank" rel="noopener" class="text-sm text-accent-600 dark:text-accent-400 hover:underline mt-3 inline-flex items-center gap-1">
View on GitHub
<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>
{% endif %}
</div>
<script>
function githubWidget(username) {
return {
activeTab: 'commits',
loading: true,
commits: [],
repos: [],
featured: [],
contributions: [],
async init() {
try {
const fetches = [
fetch('/githubapi/api/commits').then(r => r.ok ? r.json() : null).catch(() => null),
fetch('/githubapi/api/featured').then(r => r.ok ? r.json() : null).catch(() => null),
fetch('/githubapi/api/contributions').then(r => r.ok ? r.json() : null).catch(() => null),
];
if (username) {
fetches.push(
fetch('https://api.github.com/users/' + username + '/repos?sort=updated&per_page=10&type=owner', {
headers: { 'Accept': 'application/vnd.github.v3+json' }
}).then(r => r.ok ? r.json() : null).catch(() => null)
);
}
const [commitsRes, featuredRes, contribRes, reposRes] = await Promise.all(fetches);
this.commits = commitsRes?.commits || [];
this.featured = featuredRes?.featured || [];
this.contributions = contribRes?.contributions || [];
this.repos = (reposRes || []).filter(r => !r.fork && !r.private);
} catch (err) {
console.error('GitHub widget error:', err);
} finally {
this.loading = false;
}
},
formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
const now = new Date();
const diffMs = now - d;
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffHours < 1) return 'just now';
if (diffHours < 24) return diffHours + 'h ago';
if (diffDays < 7) return diffDays + 'd ago';
return d.toLocaleDateString('en', { month: 'short', day: 'numeric' });
}
};
}
</script>
</is-land>
@@ -0,0 +1,21 @@
{# Categories for This Post #}
{% if category %}
<is-land on:visible>
<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-accent-100 dark:bg-accent-900 text-accent-700 dark:text-accent-300 rounded-full hover:bg-accent-200 dark:hover:bg-accent-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-accent-100 dark:bg-accent-900 text-accent-700 dark:text-accent-300 rounded-full hover:bg-accent-200 dark:hover:bg-accent-800 transition-colors">
{{ cat }}
</a>
{% endfor %}
{% endif %}
</div>
</div>
</is-land>
{% endif %}
@@ -0,0 +1,66 @@
{# Post Navigation Widget - Previous/Next #}
{# Uses previousInCollection/nextInCollection filters to find adjacent posts #}
{% set _prevPost = collections.posts | previousInCollection(page) %}
{% set _nextPost = collections.posts | nextInCollection(page) %}
{% if _prevPost or _nextPost %}
<is-land on:visible>
<div class="widget">
<h3 class="widget-title">More Posts</h3>
<div class="space-y-3">
{% if _prevPost %}
<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>
{% set _likedUrl = _prevPost.data.likeOf or _prevPost.data.like_of %}
{% set _bookmarkedUrl = _prevPost.data.bookmarkOf or _prevPost.data.bookmark_of %}
{% set _repostedUrl = _prevPost.data.repostOf or _prevPost.data.repost_of %}
{% set _replyToUrl = _prevPost.data.inReplyTo or _prevPost.data.in_reply_to %}
<a href="{{ _prevPost.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline line-clamp-2 flex items-center gap-1.5">
{% if _likedUrl %}
<svg class="w-3.5 h-3.5 text-red-500 flex-shrink-0" 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>
Liked {{ _likedUrl | replace("https://", "") | truncate(35) }}
{% elif _bookmarkedUrl %}
<svg class="w-3.5 h-3.5 text-amber-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
{{ _prevPost.data.title or ("Bookmarked " + (_bookmarkedUrl | replace("https://", "") | truncate(30))) }}
{% elif _repostedUrl %}
<svg class="w-3.5 h-3.5 text-green-500 flex-shrink-0" 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>
Reposted {{ _repostedUrl | replace("https://", "") | truncate(35) }}
{% elif _replyToUrl %}
<svg class="w-3.5 h-3.5 text-sky-500 flex-shrink-0" 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>
Reply to {{ _replyToUrl | replace("https://", "") | truncate(35) }}
{% else %}
{{ _prevPost.data.title or _prevPost.data.name or (_prevPost.templateContent | striptags | truncate(50)) or "Note" }}
{% endif %}
</a>
</div>
{% endif %}
{% if _nextPost %}
<div>
<span class="text-xs text-surface-500 uppercase tracking-wide block mb-1">Next</span>
{% set _likedUrl = _nextPost.data.likeOf or _nextPost.data.like_of %}
{% set _bookmarkedUrl = _nextPost.data.bookmarkOf or _nextPost.data.bookmark_of %}
{% set _repostedUrl = _nextPost.data.repostOf or _nextPost.data.repost_of %}
{% set _replyToUrl = _nextPost.data.inReplyTo or _nextPost.data.in_reply_to %}
<a href="{{ _nextPost.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline line-clamp-2 flex items-center gap-1.5">
{% if _likedUrl %}
<svg class="w-3.5 h-3.5 text-red-500 flex-shrink-0" 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>
Liked {{ _likedUrl | replace("https://", "") | truncate(35) }}
{% elif _bookmarkedUrl %}
<svg class="w-3.5 h-3.5 text-amber-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
{{ _nextPost.data.title or ("Bookmarked " + (_bookmarkedUrl | replace("https://", "") | truncate(30))) }}
{% elif _repostedUrl %}
<svg class="w-3.5 h-3.5 text-green-500 flex-shrink-0" 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>
Reposted {{ _repostedUrl | replace("https://", "") | truncate(35) }}
{% elif _replyToUrl %}
<svg class="w-3.5 h-3.5 text-sky-500 flex-shrink-0" 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>
Reply to {{ _replyToUrl | replace("https://", "") | truncate(35) }}
{% else %}
{{ _nextPost.data.title or _nextPost.data.name or (_nextPost.templateContent | striptags | truncate(50)) or "Note" }}
{% endif %}
</a>
</div>
{% endif %}
</div>
</div>
</is-land>
{% endif %}
@@ -0,0 +1,27 @@
{# Recent Comments Widget — sidebar #}
{% if recentComments and recentComments.length %}
<is-land on:visible>
<div class="widget">
<h3 class="widget-title">Recent Comments</h3>
<ul class="space-y-3">
{% for comment in recentComments %}
<li class="text-sm">
<div class="flex items-start gap-2">
{% if comment.author and comment.author.photo %}
<img src="{{ comment.author.photo }}" alt="{{ comment.author.name }}"
class="w-6 h-6 rounded-full flex-shrink-0 mt-0.5" loading="lazy">
{% endif %}
<div>
<span class="font-medium">{{ comment.author.name or "Anonymous" }}</span>
<p class="text-surface-600 dark:text-surface-400 line-clamp-2">{{ comment.content.text | truncate(80) }}</p>
{% if comment["comment-target"] %}
<a href="{{ comment['comment-target'] }}" class="text-xs text-accent-600 dark:text-accent-400 hover:underline">View post</a>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
</is-land>
{% endif %}
@@ -0,0 +1,85 @@
{# Recent Posts Widget — type-aware, for blog/post sidebars #}
{# Uses collections.posts directly (all post types, not just recentPosts collection) #}
{% if collections.posts %}
<is-land on:visible>
<div class="widget">
<h3 class="widget-title">Recent Posts</h3>
<ul class="space-y-3">
{% for post in collections.posts | head(5) %}
{% if post.url != page.url %}
<li>
{% set _likedUrl = post.data.likeOf or post.data.like_of %}
{% set _bookmarkedUrl = post.data.bookmarkOf or post.data.bookmark_of %}
{% set _repostedUrl = post.data.repostOf or post.data.repost_of %}
{% set _replyToUrl = post.data.inReplyTo or post.data.in_reply_to %}
{% if _likedUrl %}
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" 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 class="min-w-0">
<a href="{{ post.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline line-clamp-1">
Liked {{ _likedUrl | replace("https://", "") | truncate(40) }}
</a>
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published or post.date }}">
{{ (post.data.published or post.date) | dateDisplay }}
</time>
</div>
</div>
{% elif _bookmarkedUrl %}
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
<div class="min-w-0">
<a href="{{ post.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline line-clamp-1">
{{ post.data.title or ("Bookmarked " + (_bookmarkedUrl | replace("https://", "") | truncate(35))) }}
</a>
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published or post.date }}">
{{ (post.data.published or post.date) | dateDisplay }}
</time>
</div>
</div>
{% elif _repostedUrl %}
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" 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 class="min-w-0">
<a href="{{ post.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline line-clamp-1">
Reposted {{ _repostedUrl | replace("https://", "") | truncate(40) }}
</a>
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published or post.date }}">
{{ (post.data.published or post.date) | dateDisplay }}
</time>
</div>
</div>
{% elif _replyToUrl %}
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-sky-500 flex-shrink-0 mt-0.5" 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 class="min-w-0">
<a href="{{ post.url }}" class="text-sm text-sky-600 dark:text-sky-400 hover:underline line-clamp-1">
Reply to {{ _replyToUrl | replace("https://", "") | truncate(40) }}
</a>
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published or post.date }}">
{{ (post.data.published or post.date) | dateDisplay }}
</time>
</div>
</div>
{% else %}
<a href="{{ post.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline line-clamp-2">
{{ post.data.title or post.data.name or (post.templateContent | striptags | truncate(50)) or "Note" }}
</a>
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published or post.date }}">
{{ (post.data.published or post.date) | dateDisplay }}
</time>
{% endif %}
</li>
{% endif %}
{% endfor %}
</ul>
<a href="/blog/" class="text-sm text-accent-600 dark:text-accent-400 hover:underline mt-3 inline-block">
View all posts
</a>
</div>
</is-land>
{% endif %}
@@ -0,0 +1,93 @@
{# Recent Posts Widget (sidebar) - compact type-aware list #}
{% set recentPosts = recentPosts or collections.recentPosts %}
{% if recentPosts and recentPosts.length %}
<is-land on:visible>
<div class="widget">
<h3 class="widget-title">Recent Posts</h3>
<ul class="space-y-3">
{% for post in recentPosts | head(5) %}
<li>
{# Detect post type #}
{% set likedUrl = post.data.likeOf or post.data.like_of %}
{% set bookmarkedUrl = post.data.bookmarkOf or post.data.bookmark_of %}
{% set repostedUrl = post.data.repostOf or post.data.repost_of %}
{% set replyToUrl = post.data.inReplyTo or post.data.in_reply_to %}
{% if likedUrl %}
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" 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 class="min-w-0">
<a href="{{ post.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline break-all line-clamp-1">
Liked {{ likedUrl | replace("https://", "") | truncate(40) }}
</a>
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published }}">
{{ (post.data.published or post.date) | dateDisplay }}
</time>
</div>
</div>
{% elif bookmarkedUrl %}
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/>
</svg>
<div class="min-w-0">
<a href="{{ post.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline line-clamp-1">
{{ post.data.title or ("Bookmarked " + (bookmarkedUrl | replace("https://", "") | truncate(35))) }}
</a>
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published }}">
{{ (post.data.published or post.date) | dateDisplay }}
</time>
</div>
</div>
{% elif repostedUrl %}
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" 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 class="min-w-0">
<a href="{{ post.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline break-all line-clamp-1">
Reposted {{ repostedUrl | replace("https://", "") | truncate(40) }}
</a>
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published }}">
{{ (post.data.published or post.date) | dateDisplay }}
</time>
</div>
</div>
{% elif replyToUrl %}
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-sky-500 flex-shrink-0 mt-0.5" 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 class="min-w-0">
<a href="{{ post.url }}" class="text-sm text-sky-600 dark:text-sky-400 hover:underline break-all line-clamp-1">
Reply to {{ replyToUrl | replace("https://", "") | truncate(40) }}
</a>
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published }}">
{{ (post.data.published or post.date) | dateDisplay }}
</time>
</div>
</div>
{% else %}
{# Article / Note / other #}
<a href="{{ post.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline line-clamp-1">
{{ post.data.title or post.data.name or (post.templateContent | striptags | truncate(50)) or "Note" }}
</a>
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published }}">
{{ (post.data.published or post.date) | dateDisplay }}
</time>
{% endif %}
</li>
{% endfor %}
</ul>
<a href="/blog/" class="text-sm text-accent-600 dark:text-accent-400 hover:underline mt-3 block">
View all posts
</a>
</div>
</is-land>
{% endif %}
@@ -0,0 +1,10 @@
{# Search Widget — redirects to /search/?q=query #}
<form action="/search/" method="get" class="flex gap-2">
<input type="text" name="q" placeholder="Search..."
class="flex-1 min-w-0 px-3 py-2 text-sm rounded-lg border border-surface-300 dark:border-surface-600 bg-white dark:bg-surface-800 text-surface-900 dark:text-surface-100 placeholder-surface-400 dark:placeholder-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
<button type="submit" class="px-3 py-2 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 transition-colors" aria-label="Search">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</button>
</form>
@@ -0,0 +1,31 @@
{# Share Widget #}
{% set shareText = title + " " + site.url + page.url %}
<is-land on:visible>
<div class="widget">
<h3 class="widget-title">Share</h3>
<div class="flex gap-2">
<a href="https://bsky.app/intent/compose?text={{ shareText | 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" 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>
</a>
<span x-data="fediverseInteract('{{ shareText }}', 'share')" class="flex-1 inline-flex">
<a href="https://share.joinmastodon.org/#text={{ shareText | urlencode }}"
@click="handleClick($event)"
class="w-full 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 cursor-pointer"
title="Share on Mastodon / Fediverse (Shift+click to change instance)">
<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>
</a>
{% set modalTitle = "Share on Mastodon / Fediverse" %}
{% set modalDescription = "Choose your instance to share this post." %}
{% include "components/fediverse-modal.njk" %}
</span>
</div>
</div>
</is-land>
@@ -0,0 +1,96 @@
{# Social Feed Widget - Tabbed Bluesky/Mastodon #}
{% if (blueskyFeed and blueskyFeed.length) or (mastodonFeed and mastodonFeed.length) %}
<is-land on:visible>
<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-[#0085ff] text-[#0085ff]' : '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-[#a730b8] text-[#a730b8]' : '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-[#0085ff] 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-[#0085ff] 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-[#a730b8] 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-[#a730b8] 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>
</is-land>
{% endif %}
@@ -0,0 +1,20 @@
{# Subscribe Widget #}
<is-land on:visible>
<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-orange-600 dark:hover:text-orange-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-orange-600 dark:hover:text-orange-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>
</is-land>
@@ -0,0 +1,19 @@
{# Table of Contents Widget (for articles with headings) #}
{% if toc and toc.length %}
<is-land on:visible>
<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-accent-600 dark:hover:text-accent-400 transition-colors">
{{ item.text }}
</a>
</li>
{% endfor %}
</ul>
</nav>
</div>
</is-land>
{% endif %}
@@ -0,0 +1,168 @@
{# Recent Webmentions Widget - site-wide inbound/outbound activity #}
{# Uses client-side fetch from /webmentions/api/mentions (same as /interactions page) #}
{# Outbound tab uses Eleventy collections (likes, replies, bookmarks, reposts) #}
<is-land on:visible>
<div class="widget" x-data="webmentionsWidget()" x-init="init()">
<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>
Webmentions
</h3>
{# Tab buttons #}
<div class="flex border-b border-surface-200 dark:border-surface-700 mb-3">
<button
@click="tab = 'inbound'"
:class="tab === 'inbound' ? 'border-accent-500 text-accent-600 dark:text-accent-400' : 'border-transparent text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-2 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors">
Received
<span x-show="mentions.length" x-text="mentions.length" class="ml-0.5 text-xs opacity-75"></span>
</button>
<button
@click="tab = 'outbound'"
:class="tab === 'outbound' ? 'border-accent-500 text-accent-600 dark:text-accent-400' : 'border-transparent text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-2 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors">
Sent
</button>
</div>
{# === Inbound tab (client-side fetched) === #}
<div x-show="tab === 'inbound'" x-transition>
{# Loading #}
<div x-show="loading" class="text-center py-3">
<div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-accent-500"></div>
</div>
{# Mentions list #}
<div x-show="!loading && mentions.length" class="space-y-2">
<template x-for="wm in mentions.slice(0, 8)" :key="wm['wm-id']">
<div class="flex items-start gap-2 text-xs">
<img
:src="wm.author?.photo || '/images/default-avatar.svg'"
:alt="wm.author?.name || 'Anonymous'"
class="w-5 h-5 rounded-full flex-shrink-0 mt-0.5"
loading="lazy"
onerror="this.src='/images/default-avatar.svg'"
>
<div class="min-w-0">
<span class="font-medium text-surface-900 dark:text-surface-100" x-text="wm.author?.name || 'Anonymous'"></span>
<span x-show="wm['wm-property'] === 'like-of'" class="text-red-500"> liked</span>
<span x-show="wm['wm-property'] === 'repost-of'" class="text-green-500"> reposted</span>
<span x-show="wm['wm-property'] === 'in-reply-to'" class="text-sky-500"> replied to</span>
<span x-show="wm['wm-property'] === 'mention-of'" class="text-amber-500"> mentioned</span>
<span x-show="wm['wm-property'] === 'bookmark-of'" class="text-purple-500"> bookmarked</span>
<a :href="wm['wm-target']" class="text-surface-500 hover:underline block truncate" x-text="formatPath(wm['wm-target'])"></a>
</div>
</div>
</template>
</div>
{# Empty #}
<p x-show="!loading && !mentions.length && !error" class="text-xs text-surface-500 py-2">No webmentions received yet.</p>
{# Error #}
<p x-show="error" class="text-xs text-red-500 py-2" x-text="error"></p>
{# Link to full interactions page #}
<div x-show="mentions.length > 0" class="mt-2 pt-2 border-t border-surface-200 dark:border-surface-700">
<a href="/interactions/" class="text-xs text-accent-600 dark:text-accent-400 hover:underline">
View all &rarr;
</a>
</div>
</div>
{# === Outbound tab (from Eleventy collections) === #}
<div x-show="tab === 'outbound'" x-transition>
<div class="space-y-2">
{% set _recentOutbound = [] %}
{% for item in collections.likes | head(3) %}
<div class="flex items-start gap-2 text-xs">
<svg class="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" 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 class="min-w-0">
<a href="{{ item.url }}" class="text-surface-900 dark:text-surface-100 hover:underline block truncate">
{% set _likedUrl = item.data.likeOf or item.data.like_of %}
Liked {{ _likedUrl | replace("https://", "") | truncate(30) }}
</a>
</div>
</div>
{% endfor %}
{% for item in collections.replies | head(2) %}
<div class="flex items-start gap-2 text-xs">
<svg class="w-4 h-4 text-sky-500 flex-shrink-0 mt-0.5" 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 class="min-w-0">
<a href="{{ item.url }}" class="text-surface-900 dark:text-surface-100 hover:underline block truncate">
{% set _replyToUrl = item.data.inReplyTo or item.data.in_reply_to %}
Reply to {{ _replyToUrl | replace("https://", "") | truncate(30) }}
</a>
</div>
</div>
{% endfor %}
{% for item in collections.reposts | head(2) %}
<div class="flex items-start gap-2 text-xs">
<svg class="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" 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 class="min-w-0">
<a href="{{ item.url }}" class="text-surface-900 dark:text-surface-100 hover:underline block truncate">
{% set _repostedUrl = item.data.repostOf or item.data.repost_of %}
Reposted {{ _repostedUrl | replace("https://", "") | truncate(30) }}
</a>
</div>
</div>
{% endfor %}
</div>
<div class="mt-2 pt-2 border-t border-surface-200 dark:border-surface-700">
<a href="/interactions/" class="text-xs text-accent-600 dark:text-accent-400 hover:underline">
View all &rarr;
</a>
</div>
</div>
</div>
<script>
function webmentionsWidget() {
return {
tab: 'inbound',
loading: false,
error: null,
mentions: [],
async init() {
this.loading = true;
try {
const [wmRes, convRes] = await Promise.all([
fetch('/webmentions/api/mentions?per-page=50&page=0').catch(() => null),
fetch('/conversations/api/mentions?per-page=50&page=0').catch(() => null),
]);
const wmData = wmRes?.ok ? await wmRes.json() : { children: [] };
const convData = convRes?.ok ? await convRes.json() : { children: [] };
// Merge: conversations items first (richer metadata), then webmentions
const seen = new Set();
const merged = [];
for (const item of (convData.children || [])) {
const key = item['wm-id'] || item.url;
if (key && !seen.has(key)) { seen.add(key); merged.push(item); }
}
for (const item of (wmData.children || [])) {
const key = item['wm-id'];
if (!key || seen.has(key)) continue;
if (item.url && seen.has(item.url)) continue;
seen.add(key);
merged.push(item);
}
this.mentions = merged.sort((a, b) => {
return new Date(b.published || b['wm-received'] || 0) - new Date(a.published || a['wm-received'] || 0);
});
} catch (e) {
this.error = 'Could not load';
} finally {
this.loading = false;
}
},
formatPath(url) {
try { return new URL(url).pathname; } catch { return url; }
}
};
}
</script>
</is-land>
+549
View File
@@ -0,0 +1,549 @@
<!DOCTYPE html>
<html lang="{{ site.locale | default('en') }}">
<head>
{# OG image resolution handled by og-fix transform in eleventy.config.js
to bypass Eleventy 3.x parallel rendering race condition (#3183).
Template outputs __OG_IMAGE_PLACEHOLDER__ and __TWITTER_CARD_PLACEHOLDER__
which the transform replaces using the correct slug derived from outputPath. #}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="generator" content="Eleventy">
<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) %}
{# Normalize photo - could be array for multi-photo posts #}
{% set ogPhoto = photo %}
{% if ogPhoto %}
{% if ogPhoto[0] and (ogPhoto[0] | length) > 10 %}
{% set ogPhoto = ogPhoto[0] %}
{% endif %}
{% endif %}
<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 ogPhoto and ogPhoto != "" and (ogPhoto | length) > 10 %}
<meta property="og:image" content="{% if 'http' in ogPhoto %}{{ ogPhoto }}{% else %}{{ site.url }}{% if ogPhoto[0] != '/' %}/{% endif %}{{ ogPhoto }}{% endif %}">
{% elif image and image != "" and (image | length) > 10 %}
<meta property="og:image" content="{% if 'http' in image %}{{ image }}{% else %}{{ site.url }}{% if image[0] != '/' %}/{% endif %}{{ image }}{% endif %}">
{% else %}
<meta property="og:image" content="__OG_IMAGE_PLACEHOLDER__">
{% 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 hasExplicitImage = (ogPhoto and ogPhoto != "" and (ogPhoto | length) > 10) or (image and image != "" and (image | length) > 10) %}
<meta name="twitter:card" content="{% if hasExplicitImage %}summary_large_image{% else %}__TWITTER_CARD_PLACEHOLDER__{% endif %}">
<meta name="twitter:title" content="{{ ogTitle }}">
<meta name="twitter:description" content="{{ ogDesc }}">
{% if ogPhoto and ogPhoto != "" and (ogPhoto | length) > 10 %}
<meta name="twitter:image" content="{% if 'http' in ogPhoto %}{{ ogPhoto }}{% else %}{{ site.url }}{% if ogPhoto[0] != '/' %}/{% endif %}{{ ogPhoto }}{% endif %}">
{% elif image and image != "" and (image | length) > 10 %}
<meta name="twitter:image" content="{% if 'http' in image %}{{ image }}{% else %}{{ site.url }}{% if image[0] != '/' %}/{% endif %}{{ image }}{% endif %}">
{% else %}
<meta name="twitter:image" content="__OG_IMAGE_PLACEHOLDER__">
{% endif %}
{# Favicon #}
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
{# Critical CSS — inlined for fast first paint #}
<style>{{ "css/critical.css" | inlineFile | safe }}</style>
{# Defer full stylesheet — loads after first paint #}
<link rel="stylesheet" href="/css/style.css?v={{ '/css/style.css' | hash }}" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/css/style.css?v={{ '/css/style.css' | hash }}"></noscript>
<link rel="stylesheet" href="/css/prism-theme.css?v={{ '/css/prism-theme.css' | hash }}" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/css/prism-theme.css?v={{ '/css/prism-theme.css' | hash }}"></noscript>
<link rel="stylesheet" href="/pagefind/pagefind-ui.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/pagefind/pagefind-ui.css"></noscript>
<script>
var _pfQueue = [];
function initPagefind(sel, opts) { _pfQueue.push([sel, opts]); }
</script>
<link rel="stylesheet" href="/css/lite-yt-embed.css?v={{ '/css/lite-yt-embed.css' | hash }}">
<script src="/js/vendor/lite-yt-embed.js?v={{ '/js/vendor/lite-yt-embed.js' | hash }}" defer></script>
{# Alpine.js components — MUST load before Alpine core (Alpine.data() registration via alpine:init) #}
<script src="/js/comments.js?v={{ '/js/comments.js' | hash }}" defer></script>
<script src="/js/fediverse-interact.js?v={{ '/js/fediverse-interact.js' | hash }}" defer></script>
<script src="/js/lightbox.js?v={{ '/js/lightbox.js' | hash }}" defer></script>
<script defer src="/js/vendor/alpine-collapse.min.js?v={{ '/js/vendor/alpine-collapse.min.js' | hash }}"></script>
<script defer src="/js/vendor/alpine.min.js?v={{ '/js/vendor/alpine.min.js' | hash }}"></script>
<style>[x-cloak] { display: none !important; }</style>
{# Graceful no-JS fallback: show content that Alpine would normally control #}
<noscript>
<style>
/* Override x-cloak so hidden content is visible without Alpine */
[x-cloak] { display: block !important; }
/* Show all tab panels stacked (Alpine x-show tabs) */
[x-show] { display: block !important; }
/* Hide JS-only interactive controls */
.fab-container, .fab-button, .fab-backdrop, .fab-menu { display: none !important; }
/* Hide tab button rows - content shows stacked instead */
[x-data] > .flex.border-b { display: none !important; }
/* Hide loading spinners and JS-only buttons */
[x-show*="loading"], button[\\@click*="fetch"], button[\\@click*="loadMore"] { display: none !important; }
</style>
</noscript>
<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="alternate" type="application/rss+xml" href="/digest/feed.xml" title="Weekly Digest — RSS Feed">
{% if site.markdownAgents.enabled and page.url and page.url.startsWith('/articles/') and page.url != '/articles/' %}
<link rel="alternate" type="text/markdown" href="{{ page.url | stripTrailingSlash }}.md" title="Markdown version">
{% endif %}
{% if category and page.url and page.url.startsWith('/categories/') and page.url != '/categories/' %}
<link rel="alternate" type="application/rss+xml" href="/categories/{{ category | slugify }}/feed.xml" title="{{ category }} — RSS Feed">
<link rel="alternate" type="application/json" href="/categories/{{ category | slugify }}/feed.json" title="{{ category }} — JSON Feed">
{% endif %}
<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="microsub" href="{{ site.url }}/microsub">
<link rel="self" href="{{ site.url }}{{ page.url }}">
<link rel="hub" href="https://websubhub.com/hub">
<link rel="webmention" href="https://webmention.io/{{ site.webmentions.domain }}/webmention">
<link rel="pingback" href="https://webmention.io/{{ site.webmentions.domain }}/xmlrpc">
{# Fediverse creator meta tag for Mastodon verification #}
{% if site.fediverseCreator %}
<meta name="fediverse:creator" content="{{ site.fediverseCreator }}">
{% endif %}
{# IndieAuth rel="me" links for identity verification #}
{# Note: Bluesky links use "me atproto" for verification #}
{% for social in site.social %}
<link rel="{{ social.rel }}" href="{{ social.url }}">
{% endfor %}
</head>
<body{% if pagefindIgnore %} data-pagefind-ignore="all"{% endif %}>
<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="/cv/">CV</a>
{# Slash pages dropdown - all root pages in one menu #}
<div class="nav-dropdown" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="/slashes/" class="nav-dropdown-trigger">
/
<svg class="w-3 h-3 ml-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</a>
<div class="nav-dropdown-menu" x-show="open" x-transition x-cloak>
<a href="/slashes/">All Pages</a>
<a href="/cv/">/cv</a>
{% for item in collections.pages %}
<a href="{{ item.url }}">/{{ item.fileSlug }}</a>
{% endfor %}
{# Plugin pages — only show when their data source is configured #}
{% set hasPluginPages = (funkwhaleActivity and funkwhaleActivity.source == "indiekit") or
(githubActivity and githubActivity.source != "error") or
(lastfmActivity and lastfmActivity.source == "indiekit") or
(newsActivity and newsActivity.source == "indiekit") or
(youtubeChannel and youtubeChannel.source == "indiekit") or
(blogrollStatus and blogrollStatus.source == "indiekit") or
(podrollStatus and podrollStatus.source == "indiekit") %}
{% if hasPluginPages %}
<div class="nav-dropdown-divider"></div>
{% if blogrollStatus and blogrollStatus.source == "indiekit" %}<a href="/blogroll/">/blogroll</a>{% endif %}
{% if funkwhaleActivity and funkwhaleActivity.source == "indiekit" %}<a href="/funkwhale/">/funkwhale</a>{% endif %}
{% if githubActivity and githubActivity.source != "error" %}<a href="/github/">/github</a>{% endif %}
{% if lastfmActivity and lastfmActivity.source == "indiekit" %}<a href="/listening/">/listening</a>{% endif %}
{% if newsActivity and newsActivity.source == "indiekit" %}<a href="/news/">/news</a>{% endif %}
{% if podrollStatus and podrollStatus.source == "indiekit" %}<a href="/podroll/">/podroll</a>{% endif %}
{% if youtubeChannel and youtubeChannel.source == "indiekit" %}<a href="/youtube/">/youtube</a>{% endif %}
{% endif %}
</div>
</div>
{# Blog dropdown #}
<div class="nav-dropdown" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="/blog/" class="nav-dropdown-trigger">
Blog
<svg class="w-3 h-3 ml-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</a>
<div class="nav-dropdown-menu" x-show="open" x-transition x-cloak>
<a href="/blog/">All Posts</a>
{% for pt in enabledPostTypes %}
<a href="{{ pt.path }}">{{ pt.label }}</a>
{% endfor %}
</div>
</div>
<a href="/interactions/">Interactions</a>
<a href="/digest/">Digest</a>
<a href="/dashboard"
x-data="{ show: false }"
x-show="show"
x-cloak
x-transition
@indiekit:auth.window="show = $event.detail.loggedIn"
class="admin-nav-link">
<svg class="w-4 h-4 inline -mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
Dashboard
</a>
</nav>
<a href="/search/" aria-label="Search" title="Search" class="p-2 rounded-lg text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
</a>
<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" x-data="{ blogOpen: false, slashOpen: false }">
<a href="/">Home</a>
<a href="/about/">About</a>
<a href="/cv/">CV</a>
{# Slash pages section - all root pages in one menu #}
<div class="mobile-nav-section">
<button type="button" class="mobile-nav-toggle" @click="slashOpen = !slashOpen">
/
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': slashOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div class="mobile-nav-submenu" x-show="slashOpen" x-collapse>
<a href="/slashes/">All Pages</a>
<a href="/cv/">/cv</a>
{% for item in collections.pages %}
<a href="{{ item.url }}">/{{ item.fileSlug }}</a>
{% endfor %}
{# Plugin pages — only show when configured #}
{% if hasPluginPages %}
<div class="mobile-nav-divider"></div>
{% if blogrollStatus and blogrollStatus.source == "indiekit" %}<a href="/blogroll/">/blogroll</a>{% endif %}
{% if funkwhaleActivity and funkwhaleActivity.source == "indiekit" %}<a href="/funkwhale/">/funkwhale</a>{% endif %}
{% if githubActivity and githubActivity.source != "error" %}<a href="/github/">/github</a>{% endif %}
{% if lastfmActivity and lastfmActivity.source == "indiekit" %}<a href="/listening/">/listening</a>{% endif %}
{% if newsActivity and newsActivity.source == "indiekit" %}<a href="/news/">/news</a>{% endif %}
{% if podrollStatus and podrollStatus.source == "indiekit" %}<a href="/podroll/">/podroll</a>{% endif %}
{% if youtubeChannel and youtubeChannel.source == "indiekit" %}<a href="/youtube/">/youtube</a>{% endif %}
{% endif %}
</div>
</div>
{# Blog section #}
<div class="mobile-nav-section">
<button type="button" class="mobile-nav-toggle" @click="blogOpen = !blogOpen">
Blog
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': blogOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div class="mobile-nav-submenu" x-show="blogOpen" x-collapse>
<a href="/blog/">All Posts</a>
{% for pt in enabledPostTypes %}
<a href="{{ pt.path }}">{{ pt.label }}</a>
{% endfor %}
</div>
</div>
<a href="/interactions/">Interactions</a>
<a href="/digest/">Digest</a>
<a href="/search/">Search</a>
<a href="/dashboard"
x-data="{ show: false }"
x-show="show"
x-cloak
@indiekit:auth.window="show = $event.detail.loggedIn">
Dashboard
</a>
{# Mobile theme toggle #}
<button type="button" class="mobile-theme-toggle" aria-label="Toggle dark mode">
<span class="theme-label">Theme</span>
<span class="theme-icons">
<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>
</span>
</button>
</nav>
</header>
<main class="container py-8" data-pagefind-body>
{% if withSidebar and page.url == "/" and homepageConfig and homepageConfig.sections %}
{# Homepage: builder controls its own layout and sidebar #}
{{ content | safe }}
{% elif withSidebar %}
<div class="layout-with-sidebar">
<div class="main-content">
{{ content | safe }}
</div>
<aside class="sidebar" data-pagefind-ignore>
{% 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" data-pagefind-ignore>
{% include "components/blog-sidebar.njk" %}
</aside>
</div>
{% else %}
{{ content | safe }}
{% endif %}
</main>
<footer class="border-t border-surface-200 dark:border-surface-700 mt-12 pt-8 pb-6">
<div class="container">
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
{# Navigate #}
<div>
<h4 class="text-sm font-semibold uppercase tracking-wider text-surface-500 dark:text-surface-400 mb-3">Navigate</h4>
<ul class="space-y-2">
<li><a href="/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Home</a></li>
<li><a href="/about/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">About</a></li>
<li><a href="/cv/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">CV</a></li>
<li><a href="/changelog/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Changelog</a></li>
<li><a href="/search/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Search</a></li>
</ul>
</div>
{# Content #}
<div>
<h4 class="text-sm font-semibold uppercase tracking-wider text-surface-500 dark:text-surface-400 mb-3">Content</h4>
<ul class="space-y-2">
<li><a href="/blog/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Blog</a></li>
{% for pt in enabledPostTypes %}
<li><a href="{{ pt.path }}" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">{{ pt.label }}</a></li>
{% endfor %}
<li><a href="/interactions/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Interactions</a></li>
<li><a href="/digest/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Digest</a></li>
</ul>
</div>
{# Connect #}
<div>
<h4 class="text-sm font-semibold uppercase tracking-wider text-surface-500 dark:text-surface-400 mb-3">Connect</h4>
<ul class="space-y-2">
{% for social in site.social %}
<li><a href="{{ social.url }}" rel="{{ social.rel }}" target="_blank" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">{{ social.name }}</a></li>
{% endfor %}
</ul>
</div>
{# Meta #}
<div>
<h4 class="text-sm font-semibold uppercase tracking-wider text-surface-500 dark:text-surface-400 mb-3">Meta</h4>
<ul class="space-y-2">
<li><a href="/feed.xml" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">RSS Feed</a></li>
<li><a href="/feed.json" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">JSON Feed</a></li>
<li><a href="/changelog/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Changelog</a></li>
</ul>
</div>
</div>
<p class="text-center text-sm text-surface-500 dark:text-surface-400">Powered by <a href="https://getindiekit.com" class="hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Indiekit</a> + <a href="https://11ty.dev" class="hover:text-surface-900 dark:hover:text-surface-100 hover:underline">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 (desktop and mobile)
function toggleTheme() {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', toggleTheme);
}
const mobileThemeToggle = document.querySelector('.mobile-theme-toggle');
if (mobileThemeToggle) {
mobileThemeToggle.addEventListener('click', toggleTheme);
}
// 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>
{# Island architecture - lazy hydration for widgets #}
<script type="module" src="/js/is-land.js"></script>
{# Relative date display - progressively enhances <time> elements #}
<script src="/js/time-difference.js?v={{ '/js/time-difference.js' | hash }}" defer></script>
{# Responsive tables - auto-enhances <table> on narrow screens #}
<script type="module" src="/js/table-saw.js"></script>
{# Client-side filtering for archive pages #}
<script type="module" src="/js/filter-container.js"></script>
{# Client-side webmention fetcher - supplements build-time cache with real-time data #}
<script src="/js/webmentions.js?v={{ '/js/webmentions.js' | hash }}" defer></script>
{# Admin auth detection - shows dashboard link + FAB when logged in #}
<script src="/js/admin.js?v={{ '/js/admin.js' | hash }}" defer></script>
{# Save for Later buttons — active when logged in #}
<script src="/js/save-later.js?v={{ '/js/save-later.js' | hash }}" defer></script>
{# Share Post buttons — opens share form popup when logged in #}
<script src="/js/share-post.js?v={{ '/js/share-post.js' | hash }}" defer></script>
{# Floating Action Button - visible only when logged in #}
<div x-data="{ show: false, open: false }"
x-show="show"
x-cloak
@indiekit:auth.window="show = $event.detail.loggedIn"
@keydown.escape.window="open = false"
class="fab-container">
{# Backdrop #}
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="open = false"
class="fab-backdrop"></div>
{# Menu items #}
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-4"
class="fab-menu">
{% if mpUrl %}
<a href="/posts/edit?url={{ mpUrl | urlencode }}" @click="open = false" class="fab-menu-item" rel="nofollow">
<svg class="w-5 h-5 text-accent-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
<span>Edit this post</span>
</a>
<div class="fab-menu-divider"></div>
{% endif %}
<a href="/posts/create?type=page" @click="open = false" class="fab-menu-item" rel="nofollow">
<svg class="w-5 h-5 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
<span>Page</span>
</a>
<a href="/posts/create?type=bookmark" @click="open = false" class="fab-menu-item" rel="nofollow">
<svg class="w-5 h-5 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
<span>Bookmark</span>
</a>
<a href="/posts/create?type=photo" @click="open = false" class="fab-menu-item" rel="nofollow">
<svg class="w-5 h-5 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
</svg>
<span>Photo</span>
</a>
<a href="/posts/create?type=article" @click="open = false" class="fab-menu-item" rel="nofollow">
<svg class="w-5 h-5 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
</svg>
<span>Article</span>
</a>
<a href="/posts/create?type=note" @click="open = false" class="fab-menu-item" rel="nofollow">
<svg class="w-5 h-5 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
<span>Note</span>
</a>
</div>
{# FAB button #}
<button @click="open = !open"
class="fab-button"
:aria-expanded="open"
aria-label="Create new post">
<svg class="w-7 h-7 transition-transform duration-200" :class="{ 'rotate-45': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
{# Pagefind — load at end of body so all DOM elements exist, then process queue #}
<script src="/pagefind/pagefind-ui.js"></script>
<script>
(function() {
if (typeof PagefindUI === "undefined") { console.warn("[pagefind] PagefindUI not loaded"); return; }
for (var i = 0; i < _pfQueue.length; i++) {
new PagefindUI(Object.assign({ element: _pfQueue[i][0], showSubResults: false, showImages: false }, _pfQueue[i][1] || {}));
}
})();
</script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
---
layout: layouts/base.njk
---
{# Full-width layout for rich HTML pages (interactive guides, architecture diagrams, etc.)
Inherits site header + footer from base.njk but renders content at full container width
with no sidebar, no post metadata, and no prose constraints. #}
<article>
{% if title %}
<header class="mb-6 sm:mb-8">
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">
{{ title }}
</h1>
{% if description %}
<p class="text-lg text-surface-600 dark:text-surface-400">
{{ description }}
</p>
{% endif %}
</header>
{% endif %}
<div class="fullwidth-content">
{{ content | safe }}
</div>
</article>
+157
View File
@@ -0,0 +1,157 @@
---
layout: layouts/base.njk
withSidebar: true
---
{# Homepage content — two-tier fallback: #}
{# 1. Plugin config (homepageConfig) — homepage builder controls everything #}
{# 2. Default — show recent posts with default hero #}
{# Default hero — only shown for Tier 2 (plugin controls its own hero) #}
{% if not (homepageConfig and homepageConfig.sections) %}
<section class="mb-8 sm:mb-12">
<div class="flex flex-col sm:flex-row gap-6 sm:gap-8 items-start">
{# Avatar #}
<img
src="{{ site.author.avatar }}"
alt="{{ site.author.name }}"
class="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover shadow-lg flex-shrink-0"
loading="eager"
>
{# Introduction #}
<div class="flex-1 min-w-0">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">
{{ site.author.name }}
</h1>
<p class="text-lg sm:text-xl text-accent-600 dark:text-accent-400 mb-3 sm:mb-4">
{{ site.author.title }}
</p>
{% if site.author.bio %}
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mb-4">
{{ site.author.bio }}
</p>
{% endif %}
{% if site.description %}
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mb-4 sm:mb-6">
{{ site.description }}
<a href="/about/" class="text-accent-600 dark:text-accent-400 hover:underline font-medium">Read more &rarr;</a>
</p>
{% endif %}
{# 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>
{% endif %}
{# --- Tier 1: Plugin-driven layout --- #}
{% if homepageConfig and homepageConfig.sections %}
{% include "components/homepage-builder.njk" %}
{# --- Tier 2: Default — recent posts and explore links --- #}
{% else %}
{# Recent Posts #}
{% if collections.posts and collections.posts.length %}
<section class="mb-8 sm:mb-12">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">Recent Posts</h2>
<div class="space-y-4">
{% for post in collections.posts | head(10) %}
<article class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-accent-400 dark:hover:border-accent-600 transition-colors">
<h3 class="font-semibold text-surface-900 dark:text-surface-100 mb-1">
<a href="{{ post.url }}" class="hover:text-accent-600 dark:hover:text-accent-400">
{{ post.data.title or post.data.name or "Untitled" }}
</a>
</h3>
{% if post.data.summary %}
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2 line-clamp-2">{{ post.data.summary }}</p>
{% endif %}
<div class="flex items-center gap-3 text-xs text-surface-500">
<time datetime="{{ post.data.published or post.date }}">
{{ (post.data.published or post.date) | date("MMM d, yyyy") }}
</time>
{% if post.data.postType %}
<span class="px-2 py-0.5 bg-surface-100 dark:bg-surface-700 rounded">{{ post.data.postType }}</span>
{% endif %}
</div>
</article>
{% endfor %}
</div>
<a href="/blog/" class="text-sm text-accent-600 dark:text-accent-400 hover:underline mt-4 inline-flex items-center gap-1">
View all posts
<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>
</section>
{% endif %}
{# Explore — quick links to key sections #}
<section class="mb-8 sm:mb-12">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">Explore</h2>
<div class="grid gap-3 sm:grid-cols-3">
<a href="/blog/" class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-accent-400 dark:hover:border-accent-600 transition-colors text-center">
<div class="text-2xl mb-2">
<svg class="w-8 h-8 mx-auto text-accent-600 dark:text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</div>
<span class="font-semibold text-surface-900 dark:text-surface-100">Blog</span>
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">Articles, notes, and photos</p>
</a>
<a href="/cv/" class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-accent-400 dark:hover:border-accent-600 transition-colors text-center">
<div class="text-2xl mb-2">
<svg class="w-8 h-8 mx-auto text-accent-600 dark:text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg>
</div>
<span class="font-semibold text-surface-900 dark:text-surface-100">CV</span>
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">Experience and projects</p>
</a>
<a href="/about/" class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-accent-400 dark:hover:border-accent-600 transition-colors text-center">
<div class="text-2xl mb-2">
<svg class="w-8 h-8 mx-auto text-accent-600 dark:text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg>
</div>
<span class="font-semibold text-surface-900 dark:text-surface-100">About</span>
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">Who I am and what I do</p>
</a>
</div>
</section>
{# Posting Activity — contribution graph (Tier 2 default only) #}
{% if collections.posts and collections.posts.length %}
<section class="mb-8 sm:mb-12">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">Posting Activity</h2>
{% postGraph collections.posts %}
<a href="/graph/" class="text-sm text-accent-600 dark:text-accent-400 hover:underline mt-4 inline-flex items-center gap-1">
View full 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>
</section>
{% endif %}
{% endif %} {# end two-tier fallback #}
+135
View File
@@ -0,0 +1,135 @@
---
layout: layouts/base.njk
withSidebar: true
---
{# Layout for slash pages (/about, /now, /uses, etc.) #}
{# These are root-level pages created via Indiekit's page post type #}
<article class="h-entry">
<header class="mb-6 sm:mb-8">
<h1 class="p-name text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">
{{ title }}
</h1>
{% if summary %}
<p class="p-summary text-lg text-surface-600 dark:text-surface-400">
{{ summary }}
</p>
{% endif %}
{% if updated %}
<p class="text-sm text-surface-500 dark:text-surface-400 mt-2">
Last updated: <time class="dt-updated" datetime="{{ updated | isoDate }}">{{ updated | dateDisplay }}</time>
</p>
{% endif %}
</header>
<div class="e-content prose dark:prose-invert prose-lg max-w-none">
{{ content | safe }}
</div>
{# AI post-graph — shown only on the /ai/ page #}
{% if page.url == "/ai/" and collections.posts %}
{% set stats = collections.posts | aiStats %}
{% set aiPostsList = collections.posts | aiPosts %}
<section class="mt-8 sm:mt-12 p-6 rounded-xl bg-surface-50 dark:bg-surface-800/50 border border-surface-200 dark:border-surface-700">
<h2 class="text-xl font-bold text-surface-900 dark:text-surface-100 mb-4">AI Usage Across Posts</h2>
<div class="grid gap-4 sm:grid-cols-4 mb-6">
<div class="text-center p-3 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700">
<div class="text-2xl font-bold text-surface-900 dark:text-surface-100">{{ stats.total }}</div>
<div class="text-xs text-surface-500 dark:text-surface-400">Total posts</div>
</div>
<div class="text-center p-3 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700">
<div class="text-2xl font-bold text-amber-600 dark:text-amber-400">{{ stats.aiCount }}</div>
<div class="text-xs text-surface-500 dark:text-surface-400">AI-involved</div>
</div>
<div class="text-center p-3 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700">
<div class="text-2xl font-bold text-emerald-600 dark:text-emerald-400">{{ stats.total - stats.aiCount }}</div>
<div class="text-xs text-surface-500 dark:text-surface-400">Human-only</div>
</div>
<div class="text-center p-3 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700">
<div class="text-2xl font-bold text-surface-900 dark:text-surface-100">{{ stats.percentage }}%</div>
<div class="text-xs text-surface-500 dark:text-surface-400">AI ratio</div>
</div>
</div>
{# Breakdown by level #}
<div class="flex flex-wrap gap-3 text-sm mb-6">
<span class="px-3 py-1 rounded-full bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-300">
Level 0 (None): {{ stats.byLevel[0] }}
</span>
<span class="px-3 py-1 rounded-full bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300">
Level 1 (Editorial): {{ stats.byLevel[1] }}
</span>
<span class="px-3 py-1 rounded-full bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200">
Level 2 (Co-drafted): {{ stats.byLevel[2] }}
</span>
<span class="px-3 py-1 rounded-full bg-amber-200 dark:bg-amber-900/60 text-amber-900 dark:text-amber-100">
Level 3 (AI-generated): {{ stats.byLevel[3] }}
</span>
</div>
{# Post graph showing AI posts (highlighted) on the full year grid #}
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-3">AI-Involved Posts Over Time</h3>
<p class="text-sm text-surface-500 dark:text-surface-400 mb-4">Highlighted days had posts with AI involvement (level 1+). Empty boxes represent days with no AI-involved posts.</p>
{% postGraph aiPostsList, { prefix: "ai", highlightColorLight: "#d97706", highlightColorDark: "#fbbf24" } %}
</section>
{% endif %}
{# AI usage disclosure #}
{% set aiTextLevel = aiTextLevel or ai_text_level %}
{% set aiCodeLevel = aiCodeLevel or ai_code_level %}
{% set aiTools = aiTools or ai_tools %}
{% set aiDescription = aiDescription or ai_description %}
{% if aiTextLevel or aiCodeLevel or aiTools %}
<aside class="mt-6 p-4 rounded-lg bg-surface-50 dark:bg-surface-800/50 border border-surface-200 dark:border-surface-700">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-surface-500 dark:text-surface-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714a2.25 2.25 0 00.659 1.591L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-2.47 2.47a2.25 2.25 0 01-1.59.659H9.06a2.25 2.25 0 01-1.591-.659L5 14.5m14 0V17a2 2 0 01-2 2H7a2 2 0 01-2-2v-2.5"/>
</svg>
<strong class="text-sm font-semibold text-surface-700 dark:text-surface-300">AI Usage</strong>
</div>
<div class="flex flex-wrap gap-3 text-xs text-surface-600 dark:text-surface-400">
{% if aiTextLevel %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-surface-100 dark:bg-surface-700">
Text: {% if aiTextLevel === "0" %}None{% elif aiTextLevel === "1" %}Editorial{% elif aiTextLevel === "2" %}Co-drafted{% elif aiTextLevel === "3" %}AI-generated{% endif %}
</span>
{% endif %}
{% if aiCodeLevel %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-surface-100 dark:bg-surface-700">
Code: {% if aiCodeLevel === "0" %}Human{% elif aiCodeLevel === "1" %}AI-assisted{% elif aiCodeLevel === "2" %}AI-generated{% endif %}
</span>
{% endif %}
{% if aiTools %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-surface-100 dark:bg-surface-700">
Tools: {{ aiTools }}
</span>
{% endif %}
</div>
{% if aiDescription %}
<p class="mt-2 text-xs text-surface-500 dark:text-surface-400">{{ aiDescription }}</p>
{% endif %}
</aside>
{% endif %}
{# Categories/tags if present #}
{% if category %}
<footer class="mt-8 pt-6 border-t border-surface-200 dark:border-surface-700">
<div class="flex flex-wrap gap-2">
{% if category is string %}
<a href="/categories/{{ category | slugify }}/" class="p-category text-sm px-3 py-1 bg-surface-100 dark:bg-surface-800 rounded-full hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors">
{{ category }}
</a>
{% else %}
{% for cat in category %}
<a href="/categories/{{ cat | slugify }}/" class="p-category text-sm px-3 py-1 bg-surface-100 dark:bg-surface-800 rounded-full hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors">
{{ cat }}
</a>
{% endfor %}
{% endif %}
</div>
</footer>
{% endif %}
{# Hidden metadata for microformats #}
<a class="u-url hidden" href="{{ page.url }}"></a>
<data class="p-author h-card hidden" value="{{ site.author.name }}"></data>
</article>
+263
View File
@@ -0,0 +1,263 @@
---
layout: layouts/base.njk
withBlogSidebar: true
---
<article class="h-entry post" x-data="lightbox" @keydown.window="onKeydown($event)">
{# Support both camelCase (Indiekit Eleventy preset) and underscore (legacy) property names #}
{% set bookmarkedUrl = bookmarkOf or bookmark_of %}
{% set likedUrl = likeOf or like_of %}
{% set replyTo = inReplyTo or in_reply_to %}
{% set repostedUrl = repostOf or repost_of %}
{% if title %}
<h1 class="p-name text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-3 sm:mb-4">{{ title }}</h1>
{% else %}
<div class="flex items-center gap-2 mb-1">
<span class="text-sm font-medium text-surface-500 dark:text-surface-400">
{% if replyTo %}&#8617; Reply{% elif likedUrl %}&#9829; Like{% elif repostedUrl %}&#9851; Repost{% elif bookmarkedUrl %}&#128278; Bookmark{% else %}&#9998; Note{% endif %}
</span>
</div>
{% endif %}
<div class="post-meta mb-4 sm:mb-6">
<time-difference><time class="dt-published" datetime="{{ date.toISOString() }}">
{{ date | dateDisplay }}
</time></time-difference>
{% 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 #}
{# For interaction types (bookmarks, likes, replies, reposts), include the target URL #}
{% set bridgySummary = description or summary or (content | ogDescription(280)) %}
{% set interactionUrl = bookmarkedUrl or likedUrl or replyTo or repostedUrl %}
{% if bridgySummary or interactionUrl %}
<p class="p-summary e-bridgy-mastodon-content e-bridgy-bluesky-content hidden">{% if bookmarkedUrl %}🔖 {{ bookmarkedUrl }}{% if bridgySummary %} - {{ bridgySummary }}{% endif %}{% elif likedUrl %}❤️ {{ likedUrl }}{% if bridgySummary %} - {{ bridgySummary }}{% endif %}{% elif replyTo %}↩️ {{ replyTo }}{% if bridgySummary %} - {{ bridgySummary }}{% endif %}{% elif repostedUrl %}🔁 {{ repostedUrl }}{% if bridgySummary %} - {{ bridgySummary }}{% endif %}{% else %}{{ bridgySummary }}{% endif %}</p>
{% endif %}
{# Render photo(s) from frontmatter for photo posts - use eleventy:ignore to skip image transform #}
{% if photo %}
<div class="photo-gallery mb-6">
{% for img in photo %}
{% set photoUrl = img.url %}
{% if photoUrl and photoUrl[0] != '/' and 'http' not in photoUrl %}
{% set photoUrl = '/' + photoUrl %}
{% endif %}
<img src="{{ photoUrl }}" alt="{{ img.alt | default('Photo') }}" class="u-photo rounded-lg max-w-full" loading="lazy" eleventy:ignore>
{% endfor %}
</div>
{% endif %}
{% set isInteraction = replyTo or likedUrl or repostedUrl or bookmarkedUrl %}
{% set hasContent = content and content | striptags | trim %}
<div class="e-content prose prose-surface dark:prose-invert max-w-none{% if isInteraction and hasContent %} border-l-[3px] border-l-accent-500 dark:border-l-accent-400 pl-4{% endif %}">
{{ content | safe }}
</div>
{# Rich reply context with h-cite microformat #}
{% include "components/reply-context.njk" %}
{# AI usage disclosure — always shown, collapsed by default, placed after reply context #}
{% set aiTextLevel = aiTextLevel or ai_text_level or "0" %}
{% set aiCodeLevel = aiCodeLevel or ai_code_level %}
{% set aiTools = aiTools or ai_tools %}
{% set aiDescription = aiDescription or ai_description %}
<details class="mt-4 text-xs text-surface-500 dark:text-surface-400">
<summary class="cursor-pointer hover:text-surface-600 dark:hover:text-surface-300 list-none flex items-center gap-1.5 [&::-webkit-details-marker]:hidden">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714a2.25 2.25 0 00.659 1.591L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-2.47 2.47a2.25 2.25 0 01-1.59.659H9.06a2.25 2.25 0 01-1.591-.659L5 14.5m14 0V17a2 2 0 01-2 2H7a2 2 0 01-2-2v-2.5"/>
</svg>
<span>AI:
Text {% if aiTextLevel === "0" %}None{% elif aiTextLevel === "1" %}Editorial{% elif aiTextLevel === "2" %}Co-drafted{% elif aiTextLevel === "3" %}AI-generated{% endif %}{% if aiCodeLevel %} · Code {% if aiCodeLevel === "0" %}Human{% elif aiCodeLevel === "1" %}AI-assisted{% elif aiCodeLevel === "2" %}AI-generated{% endif %}{% endif %}{% if aiTools %} · {{ aiTools }}{% endif %}
</span>
</summary>
{% if aiDescription %}
<p class="mt-1 pl-5">{{ aiDescription }}</p>
{% endif %}
</details>
{# Pending syndication targets (for services like IndieNews that require u-syndication before webmention) #}
{% if mpSyndicateTo %}
<div class="hidden">
{% for url in mpSyndicateTo %}
{% if "news.indieweb.org" in url %}
<a href="{{ url }}" class="u-syndication" rel="syndication">IndieNews</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{# Syndication Footer - shows where this post was also published #}
{# Separate self-hosted AP URLs from external syndication targets #}
{% set externalSyndication = [] %}
{% set selfHostedApUrl = "" %}
{% if syndication %}
{% for url in syndication %}
{% if url.indexOf(site.url) == 0 %}
{% set selfHostedApUrl = url %}
{% else %}
{% set externalSyndication = (externalSyndication.push(url), externalSyndication) %}
{% endif %}
{% endfor %}
{% endif %}
{% if externalSyndication.length or selfHostedApUrl %}
<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">
{# Fediverse remote interaction button (self-hosted ActivityPub) #}
{% if selfHostedApUrl %}
<span x-data="fediverseInteract('{{ selfHostedApUrl }}', 'interact')" class="inline-flex">
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#a730b8]/10 text-[#a730b8] hover:bg-[#a730b8]/20 transition-colors text-sm font-medium cursor-pointer"
href="{{ selfHostedApUrl }}"
rel="syndication"
title="Interact from your fediverse instance (Shift+click to change)"
@click="handleClick($event)">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
<span>Fediverse</span>
</a>
{% set modalTitle = "Fediverse Interaction" %}
{% set modalDescription = "Choose your instance to like, boost, or reply." %}
{% include "components/fediverse-modal.njk" %}
</span>
{% endif %}
{# External syndication buttons #}
{% for url in externalSyndication %}
{% 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 site.feeds.mastodon.instance and site.feeds.mastodon.instance 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>
{% elif "linkedin.com" in url %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#0a66c2]/10 text-[#0a66c2] hover:bg-[#0a66c2]/20 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication" title="View on LinkedIn">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" 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"/>
</svg>
<span>LinkedIn</span>
</a>
{% elif "news.indieweb.org" in url %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#ff5c00]/10 text-[#ff5c00] hover:bg-[#ff5c00]/20 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication" title="View on IndieNews">
<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="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><line x1="10" y1="6" x2="18" y2="6"/><line x1="10" y1="10" x2="18" y2="10"/><line x1="10" y1="14" x2="14" y2="14"/>
</svg>
<span>IndieNews</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 #}
{# Handle photo as potentially an array #}
{% set postImage = photo %}
{% if postImage %}
{# If photo is an array, use first element (check if first element looks like a URL) #}
{% if postImage[0] and (postImage[0] | length) > 10 %}
{% set postImage = postImage[0] %}
{% endif %}
{% endif %}
{% if not postImage or postImage == "" %}
{% set postImage = image or (content | extractFirstImage) %}
{% endif %}
{% 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 and postImage != "" and (postImage | length) > 10 %},
"image": ["{% if postImage.startsWith('http') %}{{ postImage }}{% elif '/' in postImage and postImage[0] == '/' %}{{ site.url }}{{ postImage }}{% else %}{{ site.url }}/{{ postImage }}{% endif %}"]{% endif %}{% if aiTextLevel or aiCodeLevel or aiTools %},
"usageInfo": "{{ site.url }}/ai"{% set _aiParts = [] %}{% if aiTextLevel %}{% set _textLabel %}{% if aiTextLevel === "0" %}None{% elif aiTextLevel === "1" %}Editorial assistance{% elif aiTextLevel === "2" %}Co-drafting{% elif aiTextLevel === "3" %}AI-generated{% endif %}{% endset %}{% set _aiParts = (_aiParts.push("Text: " + _textLabel), _aiParts) %}{% endif %}{% if aiCodeLevel %}{% set _codeLabel %}{% if aiCodeLevel === "0" %}Human-written{% elif aiCodeLevel === "1" %}AI-assisted{% elif aiCodeLevel === "2" %}AI-generated{% endif %}{% endset %}{% set _aiParts = (_aiParts.push("Code: " + _codeLabel), _aiParts) %}{% endif %}{% if aiTools %}{% set _aiParts = (_aiParts.push("Tools: " + aiTools), _aiParts) %}{% endif %},
"creativeWorkStatus": "{{ _aiParts | join(', ') }}"{% endif %}
}
</script>
{# Lightbox overlay for article images #}
<template x-teleport="body">
<div x-show="open" x-transition.opacity.duration.200ms
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90 backdrop-blur-sm"
@click.self="close()">
<button @click="close()" class="absolute top-4 right-4 text-white/70 hover:text-white text-3xl leading-none p-2 z-10" aria-label="Close">&times;</button>
<template x-if="images.length > 1">
<button @click="prev()" class="absolute left-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white text-4xl leading-none p-2 z-10" aria-label="Previous">&lsaquo;</button>
</template>
<template x-if="images.length > 1">
<button @click="next()" class="absolute right-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white text-4xl leading-none p-2 z-10" aria-label="Next">&rsaquo;</button>
</template>
<img :src="src" :alt="alt" class="max-h-[90vh] max-w-[90vw] object-contain" @click.stop>
<div x-show="alt" x-text="alt" class="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/80 text-sm max-w-2xl text-center px-4 py-2 bg-black/50 rounded-lg"></div>
</div>
</template>
</article>
{# Comments section #}
{% include "components/comments.njk" %}
{# Webmentions display - likes, reposts, replies #}
{% include "components/webmentions.njk" %}
{# Post Navigation - Previous/Next #}
{% include "components/post-navigation.njk" %}
+69
View File
@@ -0,0 +1,69 @@
---
layout: layouts/base.njk
title: About
permalink: false
eleventyExcludeFromCollections: true
---
<article class="h-card">
<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row gap-6 sm:gap-8 items-start">
<img
src="{{ site.author.avatar }}"
alt="{{ site.author.name }}"
class="u-photo w-32 h-32 sm:w-40 sm:h-40 rounded-full object-cover shadow-lg flex-shrink-0"
loading="eager"
>
<div class="min-w-0">
<h1 class="p-name text-2xl sm: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-accent-600 dark:text-accent-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>
+100
View File
@@ -0,0 +1,100 @@
---
layout: layouts/base.njk
title: Articles
withSidebar: true
pagination:
data: collections.articles
size: 20
alias: paginatedArticles
generatePageOnEmptyData: true
permalink: "articles/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
---
<div class="h-feed">
<div class="flex flex-wrap items-center gap-4 mb-2">
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100">Articles</h1>
{% set sparklineSvg = collections.articles | postingFrequency %}
{% if sparklineSvg %}
<span class="text-amber-600 dark:text-amber-400">{{ sparklineSvg | safe }}</span>
{% endif %}
</div>
<p class="text-surface-600 dark:text-surface-400 mb-6 sm: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 border-l-[3px] border-l-surface-300 dark:border-l-surface-600">
<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-accent-600 dark:hover:text-accent-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-accent-600 dark:text-accent-400 hover:underline mt-3 inline-block">
Read more &rarr;
</a>
</li>
{% endfor %}
</ul>
{# Pagination controls #}
{% if pagination.pages.length > 1 %}
<nav class="pagination" aria-label="Articles pagination">
<div class="pagination-info">
Page {{ pagination.pageNumber + 1 }} of {{ pagination.pages.length }}
</div>
<div class="pagination-links">
{% if pagination.href.previous %}
<a href="{{ pagination.href.previous }}" class="pagination-link" aria-label="Previous page">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
Previous
</a>
{% else %}
<span class="pagination-link disabled">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
Previous
</span>
{% endif %}
{% if pagination.href.next %}
<a href="{{ pagination.href.next }}" class="pagination-link" aria-label="Next page">
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</a>
{% else %}
<span class="pagination-link disabled">
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</span>
{% endif %}
</div>
</nav>
{% endif %}
{% else %}
{% set postType = "article" %}
{% include "components/empty-collection.njk" %}
{% endif %}
</div>
+389
View File
@@ -0,0 +1,389 @@
---
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">
<div class="flex flex-wrap items-center gap-4 mb-2">
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100">Blog</h1>
{% set sparklineSvg = collections.posts | postingFrequency %}
{% if sparklineSvg %}
<span class="text-amber-600 dark:text-amber-400">{{ sparklineSvg | safe }}</span>
{% endif %}
</div>
<p class="text-surface-600 dark:text-surface-400 mb-6 sm:mb-8">
All posts including articles and notes.
<span class="text-sm">({{ collections.posts.length }} total)</span>
</p>
{% if paginatedPosts.length > 0 %}
<filter-container oninit leave-url-alone>
<div class="flex flex-wrap gap-3 mb-6">
<select data-filter-key="type" class="px-3 py-1.5 text-sm bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg">
<option value="">All Types</option>
<option value="article">Articles</option>
<option value="note">Notes</option>
<option value="photo">Photos</option>
<option value="bookmark">Bookmarks</option>
<option value="like">Likes</option>
<option value="reply">Replies</option>
<option value="repost">Reposts</option>
</select>
<span data-filter-results class="text-sm text-surface-500 dark:text-surface-400 self-center"></span>
</div>
<ul class="post-list">
{% for post in paginatedPosts %}
{# Detect post type from frontmatter properties #}
{% set likedUrl = post.data.likeOf or post.data.like_of %}
{% set bookmarkedUrl = post.data.bookmarkOf or post.data.bookmark_of %}
{% set repostedUrl = post.data.repostOf or post.data.repost_of %}
{% set replyToUrl = post.data.inReplyTo or post.data.in_reply_to %}
{% set hasPhotos = post.data.photo and post.data.photo.length %}
{% set _postType %}{% if likedUrl %}like{% elif bookmarkedUrl %}bookmark{% elif repostedUrl %}repost{% elif replyToUrl %}reply{% elif hasPhotos %}photo{% elif post.data.title %}article{% else %}note{% endif %}{% endset %}
{% set borderClass = "" %}
{% if likedUrl %}
{% set borderClass = "border-l-[3px] border-l-red-400 dark:border-l-red-500" %}
{% elif bookmarkedUrl %}
{% set borderClass = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
{% elif repostedUrl %}
{% set borderClass = "border-l-[3px] border-l-green-400 dark:border-l-green-500" %}
{% elif replyToUrl %}
{% set borderClass = "border-l-[3px] border-l-sky-400 dark:border-l-sky-500" %}
{% elif hasPhotos %}
{% set borderClass = "border-l-[3px] border-l-purple-400 dark:border-l-purple-500" %}
{% else %}
{% set borderClass = "border-l-[3px] border-l-surface-300 dark:border-l-surface-600" %}
{% endif %}
<li class="h-entry post-card {{ borderClass }}" data-filter-type="{{ _postType | trim }}">
{% if likedUrl %}
{# ── Like card ── #}
<div class="post-header flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 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">
<span class="font-medium text-red-600 dark:text-red-400">Liked</span>
<time-difference><time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time></time-difference>
{% 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>
{% unfurl likedUrl %}
<a class="u-like-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ likedUrl }}">
{{ likedUrl }}
</a>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-sm text-accent-600 dark:text-accent-400 hover:underline mt-3 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif bookmarkedUrl %}
{# ── Bookmark card ── #}
<div class="post-header flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="post-meta">
<span class="font-medium text-amber-600 dark:text-amber-400">Bookmarked</span>
<time-difference><time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time></time-difference>
{% 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>
{% if post.data.title %}
<h2 class="p-name text-lg font-semibold text-surface-900 dark:text-surface-100 mt-2">
<a class="hover:text-accent-600 dark:hover:text-accent-400" href="{{ post.url }}">{{ post.data.title }}</a>
</h2>
{% endif %}
{% unfurl bookmarkedUrl %}
<a class="u-bookmark-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ bookmarkedUrl }}">
{{ bookmarkedUrl }}
</a>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-sm text-accent-600 dark:text-accent-400 hover:underline mt-3 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif repostedUrl %}
{# ── Repost card ── #}
<div class="post-header flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 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">
<div class="post-meta">
<span class="font-medium text-green-600 dark:text-green-400">Reposted</span>
<time-difference><time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time></time-difference>
{% 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>
{% unfurl repostedUrl %}
<a class="u-repost-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ repostedUrl }}">
{{ repostedUrl }}
</a>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-sm text-accent-600 dark:text-accent-400 hover:underline mt-3 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif replyToUrl %}
{# ── Reply card ── #}
<div class="post-header flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 text-sky-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">
<div class="post-meta">
<span class="font-medium text-sky-600 dark:text-sky-400">In reply to</span>
<time-difference><time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time></time-difference>
{% 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>
{% unfurl replyToUrl %}
<a class="u-in-reply-to text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ replyToUrl }}">
{{ replyToUrl }}
</a>
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
{{ post.templateContent | safe }}
</div>
<a class="u-url text-sm text-accent-600 dark:text-accent-400 hover:underline mt-3 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif hasPhotos %}
{# ── Photo card ── #}
<div class="post-header flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<svg class="w-5 h-5 text-purple-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 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="post-meta">
<span class="font-medium text-purple-600 dark:text-purple-400">Photo</span>
<time-difference><time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time></time-difference>
{% 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 class="photo-gallery mt-3">
{% for img in post.data.photo %}
{% set photoUrl = img.url %}
{% if photoUrl and photoUrl[0] != '/' and 'http' not in photoUrl %}
{% set photoUrl = '/' + photoUrl %}
{% endif %}
<a href="{{ post.url }}" class="photo-link">
<img src="{{ photoUrl }}" alt="{{ img.alt | default('Photo') }}" class="u-photo" loading="lazy" eleventy:ignore>
</a>
{% endfor %}
</div>
{% if post.templateContent %}
<div class="e-content photo-caption prose dark:prose-invert prose-sm mt-3 max-w-none">
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-sm text-accent-600 dark:text-accent-400 hover:underline mt-3 inline-block" href="{{ post.url }}">Permalink</a>
</div>
</div>
{% elif post.data.title %}
{# ── Article card (unchanged) ── #}
<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-accent-600 dark:hover:text-accent-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-accent-600 dark:text-accent-400 hover:underline mt-3 inline-block">
Read more &rarr;
</a>
{% else %}
{# ── Note card (unchanged) ── #}
<div class="post-header">
<a class="u-url" href="{{ post.url }}">
<time-difference><time class="dt-published text-sm text-surface-500 dark:text-surface-400 font-medium" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time></time-difference>
</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-accent-600 dark:text-accent-400 hover:underline">
Permalink
</a>
</div>
{% endif %}
{# AI usage badge — only show when AI was actually used (level > 0) #}
{% set postAiText = post.data.aiTextLevel or post.data.ai_text_level %}
{% set postAiCode = post.data.aiCodeLevel or post.data.ai_code_level %}
{% if (postAiText and postAiText !== "0") or (postAiCode and postAiCode !== "0") %}
<span class="inline-flex items-center gap-1 mt-2 px-1.5 py-0.5 rounded text-[10px] font-medium bg-surface-100 dark:bg-surface-700 text-surface-500 dark:text-surface-400" title="AI usage: Text level {{ postAiText or '' }}, Code level {{ postAiCode or '' }}">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714a2.25 2.25 0 00.659 1.591L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-2.47 2.47a2.25 2.25 0 01-1.59.659H9.06a2.25 2.25 0 01-1.591-.659L5 14.5m14 0V17a2 2 0 01-2 2H7a2 2 0 01-2-2v-2.5"/></svg>
AI{% if postAiText %}: T{{ postAiText }}{% endif %}{% if postAiCode %}/C{{ postAiCode }}{% endif %}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
</filter-container>
{# 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-accent-600 dark:text-accent-400 hover:underline">Quill</a> - Web-based</li>
<li><a href="https://indiepass.app" class="text-accent-600 dark:text-accent-400 hover:underline">IndiePass</a> - Mobile app</li>
<li><a href="https://micropublish.net" class="text-accent-600 dark:text-accent-400 hover:underline">Micropublish</a> - Web-based</li>
</ul>
{% endif %}
</div>
+109
View File
@@ -0,0 +1,109 @@
---
layout: layouts/base.njk
title: Bookmarks
withSidebar: true
pagination:
data: collections.bookmarks
size: 20
alias: paginatedBookmarks
generatePageOnEmptyData: true
permalink: "bookmarks/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
---
<div class="h-feed">
<div class="flex flex-wrap items-center gap-4 mb-2">
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100">Bookmarks</h1>
{% set sparklineSvg = collections.bookmarks | postingFrequency %}
{% if sparklineSvg %}
<span class="text-amber-600 dark:text-amber-400">{{ sparklineSvg | safe }}</span>
{% endif %}
</div>
<p class="text-surface-600 dark:text-surface-400 mb-6 sm:mb-8">
Links I've saved for later.
<span class="text-sm">({{ collections.bookmarks.length }} total)</span>
</p>
{% if paginatedBookmarks.length > 0 %}
<ul class="post-list">
{% for post in paginatedBookmarks %}
<li class="h-entry post-card border-l-[3px] border-l-amber-400 dark:border-l-amber-500">
<div class="post-header">
{% if post.data.title %}
<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-amber-600 dark:hover:text-amber-400" href="{{ post.url }}">
{{ post.data.title }}
</a>
</h2>
{% endif %}
</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>
{# Support both camelCase (Indiekit Eleventy preset) and underscore (legacy) property names #}
{% set bookmarkedUrl = post.data.bookmarkOf or post.data.bookmark_of %}
{% if bookmarkedUrl %}
{% unfurl bookmarkedUrl %}
<a class="u-bookmark-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ bookmarkedUrl }}">
{{ bookmarkedUrl }}
</a>
{% endif %}
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
{{ post.templateContent | safe }}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{# Pagination controls #}
{% if pagination.pages.length > 1 %}
<nav class="pagination" aria-label="Bookmarks 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 %}
{% set postType = "bookmark" %}
{% include "components/empty-collection.njk" %}
{% endif %}
</div>
+30
View File
@@ -0,0 +1,30 @@
---
layout: layouts/base.njk
title: Categories
withSidebar: true
permalink: categories/
eleventyImport:
collections:
- categories
---
<div>
<h1 class="text-2xl sm: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-6 sm: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-accent-100 dark:hover:bg-accent-900 hover:text-accent-700 dark:hover:text-accent-300 transition-colors">
{{ cat }}
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No categories yet.</p>
{% endif %}
</div>
+83
View File
@@ -0,0 +1,83 @@
---
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-2xl sm: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-6 sm: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 %}
{% set postType = post.inputPath | replace("./content/", "") %}
{% set postType = postType.split("/")[0] %}
{% set borderClass = "" %}
{% if postType == "likes" %}
{% set borderClass = "border-l-[3px] border-l-red-400 dark:border-l-red-500" %}
{% elif postType == "bookmarks" %}
{% set borderClass = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
{% elif postType == "reposts" %}
{% set borderClass = "border-l-[3px] border-l-green-400 dark:border-l-green-500" %}
{% elif postType == "replies" %}
{% set borderClass = "border-l-[3px] border-l-accent-400 dark:border-l-accent-500" %}
{% elif postType == "photos" %}
{% set borderClass = "border-l-[3px] border-l-purple-400 dark:border-l-purple-500" %}
{% else %}
{% set borderClass = "border-l-[3px] border-l-surface-300 dark:border-l-surface-600" %}
{% endif %}
<li class="h-entry post-card {{ borderClass }}">
<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-accent-600 dark:hover:text-accent-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>
<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-accent-600 dark:text-accent-400 hover:underline mt-3 inline-block">
View &rarr;
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No posts found with this category.</p>
{% endif %}
<div class="mt-8">
<a href="/categories/" class="text-accent-600 dark:text-accent-400 hover:underline">&larr; All categories</a>
</div>
</div>
+69
View File
@@ -0,0 +1,69 @@
---
eleventyExcludeFromCollections: true
eleventyImport:
collections:
- categoryFeeds
pagination:
data: collections.categoryFeeds
size: 1
alias: categoryFeed
permalink: "categories/{{ categoryFeed.slug }}/feed.json"
---
{
"version": "https://jsonfeed.org/version/1.1",
"title": "{{ site.name }} — {{ categoryFeed.name }}",
"home_page_url": "{{ site.url }}/categories/{{ categoryFeed.slug }}/",
"feed_url": "{{ site.url }}/categories/{{ categoryFeed.slug }}/feed.json",
"hubs": [
{
"type": "WebSub",
"url": "https://websubhub.com/hub"
}
],
"description": "Posts tagged with \"{{ categoryFeed.name }}\" on {{ site.name }}",
"language": "{{ site.locale | default('en') }}",
"authors": [
{
"name": "{{ site.author.name | default(site.name) }}",
"url": "{{ site.url }}/"
}
],
"_textcasting": {
"version": "1.0",
"about": "https://textcasting.org/"
{%- set hasSupport = site.support and (site.support.url or site.support.stripe or site.support.lightning or site.support.paymentPointer) %}
{%- if hasSupport %},
"support": {{ site.support | textcastingSupport | jsonEncode | safe }}
{%- endif %}
},
"items": [
{%- for post in categoryFeed.posts %}
{%- set absolutePostUrl = site.url + post.url %}
{%- set postImage = post.data.photo %}
{%- if postImage %}
{%- if postImage[0] and (postImage[0] | length) > 10 %}
{%- set postImage = postImage[0] %}
{%- endif %}
{%- endif %}
{%- if not postImage or postImage == "" %}
{%- set postImage = post.data.image or (post.content | extractFirstImage) %}
{%- endif %}
{
"id": "{{ absolutePostUrl }}",
"url": "{{ absolutePostUrl }}",
"title": {% if post.data.title %}{{ post.data.title | jsonEncode | safe }}{% else %}null{% endif %},
"content_html": {{ post.content | htmlToAbsoluteUrls(absolutePostUrl) | jsonEncode | safe }},
"content_text": {{ post.content | striptags | jsonEncode | safe }},
"date_published": "{{ post.date | dateToRfc3339 }}",
"date_modified": "{{ (post.data.updated or post.date) | dateToRfc3339 }}"
{%- if postImage and postImage != "" and (postImage | length) > 10 %},
"image": "{{ postImage | url | absoluteUrl(site.url) }}"
{%- endif %}
{%- set attachments = post.data | feedAttachments %}
{%- if attachments.length > 0 %},
"attachments": {{ attachments | jsonEncode | safe }}
{%- endif %}
}{% if not loop.last %},{% endif %}
{%- endfor %}
]
}
+47
View File
@@ -0,0 +1,47 @@
---
eleventyExcludeFromCollections: true
eleventyImport:
collections:
- categoryFeeds
pagination:
data: collections.categoryFeeds
size: 1
alias: categoryFeed
permalink: "categories/{{ categoryFeed.slug }}/feed.xml"
---
<?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 }} — {{ categoryFeed.name }}</title>
<link>{{ site.url }}/categories/{{ categoryFeed.slug }}/</link>
<description>Posts tagged with "{{ categoryFeed.name }}" on {{ site.name }}</description>
<language>{{ site.locale | default('en') }}</language>
<atom:link href="{{ site.url }}/categories/{{ categoryFeed.slug }}/feed.xml" rel="self" type="application/rss+xml"/>
<atom:link href="https://websubhub.com/hub" rel="hub"/>
<lastBuildDate>{{ categoryFeed.posts | getNewestCollectionItemDate | dateToRfc822 }}</lastBuildDate>
{%- for post in categoryFeed.posts %}
{%- set absolutePostUrl = site.url + post.url %}
{%- set postImage = post.data.photo %}
{%- if postImage %}
{%- if postImage[0] and (postImage[0] | length) > 10 %}
{%- set postImage = postImage[0] %}
{%- endif %}
{%- endif %}
{%- if not postImage or postImage == "" %}
{%- set postImage = post.data.image or (post.content | extractFirstImage) %}
{%- endif %}
<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 and postImage != "" and (postImage | length) > 10 %}
{%- 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>
+208
View File
@@ -0,0 +1,208 @@
---
layout: layouts/base.njk
title: Changelog
permalink: /changelog/
eleventyExcludeFromCollections: true
pagefindIgnore: true
withSidebar: false
---
<div class="page-header mb-6 sm:mb-8">
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Changelog</h1>
<p class="text-surface-600 dark:text-surface-400">Development activity across all Indiekit repositories.</p>
</div>
<div x-data="changelogApp()" x-init="init()">
{# Tab navigation #}
<div class="flex gap-1 mb-6 border-b border-surface-200 dark:border-surface-700 overflow-x-auto">
<template x-for="tab in tabs" :key="tab.key">
<button
@click="activeTab = tab.key"
:class="activeTab === tab.key ? 'border-b-2 border-accent-500 text-accent-600 dark:text-accent-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 whitespace-nowrap flex-shrink-0"
>
<span x-text="tab.label"></span>
<span
x-show="getCount(tab.key) > 0"
x-text="getCount(tab.key)"
class="text-xs px-1.5 py-0.5 rounded-full bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-400"
></span>
</button>
</template>
</div>
{# Loading state #}
<div x-show="loading" class="flex items-center justify-center py-12">
<svg class="animate-spin h-6 w-6 text-accent-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span class="ml-3 text-surface-500">Loading changelog...</span>
</div>
{# Commit list #}
<div x-show="!loading" x-cloak>
<template x-if="filteredCommits().length === 0">
<p class="text-surface-500 py-8 text-center">No recent activity in this category.</p>
</template>
<ul class="space-y-4">
<template x-for="commit in filteredCommits()" :key="commit.fullSha">
<li class="border border-surface-200 dark:border-surface-700 rounded-lg p-4">
<div class="flex items-start gap-3">
<a :href="commit.url" target="_blank" rel="noopener"
class="font-mono text-xs bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded text-accent-600 dark:text-accent-400 hover:underline flex-shrink-0 mt-0.5"
x-text="commit.sha"></a>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-surface-900 dark:text-surface-100 break-words" x-text="commit.title"></p>
<div class="flex flex-wrap items-center gap-2 mt-2">
<span
class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="categoryColors[commit.category]"
x-text="categoryLabels[commit.category] || commit.category"
></span>
<a :href="commit.repoUrl" target="_blank" rel="noopener"
class="text-xs px-2 py-0.5 rounded-full bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-400 hover:text-accent-600 dark:hover:text-accent-400"
x-text="commit.repoName"></a>
<span class="text-xs text-surface-500" x-text="formatDate(commit.date)"></span>
<span class="text-xs text-surface-400" x-text="'by ' + commit.author"></span>
</div>
<template x-if="commit.body">
<details class="mt-2">
<summary class="text-xs text-surface-500 cursor-pointer hover:text-surface-700 dark:hover:text-surface-300">Show details</summary>
<pre class="mt-1 text-xs text-surface-600 dark:text-surface-400 whitespace-pre-wrap break-words bg-surface-50 dark:bg-surface-800 rounded p-2" x-text="commit.body"></pre>
</details>
</template>
</div>
</div>
</li>
</template>
</ul>
{# Load more button #}
<div x-show="canLoadMore" class="mt-8 text-center">
<button
@click="loadMore()"
:disabled="loadingMore"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-surface-300 dark:border-surface-600 text-surface-700 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors disabled:opacity-50"
>
<svg x-show="loadingMore" class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span x-text="loadingMore ? 'Loading...' : 'Load older commits'"></span>
</button>
</div>
{# Summary #}
<div x-show="commits.length > 0" class="mt-6 text-center text-xs text-surface-400">
<span x-text="commits.length + ' commits'"></span>
<span x-show="currentDays !== 'all'"> from the last <span x-text="currentDays"></span> days</span>
<span x-show="currentDays === 'all'"> (all time)</span>
</div>
</div>
</div>
<script>
function changelogApp() {
return {
activeTab: 'all',
loading: true,
loadingMore: false,
commits: [],
categories: {},
currentDays: 30,
daysProgression: [30, 90, 180, 'all'],
tabs: [
{ key: 'all', label: 'All' },
{ key: 'core', label: 'Core' },
{ key: 'deployment', label: 'Deployment' },
{ key: 'theme', label: 'Theme' },
{ key: 'endpoints', label: 'Endpoints' },
{ key: 'syndicators', label: 'Syndicators' },
{ key: 'post-types', label: 'Post Types' },
{ key: 'presets', label: 'Presets' },
],
categoryLabels: {
core: 'Core',
deployment: 'Deployment',
theme: 'Theme',
endpoints: 'Endpoint',
syndicators: 'Syndicator',
'post-types': 'Post Type',
presets: 'Preset',
other: 'Other',
},
categoryColors: {
core: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300',
deployment: 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300',
theme: 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300',
endpoints: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
syndicators: 'bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-300',
'post-types': 'bg-pink-100 dark:bg-pink-900 text-pink-700 dark:text-pink-300',
presets: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300',
other: 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300',
},
get canLoadMore() {
const idx = this.daysProgression.indexOf(this.currentDays);
return idx >= 0 && idx < this.daysProgression.length - 1;
},
async init() {
await this.fetchChangelog(30);
},
async fetchChangelog(days) {
try {
const response = await fetch('/githubapi/api/changelog?days=' + days);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
this.commits = data.commits || [];
this.categories = data.categories || {};
this.currentDays = data.days;
} catch (err) {
console.error('Changelog error:', err);
} finally {
this.loading = false;
this.loadingMore = false;
}
},
async loadMore() {
const idx = this.daysProgression.indexOf(this.currentDays);
if (idx < 0 || idx >= this.daysProgression.length - 1) return;
const nextDays = this.daysProgression[idx + 1];
this.loadingMore = true;
await this.fetchChangelog(nextDays);
},
filteredCommits() {
if (this.activeTab === 'all') return this.commits;
return this.commits.filter(c => c.category === this.activeTab);
},
getCount(tabKey) {
if (tabKey === 'all') return this.commits.length;
return this.commits.filter(c => c.category === tabKey).length;
},
formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
const now = new Date();
const diffMs = now - d;
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffHours < 1) return 'just now';
if (diffHours < 24) return diffHours + 'h ago';
if (diffDays < 7) return diffDays + 'd ago';
if (diffDays < 30) return Math.floor(diffDays / 7) + 'w ago';
return d.toLocaleDateString('en', { month: 'short', day: 'numeric', year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined });
}
};
}
</script>
+28
View File
@@ -0,0 +1,28 @@
---
layout: layouts/base.njk
title: ChardonsBleus
permalink: /chardonsbleus/
eleventyExcludeFromCollections: true
---
<article>
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-6">ChardonsBleus</h1>
<div class="w-full">
<link rel="stylesheet" href="https://embedbsky.com/embedbsky.com-master-min.css" />
<div id="embedbsky-com-timeline-embed"></div>
<script>
let containerWidth = 0, containerHeight = 800;
const getHtml = async (url) => {
const resp = await fetch(url);
return 200 !== resp.status ? '<p><strong>No feed data could be located</strong></p>' : resp.text();
};
document.addEventListener('DOMContentLoaded', async () => {
const timestamp = (new Date).toISOString();
const el = document.getElementById('embedbsky-com-timeline-embed');
el.style.width = "100%";
el.style.height = containerHeight + "px";
const html = await getHtml("https://embedbsky.com/feeds/b623eb5af0b6fb7966040b19bf734f8e11eee859965836a60fe5536a1f01990a.html?v=" + timestamp);
el.innerHTML = html;
});
</script>
</div>
</article>
+57
View File
@@ -0,0 +1,57 @@
/* Critical CSS — inlined in <head> for first paint */
/* Covers: layout shell, header, dark mode toggle, font display, basic typography */
*,*::before,*::after{box-sizing:border-box}
body{margin:0;font-family:"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;line-height:1.5;-webkit-font-smoothing:antialiased}
/* Dark mode base — warm stone palette */
body{background-color:#faf8f5;color:#1c1b19}
.dark body{background-color:#0f0e0d;color:#faf8f5}
/* Container */
.container{max-width:64rem;margin-left:auto;margin-right:auto;padding-left:1rem;padding-right:1rem}
/* Header — sticky, visible immediately */
.site-header{background-color:#faf8f5;border-bottom:1px solid #e8e5df;padding-top:1rem;padding-bottom:1rem;position:sticky;top:0;z-index:50}
.dark .site-header{background-color:#1c1b19;border-bottom-color:#3f3b35}
.header-container{display:flex;align-items:center;justify-content:space-between}
.site-title{font-size:1.25rem;font-weight:700;color:#1c1b19;text-decoration:none}
.dark .site-title{color:#faf8f5}
/* Header actions — hidden on mobile */
.header-actions{display:none}
@media(min-width:768px){.header-actions{display:flex;align-items:center;gap:1rem}}
/* Mobile menu toggle */
.menu-toggle{display:block;padding:0.5rem;border-radius:0.5rem;background:none;border:none;color:#5c5750;cursor:pointer}
@media(min-width:768px){.menu-toggle{display:none}}
.dark .menu-toggle{color:#a09a90}
/* Hidden utility */
.hidden{display:none!important}
[x-cloak]{display:none!important}
/* Dark mode theme toggle icons */
.theme-toggle .sun-icon{display:none}
.theme-toggle .moon-icon{display:block}
.dark .theme-toggle .sun-icon{display:block}
.dark .theme-toggle .moon-icon{display:none}
/* Main content padding */
main.container{padding-top:1.5rem;padding-bottom:1.5rem}
@media(min-width:768px){main.container{padding-top:2rem;padding-bottom:2rem}}
/* Layout with sidebar */
.layout-with-sidebar{display:grid;grid-template-columns:1fr;gap:1.5rem}
@media(min-width:1024px){.layout-with-sidebar{grid-template-columns:2fr 1fr;gap:2rem}}
.main-content{min-width:0;overflow-x:hidden}
/* Basic typography — prevent FOUT */
h1,h2,h3,h4{margin:0;line-height:1.25}
a{color:#b45309}
.dark a{color:#fbbf24}
/* Prevent flash of unstyled content for nav */
.site-nav{display:flex;align-items:center;gap:1rem}
.site-nav>a,.site-nav .nav-dropdown-trigger{color:#5c5750;text-decoration:none;padding-top:0.5rem;padding-bottom:0.5rem}
.dark .site-nav>a,.dark .site-nav .nav-dropdown-trigger{color:#a09a90}
+95
View File
@@ -0,0 +1,95 @@
lite-youtube {
background-color: #000;
position: relative;
display: block;
contain: content;
background-position: center center;
background-size: cover;
cursor: pointer;
max-width: 720px;
}
/* gradient */
lite-youtube::before {
content: attr(data-title);
display: block;
position: absolute;
top: 0;
/* Pixel-perfect port of YT's gradient PNG, using https://github.com/bluesmoon/pngtocss plus optimizations */
background-image: linear-gradient(180deg, rgb(0 0 0 / 67%) 0%, rgb(0 0 0 / 54%) 14%, rgb(0 0 0 / 15%) 54%, rgb(0 0 0 / 5%) 72%, rgb(0 0 0 / 0%) 94%);
height: 99px;
width: 100%;
font-family: "YouTube Noto",Roboto,Arial,Helvetica,sans-serif;
color: hsl(0deg 0% 93.33%);
text-shadow: 0 0 2px rgba(0,0,0,.5);
font-size: 18px;
padding: 25px 20px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
box-sizing: border-box;
}
lite-youtube:hover::before {
color: white;
}
/* responsive iframe with a 16:9 aspect ratio
thanks https://css-tricks.com/responsive-iframes/
*/
lite-youtube::after {
content: "";
display: block;
padding-bottom: calc(100% / (16 / 9));
}
lite-youtube > iframe {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
border: 0;
}
/* play button */
lite-youtube > .lty-playbtn {
display: block;
/* Make the button element cover the whole area for a large hover/click target… */
width: 100%;
height: 100%;
/* …but visually it's still the same size */
background: no-repeat center/68px 48px;
/* YT's actual play button svg */
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="red"/><path d="M45 24 27 14v20" fill="white"/></svg>');
position: absolute;
cursor: pointer;
z-index: 1;
filter: grayscale(100%);
transition: filter .1s cubic-bezier(0, 0, 0.2, 1);
border: 0;
}
lite-youtube:hover > .lty-playbtn,
lite-youtube .lty-playbtn:focus {
filter: none;
}
/* Post-click styles */
lite-youtube.lyt-activated {
cursor: unset;
}
lite-youtube.lyt-activated::before,
lite-youtube.lyt-activated > .lty-playbtn {
opacity: 0;
pointer-events: none;
}
.lyt-visually-hidden {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
+201
View File
@@ -0,0 +1,201 @@
/* Syntax Highlighting PrismJS theme for indiekit-eleventy-theme
Light mode: clean, high-contrast colors
Dark mode: scoped under .dark (Tailwind darkMode: "class") */
/* ── Base code block styling ── */
code[class*="language-"],
pre[class*="language-"] {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.875em;
line-height: 1.7;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
tab-size: 2;
hyphens: none;
}
pre[class*="language-"] {
padding: 1.25em;
margin: 1.5em 0;
overflow: auto;
border-radius: 0.5rem;
}
:not(pre) > code[class*="language-"] {
padding: 0.2em 0.4em;
border-radius: 0.25rem;
white-space: normal;
}
/* ── Light Mode ── */
code[class*="language-"],
pre[class*="language-"] {
color: #24292e;
}
pre[class*="language-"] {
background: #f4f2ee;
border: 1px solid #e1e4e8;
}
:not(pre) > code[class*="language-"] {
background: #f4f2ee;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #6a737d;
}
.token.punctuation {
color: #24292e;
}
.token.namespace {
opacity: 0.7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #005cc5;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #032f62;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #d73a49;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #d73a49;
}
.token.function,
.token.class-name {
color: #6f42c1;
}
.token.regex,
.token.important,
.token.variable {
color: #e36209;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
/* Line highlighting */
.highlight-line-active {
background-color: #fffbdd;
display: inline-block;
width: calc(100% + 2.5em);
margin-left: -1.25em;
padding-left: 1.25em;
}
/* ── Dark Mode ── */
.dark code[class*="language-"],
.dark pre[class*="language-"] {
color: #e1e4e8;
}
.dark pre[class*="language-"] {
background: #161b22;
border-color: #30363d;
}
.dark :not(pre) > code[class*="language-"] {
background: #161b22;
}
.dark .token.comment,
.dark .token.prolog,
.dark .token.doctype,
.dark .token.cdata {
color: #8b949e;
}
.dark .token.punctuation {
color: #e1e4e8;
}
.dark .token.property,
.dark .token.tag,
.dark .token.boolean,
.dark .token.number,
.dark .token.constant,
.dark .token.symbol,
.dark .token.deleted {
color: #79c0ff;
}
.dark .token.selector,
.dark .token.attr-name,
.dark .token.string,
.dark .token.char,
.dark .token.builtin,
.dark .token.inserted {
color: #a5d6ff;
}
.dark .token.operator,
.dark .token.entity,
.dark .token.url,
.dark .language-css .token.string,
.dark .style .token.string {
color: #ff7b72;
}
.dark .token.atrule,
.dark .token.attr-value,
.dark .token.keyword {
color: #ff7b72;
}
.dark .token.function,
.dark .token.class-name {
color: #d2a8ff;
}
.dark .token.regex,
.dark .token.important,
.dark .token.variable {
color: #ffa657;
}
.dark .highlight-line-active {
background-color: rgba(56, 139, 253, 0.15);
}
+986
View File
@@ -0,0 +1,986 @@
/* Inter font — latin + latin-ext subsets, weights 400/500/600/700 */
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(/fonts/inter-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(/fonts/inter-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(/fonts/inter-latin-ext-500-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(/fonts/inter-latin-500-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(/fonts/inter-latin-ext-600-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(/fonts/inter-latin-600-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(/fonts/inter-latin-ext-700-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(/fonts/inter-latin-700-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@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-accent-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;
}
}
/* Body background — warm stone canvas */
@layer base {
body {
@apply bg-surface-50 dark:bg-surface-950 text-surface-900 dark:text-surface-100;
}
/* P1: Date typography — all <time> elements get monospace for technical texture */
time {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
/* P2: Focus-visible states — keyboard-only focus rings for accessibility (WCAG 2.4.7) */
button:focus-visible,
[role="button"]:focus-visible,
summary:focus-visible {
@apply outline-none ring-2 ring-amber-500/70 ring-offset-2 ring-offset-surface-50 rounded-sm;
}
.dark button:focus-visible,
.dark [role="button"]:focus-visible,
.dark summary:focus-visible {
@apply ring-offset-surface-900;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
@apply outline-none ring-2 ring-amber-500/70 border-transparent;
}
a:focus-visible {
@apply outline-none ring-2 ring-amber-500/70 ring-offset-1 ring-offset-surface-50 rounded-sm;
}
.dark a:focus-visible {
@apply ring-offset-surface-900;
}
}
/* Layout styles */
@layer components {
/* Site header */
.site-header {
@apply bg-surface-50 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-surface-600 dark:hover:text-surface-300 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,
.site-nav .nav-dropdown-trigger {
@apply text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 no-underline transition-colors py-2;
}
/* Navigation dropdown */
.nav-dropdown {
@apply relative;
}
.nav-dropdown-trigger {
@apply flex items-center gap-1 cursor-pointer bg-transparent border-none text-base;
}
.nav-dropdown-menu {
@apply absolute top-full left-0 mt-1 py-2 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg shadow-lg min-w-[160px] z-50 overflow-y-auto;
max-height: calc(100vh - 5rem);
max-height: calc(100dvh - 5rem);
}
.nav-dropdown-menu a {
@apply block px-4 py-2 text-sm text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 hover:text-surface-900 dark:hover:text-surface-100 no-underline;
}
.nav-dropdown-divider {
@apply my-2 border-t border-surface-200 dark:border-surface-700;
}
/* 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-surface-50 dark:bg-surface-900 overflow-y-auto;
max-height: calc(100vh - 4rem);
max-height: calc(100dvh - 4rem);
}
.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-surface-900 dark:hover:text-surface-100 no-underline transition-colors border-b border-surface-100 dark:border-surface-800 last:border-b-0;
}
/* Mobile nav collapsible sections */
.mobile-nav-section {
@apply border-b border-surface-100 dark:border-surface-800;
}
.mobile-nav-toggle {
@apply flex items-center justify-between w-full px-4 py-3 text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-100 transition-colors bg-transparent border-none text-base text-left cursor-pointer;
}
.mobile-nav-submenu {
@apply bg-surface-50 dark:bg-surface-800;
}
.mobile-nav-submenu a {
@apply pl-8 py-2 text-sm border-b-0;
}
.mobile-nav-divider {
@apply my-2 mx-4 border-t border-surface-200 dark:border-surface-700;
}
/* 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;
}
/* Mobile theme toggle */
.mobile-theme-toggle {
@apply flex items-center justify-between w-full px-4 py-3 text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-100 transition-colors bg-transparent border-none text-base text-left cursor-pointer border-t border-surface-200 dark:border-surface-700;
}
.mobile-theme-toggle .theme-label {
@apply font-normal;
}
.mobile-theme-toggle .theme-icons {
@apply flex items-center;
}
.mobile-theme-toggle .sun-icon {
@apply hidden;
}
.mobile-theme-toggle .moon-icon {
@apply block;
}
.dark .mobile-theme-toggle .sun-icon {
@apply block;
}
.dark .mobile-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-accent-600 dark:text-accent-400 hover:underline;
}
/* Layout with sidebar - mobile-first with responsive grid */
.layout-with-sidebar {
@apply grid grid-cols-1 gap-6 md:gap-8 lg:grid-cols-3;
}
.main-content {
@apply lg:col-span-2 min-w-0 overflow-x-hidden; /* min-w-0 + overflow-x-hidden prevents layout breaking */
}
.sidebar {
@apply space-y-6 lg:sticky lg:top-24 lg:self-start overflow-hidden;
}
/* 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-b-surface-200 dark:border-b-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 (post metadata pills) */
.p-category {
@apply inline-block px-2 py-0.5 text-xs bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 rounded border border-surface-200 dark:border-surface-700 hover:border-surface-400 dark:hover:border-surface-500 transition-colors;
}
/* Inline hashtags in post content — styled as subtle links, not pills */
.e-content a.hashtag,
.prose a.hashtag {
@apply text-accent-600 dark:text-accent-400 no-underline hover:underline font-medium;
/* Override prose default link styling (no border-bottom, no color shift) */
text-decoration: none;
}
/* Webmention facepile - overlapping avatar display */
.facepile {
@apply flex flex-wrap items-center;
}
.facepile-avatar {
@apply inline-block -ml-2 first:ml-0 transition-transform hover:z-10 hover:scale-110;
}
.facepile-avatar 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-accent-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-accent-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;
}
/* Ensure is-land custom elements don't break block layout flow */
is-land {
@apply block;
}
/* Widget cards */
.widget {
@apply p-4 mb-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden;
}
.widget-title {
@apply font-bold text-lg mb-4;
}
/* Collapsible widget wrapper */
.widget-header {
@apply flex items-center justify-between cursor-pointer;
}
.widget-header .widget-title {
@apply mb-0;
}
.widget-chevron {
@apply w-4 h-4 text-surface-400 transition-transform duration-200 shrink-0;
}
/* Hide inner widget titles when the collapsible wrapper provides one */
.widget-collapsible .widget .widget-title {
@apply hidden;
}
/* Hide FeedLand's custom title in collapsible wrapper */
.widget-collapsible .widget .fl-title {
@apply hidden;
}
/* Neutralize inner widget card styling when inside collapsible wrapper */
.widget-collapsible .widget {
@apply border-0 shadow-none rounded-none mb-0 bg-transparent;
}
/* Post cards */
.post-card {
@apply p-5 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden;
}
.post-header {
@apply flex flex-wrap items-center gap-2;
}
.post-footer {
@apply pt-3 border-t border-surface-100 dark:border-surface-700;
}
/* Photo gallery on listing pages */
.photo-list li {
@apply pb-8;
}
.photo-gallery {
@apply my-4 grid gap-2;
}
.photo-gallery img {
@apply w-full max-h-[500px] object-cover rounded-lg;
}
.photo-link {
@apply block;
}
.photo-caption {
@apply mt-3 text-surface-600 dark:text-surface-400;
}
/* Multi-photo grid */
.photo-gallery:has(img:nth-child(2)) {
@apply grid-cols-2;
}
.photo-gallery:has(img:nth-child(3)) {
@apply grid-cols-2;
}
.photo-gallery:has(img:nth-child(4)) {
@apply grid-cols-2;
}
/* 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-accent-500;
}
}
/* Active states — subtle press feedback on buttons */
@layer base {
button:active:not(:disabled),
.pagination-link:active:not(.disabled) {
transform: scale(0.97);
}
}
/* 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;
}
}
/* Admin UI - FAB and dashboard link */
@layer components {
.fab-container {
@apply fixed bottom-6 right-6 z-50 flex flex-col items-end;
}
.fab-backdrop {
@apply fixed inset-0 bg-black/20 dark:bg-black/40 z-40;
}
.fab-button {
@apply relative z-50 w-14 h-14 rounded-full bg-accent-600 hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center;
}
.fab-button:focus-visible {
@apply outline-2 outline-offset-2 outline-accent-500;
}
.fab-menu {
@apply relative z-50 mb-3 flex flex-col gap-2 items-end;
}
.fab-menu-item {
@apply flex items-center gap-3 px-4 py-3 rounded-xl bg-surface-50 dark:bg-surface-800 shadow-md hover:shadow-lg border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 hover:text-accent-600 dark:hover:text-accent-400 no-underline transition-all duration-150 text-sm font-medium;
}
.fab-menu-divider {
@apply border-t border-surface-200 dark:border-surface-700 my-1 w-full;
}
.admin-nav-link {
@apply text-accent-600 dark:text-accent-400 hover:text-accent-700 dark:hover:text-accent-300 no-underline transition-colors py-2 inline-flex items-center gap-1;
}
}
/* Performance: content-visibility for off-screen rendering optimization */
@layer utilities {
.content-auto {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}
}
/* Dates — monospace for technical texture (system.md: every <time> gets font-mono) */
@layer base {
time {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
}
/* 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;
-webkit-overflow-scrolling: touch;
}
code {
@apply break-words;
}
pre code {
word-break: normal;
overflow-wrap: normal;
}
/* Links in content - break long URLs */
.e-content a,
.prose a {
@apply break-words;
word-break: break-word;
}
/* Content containers - clip horizontal overflow but allow pre blocks to scroll */
.e-content,
.prose {
overflow-x: clip;
max-width: 100%;
}
article {
scroll-margin-top: 80px; /* Prevent header overlap when scrolling to anchors */
}
/* Heading anchors — generated by markdown-it-anchor */
.prose h2[id],
.prose h3[id],
.prose h4[id] {
scroll-margin-top: 80px;
}
.prose :is(h2, h3, h4) > a.header-anchor {
color: inherit;
text-decoration: none;
}
.prose :is(h2, h3, h4) > a.header-anchor:hover {
text-decoration: underline;
text-decoration-color: var(--tw-prose-links, currentColor);
text-underline-offset: 4px;
}
.prose :is(h2, h3, h4) > a.header-anchor::after {
content: " #";
opacity: 0;
font-weight: normal;
transition: opacity 0.15s;
}
.prose :is(h2, h3, h4):hover > a.header-anchor::after {
opacity: 0.4;
}
.post-list li {
content-visibility: auto;
contain-intrinsic-size: auto 200px;
}
/* Tables - responsive handling */
table {
@apply w-full;
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Ensure truncate works properly in flex containers */
.truncate {
@apply overflow-hidden text-ellipsis whitespace-nowrap;
}
/* Video embeds - maintain aspect ratio */
lite-youtube,
iframe[src*="youtube"],
iframe[src*="vimeo"] {
@apply max-w-full;
}
}
/* Pagefind UI theme overrides — outside @layer for higher specificity over Pagefind's :root defaults */
#search .pagefind-ui {
--pagefind-ui-scale: 1;
--pagefind-ui-primary: #b45309;
--pagefind-ui-text: #1c1b19;
--pagefind-ui-background: #faf8f5;
--pagefind-ui-border: #e8e5df;
--pagefind-ui-tag: #f4f2ee;
--pagefind-ui-border-width: 1px;
--pagefind-ui-border-radius: 8px;
--pagefind-ui-image-border-radius: 8px;
--pagefind-ui-font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.dark #search .pagefind-ui {
--pagefind-ui-primary: #fbbf24;
--pagefind-ui-text: #faf8f5;
--pagefind-ui-background: #0f0e0d;
--pagefind-ui-border: #3f3b35;
--pagefind-ui-tag: #2a2722;
}
/* Search input */
#search .pagefind-ui__search-input {
background-color: #faf8f5;
color: #1c1b19;
border-color: #e8e5df;
font-weight: 400;
}
.dark #search .pagefind-ui__search-input {
background-color: #1c1b19;
color: #faf8f5;
border-color: #3f3b35;
}
#search .pagefind-ui__search-input:focus {
outline: 2px solid #d97706;
outline-offset: 2px;
border-color: #d97706;
}
.dark #search .pagefind-ui__search-input:focus {
outline-color: #fbbf24;
border-color: #fbbf24;
}
/* Search clear button */
#search .pagefind-ui__search-clear {
color: #5c5750;
background-color: #faf8f5;
}
.dark #search .pagefind-ui__search-clear {
color: #a09a90;
background-color: #1c1b19;
}
#search .pagefind-ui__search-clear:hover {
color: #1c1b19;
}
.dark #search .pagefind-ui__search-clear:hover {
color: #faf8f5;
}
/* Result links */
#search .pagefind-ui__result-link {
color: #b45309;
}
#search .pagefind-ui__result-link:hover {
text-decoration: underline;
}
.dark #search .pagefind-ui__result-link {
color: #fbbf24;
}
/* Result excerpts */
#search .pagefind-ui__result-excerpt {
color: #5c5750;
}
.dark #search .pagefind-ui__result-excerpt {
color: #a09a90;
}
/* Highlighted search terms in results */
#search .pagefind-ui__result-excerpt mark,
#search mark {
background-color: #fef3c7;
color: #92400e;
padding: 0.1em 0.2em;
border-radius: 2px;
}
.dark #search .pagefind-ui__result-excerpt mark,
.dark #search mark {
background-color: #78350f;
color: #fde68a;
}
/* Message (result count) */
#search .pagefind-ui__message {
color: #5c5750;
}
.dark #search .pagefind-ui__message {
color: #a09a90;
}
/* "Load more" button */
#search .pagefind-ui__button {
color: #b45309;
background-color: #faf8f5;
border-color: #e8e5df;
cursor: pointer;
}
#search .pagefind-ui__button:hover {
background-color: #fffbeb;
border-color: #b45309;
}
.dark #search .pagefind-ui__button {
color: #fbbf24;
background-color: #0f0e0d;
border-color: #3f3b35;
}
.dark #search .pagefind-ui__button:hover {
background-color: #1c1b19;
border-color: #fbbf24;
}
/* Filter panel labels */
#search .pagefind-ui__filter-name,
#search .pagefind-ui__filter-label {
color: #18181b;
}
.dark #search .pagefind-ui__filter-name,
.dark #search .pagefind-ui__filter-label {
color: #f4f4f5;
}
/* Result tags */
#search .pagefind-ui__result-tag {
background-color: #f4f4f5;
color: #52525b;
}
.dark #search .pagefind-ui__result-tag {
background-color: #27272a;
color: #a1a1aa;
}
/* Sub-result nested links */
#search .pagefind-ui__result-nested .pagefind-ui__result-link {
color: #2563eb;
font-weight: 400;
}
.dark #search .pagefind-ui__result-nested .pagefind-ui__result-link {
color: #60a5fa;
}
/* Mobile-specific improvements */
@layer utilities {
/* Ensure proper touch scrolling on overflow containers */
.overflow-x-auto {
-webkit-overflow-scrolling: touch;
}
/* Hide scrollbar but allow scrolling */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
/* Sparkline — inline SVG posting frequency chart */
.sparkline {
width: 120px;
height: 28px;
display: block;
}
@media (min-width: 640px) {
.sparkline {
width: 180px;
height: 32px;
}
}
/* Save for Later buttons — hidden until auth confirmed */
.save-later-btn {
display: none;
}
body[data-indiekit-auth="true"] .save-later-btn {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
background: none;
border: 1px solid transparent;
border-radius: 6px;
padding: 2px 8px;
font-size: 0.75rem;
color: #6b7280;
transition: all 0.2s ease;
}
body[data-indiekit-auth="true"] .save-later-btn:hover {
border-color: #d1d5db;
color: #4a9eff;
}
.save-later--saved {
color: #4a9eff !important;
opacity: 0.6;
pointer-events: none;
}
/* Share Post buttons — hidden until auth confirmed */
.share-post-btn {
display: none;
}
body[data-indiekit-auth="true"] .share-post-btn {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
background: none;
border: 1px solid transparent;
border-radius: 6px;
padding: 2px 8px;
font-size: 0.75rem;
color: #6b7280;
transition: all 0.2s ease;
}
body[data-indiekit-auth="true"] .share-post-btn:hover {
border-color: #d1d5db;
color: #10b981;
}
/* Post type dropdown */
.post-type-dropdown {
display: none;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 4px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); /* shadow-sm */
z-index: 50;
min-width: 120px;
overflow: hidden;
}
.post-type-dropdown.open {
display: block;
}
.post-type-dropdown-item {
display: block;
width: 100%;
padding: 6px 12px;
font-size: 13px;
color: #374151;
text-align: left;
background: none;
border: none;
cursor: pointer;
white-space: nowrap;
}
.post-type-dropdown-item:hover {
background: #f3f4f6;
color: #10b981;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.post-type-dropdown {
background: #1f2937;
border-color: #374151;
}
.post-type-dropdown-item {
color: #d1d5db;
}
.post-type-dropdown-item:hover {
background: #374151;
color: #34d399;
}
}
+146
View File
@@ -0,0 +1,146 @@
---
layout: layouts/base.njk
withSidebar: false
title: CV
permalink: /cv/
pagefindIgnore: true
---
{# CV page — uses configurable layout when cvPageConfig exists, falls back to hardcoded layout #}
{% set hasCvData = (cv.experience and cv.experience.length) or
(cv.projects and cv.projects.length) or
(cv.skills and ((cv.skills or {}) | dictsort | length)) or
(cv.interests and ((cv.interests or {}) | dictsort | length)) %}
{% if hasCvData %}
{# Configurable layout — use cvPageConfig if available #}
{% if cvPageConfig and cvPageConfig.sections %}
{% include "components/cv-builder.njk" %}
{# Fallback — hardcoded layout for backward compatibility #}
{% else %}
{# CV identity — check cvPageConfig.identity first, fall back to site.author #}
{% set cvId = cvPageConfig.identity if (cvPageConfig and cvPageConfig.identity) else {} %}
{% set authorName = cvId.name or site.author.name %}
{% set authorAvatar = cvId.avatar or site.author.avatar %}
{% set authorTitle = cvId.title or site.author.title %}
{% set authorBio = cvId.bio or site.author.bio %}
{% set socialLinks = cvId.social if (cvId.social and cvId.social.length) else site.social %}
{% set cvLocality = cvId.locality or site.author.locality %}
{% set cvCountry = cvId.country or site.author.country %}
{% set cvOrg = cvId.org or site.author.org %}
{% set cvUrl = cvId.url or '' %}
{% set cvEmail = cvId.email or site.author.email %}
{% set cvKeyUrl = cvId.keyUrl or site.author.keyUrl %}
{# Hero / intro #}
<section class="mb-8 sm:mb-12">
<div class="flex flex-col sm:flex-row gap-6 sm:gap-8 items-start">
<img
src="{{ authorAvatar }}"
alt="{{ authorName }}"
class="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover shadow-lg flex-shrink-0"
loading="eager"
eleventy:ignore
>
<div class="flex-1 min-w-0">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">
{{ authorName }}
</h1>
{% if authorTitle %}
<p class="text-lg sm:text-xl text-accent-600 dark:text-accent-400 mb-3 sm:mb-4">
{{ authorTitle }}
</p>
{% endif %}
{% if authorBio %}
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mb-4">
{{ authorBio }}
</p>
{% endif %}
{% from "components/social-icon.njk" import socialIcon %}
{% if socialLinks %}
<div class="flex flex-wrap gap-3">
{% for link in socialLinks %}
<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"
>
{{ socialIcon(link.icon, "w-5 h-5") }}
<span class="text-sm font-medium">{{ link.name }}</span>
</a>
{% endfor %}
</div>
{% endif %}
{# Contact details #}
{% if cvLocality or cvCountry or cvOrg or cvUrl or cvEmail or cvKeyUrl %}
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-4 text-sm text-surface-500 dark:text-surface-400">
{% if cvLocality or cvCountry %}
<span>{% if cvLocality %}{{ cvLocality }}{% endif %}{% if cvLocality and cvCountry %}, {% endif %}{% if cvCountry %}{{ cvCountry }}{% endif %}</span>
{% endif %}
{% if cvOrg %}
<span>{{ cvOrg }}</span>
{% endif %}
{% if cvUrl %}
<span><a href="{{ cvUrl }}" class="text-accent-600 dark:text-accent-400 hover:underline" target="_blank" rel="noopener">{{ cvUrl | replace("https://", "") | replace("http://", "") }}</a></span>
{% endif %}
{% if cvEmail %}
<span><a href="mailto:{{ cvEmail }}" class="text-accent-600 dark:text-accent-400 hover:underline">{{ cvEmail }}</a></span>
{% endif %}
{% if cvKeyUrl %}
<span><a href="{{ cvKeyUrl }}" class="text-accent-600 dark:text-accent-400 hover:underline" target="_blank" rel="noopener">PGP Key</a></span>
{% endif %}
</div>
{% endif %}
</div>
</div>
</section>
{# Experience — work-only variant #}
{% set section = { type: "cv-experience-work", config: {} } %}
{% include "components/sections/cv-experience-work.njk" ignore missing %}
{# Skills — work-only variant #}
{% set section = { type: "cv-skills-work", config: {} } %}
{% include "components/sections/cv-skills-work.njk" ignore missing %}
{# Work Projects (only work-related projects on the CV page) #}
{% set section = { type: "cv-projects-work", config: {} } %}
{% include "components/sections/cv-projects-work.njk" ignore missing %}
{# Education — work-only variant #}
{% set section = { type: "cv-education-work", config: {} } %}
{% include "components/sections/cv-education-work.njk" ignore missing %}
{# Languages — standalone section #}
{% set section = { type: "cv-languages", config: {} } %}
{% include "components/sections/cv-languages.njk" ignore missing %}
{# Interests — work-only variant #}
{% set section = { type: "cv-interests-work", config: {} } %}
{% include "components/sections/cv-interests-work.njk" ignore missing %}
{# Last Updated #}
{% if cv.lastUpdated %}
<p class="text-sm text-surface-500 text-center mt-8">
Last updated: <time datetime="{{ cv.lastUpdated }}">{{ cv.lastUpdated | date("PPP") }}</time>
</p>
{% endif %}
{% endif %}
{% else %}
<div class="text-center py-12">
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4">CV</h1>
<p class="text-surface-600 dark:text-surface-400">
No CV data available yet. Add your experience, projects, and skills via the
<a href="/dashboard" class="text-accent-600 dark:text-accent-400 hover:underline">admin dashboard</a>.
</p>
</div>
{% endif %}

Some files were not shown because too many files have changed in this diff Show More