feat: neutralize theme for fresh deployments
Strip personal data from templates so the theme ships clean for any deployer. Collection pages now use generatePageOnEmptyData so empty post types show encouraging placeholders instead of 404s. Navigation is conditional on enabled post types and installed plugins. Sidebar widgets split into individual components with plugin-aware visibility. Slashes page explains required plugins for root-level page creation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
34
_data/blogrollStatus.js
Normal file
34
_data/blogrollStatus.js
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
199
_data/cv.js
199
_data/cv.js
@@ -1,190 +1,21 @@
|
||||
/**
|
||||
* CV Data - Easy to update!
|
||||
* CV Data — Empty defaults.
|
||||
*
|
||||
* To add a new experience: Add an entry to the `experience` array
|
||||
* To add a new project: Add an entry to the `projects` array
|
||||
* To update skills: Modify the `skills` object
|
||||
* When the indiekit-endpoint-cv plugin is installed, it serves CV data
|
||||
* via its API endpoint and the homepage plugin renders it.
|
||||
*
|
||||
* Without the plugin, users can edit this file directly:
|
||||
* - Add entries to the `experience` array
|
||||
* - Add entries to the `projects` array
|
||||
* - Modify the `skills` object
|
||||
*/
|
||||
|
||||
export default {
|
||||
// Last updated date - automatically set to build time
|
||||
lastUpdated: new Date().toISOString().split("T")[0],
|
||||
|
||||
// Work Experience - Add new positions at the TOP of the array
|
||||
experience: [
|
||||
{
|
||||
title: "Middleware Engineer",
|
||||
company: "FGTB-ABVV",
|
||||
location: "Brussels",
|
||||
startDate: "2023-11",
|
||||
endDate: null, // null = present
|
||||
type: "full-time",
|
||||
description: "Technology Specialist focusing on IT infrastructure and application delivery",
|
||||
highlights: [
|
||||
"Strategic migration of Java applications from legacy IBM Datapowers and PureApp systems",
|
||||
"Containerized application deployment on VMware Linux and OpenShift Kubernetes clusters",
|
||||
"Mastering OpenShift, Kubernetes, and Docker technologies"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Solution Architect",
|
||||
company: "OSINTukraine.com",
|
||||
location: "Remote",
|
||||
startDate: "2022-02",
|
||||
endDate: null,
|
||||
type: "volunteer",
|
||||
description: "Open-source intelligence (OSINT) initiative for Ukraine conflict monitoring",
|
||||
highlights: [
|
||||
"Collection, archiving, translation, analysis and dissemination of critical information",
|
||||
"Monitoring Russian Telegram channels with filtering, categorization, and archiving",
|
||||
"Sub-projects: War crimes archive, Drones research, Location-related alerts system"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "DevOps Training",
|
||||
company: "BeCode",
|
||||
location: "Brussels",
|
||||
startDate: "2021-09",
|
||||
endDate: "2022-03",
|
||||
type: "training",
|
||||
description: "7-month intensive DevOps specialization",
|
||||
highlights: [
|
||||
"Vagrant and Ansible infrastructure as code for WordPress, Nginx, Redis",
|
||||
"Docker Swarm cluster management",
|
||||
"GitLab CI/CD with SonarQube security audits",
|
||||
"Jenkins pipelines, Python basics, Prometheus/Grafana monitoring"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "CTO",
|
||||
company: "DigitYser",
|
||||
location: "Brussels",
|
||||
startDate: "2018-10",
|
||||
endDate: "2020-03",
|
||||
type: "full-time",
|
||||
description: "Digital flagship of tech communities in Brussels",
|
||||
highlights: [
|
||||
"Hosting infrastructure and automation",
|
||||
"Integrations with digital marketing tools",
|
||||
"Technical Event Management: Livestreaming, sound, video, photos"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Solution Architect",
|
||||
company: "Armada.digital",
|
||||
location: "Brussels",
|
||||
startDate: "2016-05",
|
||||
endDate: "2021-12",
|
||||
type: "freelance",
|
||||
description: "Consultancy to amplify visibility of good causes",
|
||||
highlights: [
|
||||
"Custom communication and collaboration solutions",
|
||||
"Empowering individuals and ethical businesses"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "FactChecking Platform",
|
||||
company: "Journalistes Solidaires",
|
||||
location: "Brussels",
|
||||
startDate: "2020-03",
|
||||
endDate: "2020-05",
|
||||
type: "volunteer",
|
||||
description: "Cloudron/Docker backend for factchecking workflow",
|
||||
highlights: [
|
||||
"WordPress with custom post types for COVID-19 disinformation monitoring"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Event Manager",
|
||||
company: "European Data Innovation Hub",
|
||||
location: "Brussels",
|
||||
startDate: "2019-02",
|
||||
endDate: "2020-03",
|
||||
type: "full-time",
|
||||
description: "Technical event organization and management"
|
||||
},
|
||||
{
|
||||
title: "Technical Advisor",
|
||||
company: "WomenPreneur-Initiative",
|
||||
location: "Brussels",
|
||||
startDate: "2019-01",
|
||||
endDate: "2020-01",
|
||||
type: "volunteer",
|
||||
description: "Technical guidance for women-focused entrepreneurship initiative"
|
||||
},
|
||||
{
|
||||
title: "Technical Advisor",
|
||||
company: "Promote Ukraine",
|
||||
location: "Brussels",
|
||||
startDate: "2019-01",
|
||||
endDate: "2020-01",
|
||||
type: "freelance",
|
||||
description: "Technical consulting for Ukraine advocacy organization"
|
||||
}
|
||||
],
|
||||
|
||||
// Current/Recent Projects - Add new projects at the TOP
|
||||
projects: [
|
||||
{
|
||||
name: "OSINT Intelligence Platform",
|
||||
url: "https://osintukraine.com",
|
||||
description: "Real-time monitoring and analysis platform for open-source intelligence",
|
||||
technologies: ["Docker", "Telegram API", "Python", "PostgreSQL"],
|
||||
status: "active"
|
||||
},
|
||||
{
|
||||
name: "Indiekit Cloudron Package",
|
||||
url: "https://github.com/rmdes/indiekit-cloudron",
|
||||
description: "Cloudron-packaged IndieWeb publishing server with Eleventy frontend",
|
||||
technologies: ["Node.js", "Eleventy", "Docker", "Cloudron"],
|
||||
status: "active"
|
||||
}
|
||||
// Add more projects here as needed
|
||||
],
|
||||
|
||||
// Skills - Organized by category
|
||||
skills: {
|
||||
containers: ["OpenShift", "Kubernetes", "Docker", "Docker Swarm"],
|
||||
automation: ["Ansible", "Vagrant", "GitLab CI/CD", "Jenkins", "GitHub Actions"],
|
||||
monitoring: ["Prometheus", "Grafana", "OpenTelemetry"],
|
||||
systems: ["Linux Administration", "System Administration", "VMware"],
|
||||
hosting: ["Cloudron", "On-Premise", "Cloud Infrastructure"],
|
||||
web: ["Nginx", "Redis", "WordPress", "TLS/SSL", "Eleventy"],
|
||||
security: ["SonarQube", "Information Assurance", "OSINT"],
|
||||
languages: ["Python", "Bash", "JavaScript", "Node.js"]
|
||||
},
|
||||
|
||||
// Languages spoken
|
||||
languages: [
|
||||
{ name: "Portuguese", level: "Native" },
|
||||
{ name: "French", level: "Fluent" },
|
||||
{ name: "English", level: "Fluent" },
|
||||
{ name: "Spanish", level: "Conversational" }
|
||||
],
|
||||
|
||||
// Education
|
||||
education: [
|
||||
{
|
||||
degree: "DevOps Training",
|
||||
institution: "BeCode",
|
||||
location: "Brussels",
|
||||
year: "2021-2022",
|
||||
description: "7-month intensive DevOps specialization"
|
||||
},
|
||||
{
|
||||
degree: "Bachelor's in Management Information Technology",
|
||||
institution: "ISLA - Instituto Superior de Gestão e Tecnologia",
|
||||
location: "Portugal",
|
||||
year: "1998-2001",
|
||||
description: "Curso Técnico Superior Profissional de Informática de Gestão"
|
||||
}
|
||||
],
|
||||
|
||||
// Interests
|
||||
interests: [
|
||||
"Music Production (Ableton Live, Ableton Push 3)",
|
||||
"IndieWeb & Decentralized Tech",
|
||||
"Open Source Intelligence (OSINT)",
|
||||
"Democracy & Digital Rights"
|
||||
]
|
||||
lastUpdated: null,
|
||||
experience: [],
|
||||
projects: [],
|
||||
skills: {},
|
||||
languages: [],
|
||||
education: [],
|
||||
interests: [],
|
||||
};
|
||||
|
||||
50
_data/enabledPostTypes.js
Normal file
50
_data/enabledPostTypes.js
Normal 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;
|
||||
}
|
||||
27
_data/homepageConfig.js
Normal file
27
_data/homepageConfig.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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 } from "node:path";
|
||||
|
||||
const CONTENT_DIR = process.env.CONTENT_DIR || "/data/content";
|
||||
|
||||
export default function () {
|
||||
try {
|
||||
const configPath = resolve(CONTENT_DIR, ".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;
|
||||
}
|
||||
}
|
||||
34
_data/podrollStatus.js
Normal file
34
_data/podrollStatus.js
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
* Maps new URLs to their old URLs so webmentions from previous
|
||||
* URL structures can be displayed on current pages.
|
||||
*
|
||||
* Sources:
|
||||
* - redirects.map.rmendes (micro.blog: /YYYY/MM/DD/slug.html → /notes/...)
|
||||
* - old-blog-redirects.map.rmendes (Known/WP: /YYYY/slug → /content/...)
|
||||
* 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";
|
||||
@@ -94,13 +94,11 @@ function findFile(candidates) {
|
||||
// Try multiple possible locations for each map type
|
||||
const microblogMapPath = findFile([
|
||||
resolve(pkgRoot, "redirects.map"),
|
||||
resolve(pkgRoot, "redirects.map.rmendes"),
|
||||
resolve(__dirname, "../../redirects.map"),
|
||||
]);
|
||||
|
||||
const knownMapPath = findFile([
|
||||
resolve(pkgRoot, "old-blog-redirects.map"),
|
||||
resolve(pkgRoot, "old-blog-redirects.map.rmendes"),
|
||||
resolve(__dirname, "../../old-blog-redirects.map"),
|
||||
]);
|
||||
|
||||
|
||||
27
_includes/components/empty-collection.njk
Normal file
27
_includes/components/empty-collection.njk
Normal file
@@ -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-primary-600 text-white hover:bg-primary-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>
|
||||
@@ -1,300 +1,26 @@
|
||||
{# Sidebar Components #}
|
||||
{# Contains: Author card (via h-card component), Bluesky feed, GitHub repos, RSS feed #}
|
||||
{# Sidebar — composed from individual widget partials #}
|
||||
{# Each widget handles its own conditional display internally, #}
|
||||
{# except API-only widgets which need a data-source guard here. #}
|
||||
|
||||
{# Author Card Widget - includes the canonical h-card component #}
|
||||
<div class="widget">
|
||||
{% include "components/h-card.njk" %}
|
||||
</div>
|
||||
{# Author Card (h-card) — always shown #}
|
||||
{% include "components/widgets/author-card.njk" %}
|
||||
|
||||
{# Social Feed Widget - Tabbed Bluesky/Mastodon #}
|
||||
{% if (blueskyFeed and blueskyFeed.length) or (mastodonFeed and mastodonFeed.length) %}
|
||||
<div class="widget" x-data="{ activeTab: 'bluesky' }">
|
||||
<h3 class="widget-title">Social Activity</h3>
|
||||
{# Social Activity — Bluesky/Mastodon feeds #}
|
||||
{% include "components/widgets/social-activity.njk" %}
|
||||
|
||||
{# Tab buttons #}
|
||||
<div class="flex gap-1 mb-4 border-b border-surface-200 dark:border-surface-700">
|
||||
{% if blueskyFeed and blueskyFeed.length %}
|
||||
<button
|
||||
@click="activeTab = 'bluesky'"
|
||||
:class="activeTab === 'bluesky' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
|
||||
class="flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors -mb-px"
|
||||
>
|
||||
<svg class="w-4 h-4 text-[#0085ff]" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"></path>
|
||||
</svg>
|
||||
Bluesky
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if mastodonFeed and mastodonFeed.length %}
|
||||
<button
|
||||
@click="activeTab = 'mastodon'"
|
||||
:class="activeTab === 'mastodon' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
|
||||
class="flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors -mb-px"
|
||||
>
|
||||
<svg class="w-4 h-4 text-[#6364ff]" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12v6.406z"></path>
|
||||
</svg>
|
||||
Mastodon
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# GitHub Repos #}
|
||||
{% include "components/widgets/github-repos.njk" %}
|
||||
|
||||
{# Bluesky Tab Content #}
|
||||
{% if blueskyFeed and blueskyFeed.length %}
|
||||
<div x-show="activeTab === 'bluesky'" x-cloak>
|
||||
<ul class="space-y-3">
|
||||
{% for post in blueskyFeed | head(5) %}
|
||||
<li class="border-b border-surface-200 dark:border-surface-700 pb-3 last:border-0">
|
||||
<a href="{{ post.url }}" target="_blank" rel="noopener" class="block group">
|
||||
<p class="text-sm text-surface-700 dark:text-surface-300 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
|
||||
{{ post.text | truncate(140) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs text-surface-500">
|
||||
<time datetime="{{ post.createdAt }}">{{ post.createdAt | date("MMM d, yyyy") }}</time>
|
||||
{% if post.likeCount > 0 %}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
|
||||
{{ post.likeCount }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="https://bsky.app/profile/{{ site.feeds.bluesky }}" target="_blank" rel="noopener" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 inline-flex items-center gap-1">
|
||||
View on Bluesky
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Funkwhale — Now Playing / Listening Stats #}
|
||||
{% include "components/widgets/funkwhale.njk" %}
|
||||
|
||||
{# Mastodon Tab Content #}
|
||||
{% if mastodonFeed and mastodonFeed.length %}
|
||||
<div x-show="activeTab === 'mastodon'" x-cloak>
|
||||
<ul class="space-y-3">
|
||||
{% for post in mastodonFeed | head(5) %}
|
||||
<li class="border-b border-surface-200 dark:border-surface-700 pb-3 last:border-0">
|
||||
<a href="{{ post.url }}" target="_blank" rel="noopener" class="block group">
|
||||
<p class="text-sm text-surface-700 dark:text-surface-300 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
|
||||
{{ post.text | truncate(140) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs text-surface-500">
|
||||
<time datetime="{{ post.createdAt }}">{{ post.createdAt | date("MMM d, yyyy") }}</time>
|
||||
{% if post.favouritesCount > 0 %}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
||||
{{ post.favouritesCount }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="https://{{ site.feeds.mastodon.instance }}/@{{ site.feeds.mastodon.username }}" target="_blank" rel="noopener" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 inline-flex items-center gap-1">
|
||||
View on Mastodon
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Recent Posts (for non-blog pages) #}
|
||||
{% include "components/widgets/recent-posts.njk" %}
|
||||
|
||||
{# Blogroll — only when backend is available #}
|
||||
{% if blogrollStatus and blogrollStatus.source == "indiekit" %}
|
||||
{% include "components/widgets/blogroll.njk" %}
|
||||
{% endif %}
|
||||
|
||||
{# GitHub Repos Widget #}
|
||||
{% if githubRepos and githubRepos.length %}
|
||||
<div class="widget">
|
||||
<h3 class="widget-title flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
GitHub Projects
|
||||
</h3>
|
||||
<ul class="space-y-3">
|
||||
{% for repo in githubRepos | head(5) %}
|
||||
<li class="repo-card">
|
||||
<a href="{{ repo.html_url }}" class="font-medium text-primary-600 dark:text-primary-400 hover:underline" target="_blank" rel="noopener">
|
||||
{{ repo.name }}
|
||||
</a>
|
||||
{% if repo.description %}
|
||||
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">{{ repo.description | truncate(80) }}</p>
|
||||
{% endif %}
|
||||
<div class="repo-meta">
|
||||
{% if repo.language %}
|
||||
<span>{{ repo.language }}</span>
|
||||
{% endif %}
|
||||
<span>{{ repo.stargazers_count }} stars</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="https://github.com/{{ site.feeds.github }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 block">
|
||||
View all repositories
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Funkwhale Now Playing Widget #}
|
||||
{% if funkwhaleActivity and (funkwhaleActivity.nowPlaying or funkwhaleActivity.stats) %}
|
||||
<div class="widget">
|
||||
<h3 class="widget-title flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
||||
</svg>
|
||||
Listening
|
||||
</h3>
|
||||
|
||||
{# Now Playing / Recently Played #}
|
||||
{% if funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.track %}
|
||||
<div class="{% if funkwhaleActivity.nowPlaying.status == 'now-playing' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800{% else %}bg-surface-50 dark:bg-surface-800{% endif %} rounded-lg p-3 mb-3">
|
||||
{% if funkwhaleActivity.nowPlaying.status == 'now-playing' %}
|
||||
<div class="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mb-2">
|
||||
<span class="flex gap-0.5 items-end h-2.5">
|
||||
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 30%;"></span>
|
||||
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 70%; animation-delay: 0.2s;"></span>
|
||||
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span>
|
||||
</span>
|
||||
Now Playing
|
||||
</div>
|
||||
{% elif funkwhaleActivity.nowPlaying.status == 'recently-played' %}
|
||||
<div class="text-xs text-surface-500 mb-2">Recently Played</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
{% if funkwhaleActivity.nowPlaying.coverUrl %}
|
||||
<img src="{{ funkwhaleActivity.nowPlaying.coverUrl }}" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy" 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 funkwhaleActivity.nowPlaying.trackUrl %}
|
||||
<a href="{{ funkwhaleActivity.nowPlaying.trackUrl }}" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank" rel="noopener">
|
||||
{{ funkwhaleActivity.nowPlaying.track }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ funkwhaleActivity.nowPlaying.track }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ funkwhaleActivity.nowPlaying.artist }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Quick Stats #}
|
||||
{% if funkwhaleActivity.stats and funkwhaleActivity.stats.summary %}
|
||||
{% set stats = funkwhaleActivity.stats.summary.all %}
|
||||
<div class="grid grid-cols-3 gap-2 text-center mb-3">
|
||||
<div class="p-2 bg-surface-50 dark:bg-surface-800 rounded">
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400 block">{{ stats.totalPlays or 0 }}</span>
|
||||
<span class="text-[10px] text-surface-500 uppercase">plays</span>
|
||||
</div>
|
||||
<div class="p-2 bg-surface-50 dark:bg-surface-800 rounded">
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400 block">{{ stats.uniqueArtists or 0 }}</span>
|
||||
<span class="text-[10px] text-surface-500 uppercase">artists</span>
|
||||
</div>
|
||||
<div class="p-2 bg-surface-50 dark:bg-surface-800 rounded">
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400 block">{{ stats.totalDurationFormatted or '0m' }}</span>
|
||||
<span class="text-[10px] text-surface-500 uppercase">listened</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="/listening/" class="text-sm text-primary-600 dark:text-primary-400 hover:underline flex items-center gap-1">
|
||||
View full listening history
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Recent Posts Widget (for non-blog pages) #}
|
||||
{% if recentPosts and recentPosts.length %}
|
||||
<div class="widget">
|
||||
<h3 class="widget-title">Recent Posts</h3>
|
||||
<ul class="space-y-2">
|
||||
{% for post in recentPosts | head(5) %}
|
||||
<li>
|
||||
<a href="{{ post.url }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">
|
||||
{{ post.data.title or post.data.name or "Untitled" }}
|
||||
</a>
|
||||
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published }}">
|
||||
{{ post.data.published | date("MMM d, yyyy") }}
|
||||
</time>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="/posts/" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 block">
|
||||
View all posts
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Blogroll Widget - Dynamic loading from API #}
|
||||
<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-primary-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-primary-600 dark:hover:text-primary-400">Blogroll</a>
|
||||
</h3>
|
||||
|
||||
<ul x-show="blogs.length > 0" class="space-y-2 mt-3">
|
||||
<template x-for="blog in blogs.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-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span class="w-5 h-5 rounded bg-gradient-to-br from-primary-400 to-primary-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="blogs.length === 0 && !loading" class="text-sm text-surface-500 py-2">
|
||||
No blogs loaded yet.
|
||||
</div>
|
||||
|
||||
<a x-show="blogs.length > 0" href="/blogroll/" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 inline-flex items-center gap-1">
|
||||
View all <span x-text="blogs.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 {
|
||||
blogs: [],
|
||||
loading: true,
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const res = await fetch('/blogrollapi/api/blogs?limit=10').then(r => r.json());
|
||||
this.blogs = res.items || [];
|
||||
} catch (err) {
|
||||
console.error('Blogroll widget error:', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{# Categories/Tags Widget #}
|
||||
{% if categories and categories.length %}
|
||||
<div class="widget">
|
||||
<h3 class="widget-title">Categories</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for category in categories %}
|
||||
<a href="/categories/{{ category | slugify }}/" class="p-category">
|
||||
{{ category }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Categories/Tags #}
|
||||
{% include "components/widgets/categories.njk" %}
|
||||
|
||||
4
_includes/components/widgets/author-card.njk
Normal file
4
_includes/components/widgets/author-card.njk
Normal file
@@ -0,0 +1,4 @@
|
||||
{# Author Card Widget - includes the canonical h-card component #}
|
||||
<div class="widget">
|
||||
{% include "components/h-card.njk" %}
|
||||
</div>
|
||||
56
_includes/components/widgets/blogroll.njk
Normal file
56
_includes/components/widgets/blogroll.njk
Normal file
@@ -0,0 +1,56 @@
|
||||
{# Blogroll Widget - Dynamic loading from API #}
|
||||
<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-primary-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-primary-600 dark:hover:text-primary-400">Blogroll</a>
|
||||
</h3>
|
||||
|
||||
<ul x-show="blogs.length > 0" class="space-y-2 mt-3">
|
||||
<template x-for="blog in blogs.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-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span class="w-5 h-5 rounded bg-gradient-to-br from-primary-400 to-primary-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="blogs.length === 0 && !loading" class="text-sm text-surface-500 py-2">
|
||||
No blogs loaded yet.
|
||||
</div>
|
||||
|
||||
<a x-show="blogs.length > 0" href="/blogroll/" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 inline-flex items-center gap-1">
|
||||
View all <span x-text="blogs.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 {
|
||||
blogs: [],
|
||||
loading: true,
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const res = await fetch('/blogrollapi/api/blogs?limit=10').then(r => r.json());
|
||||
this.blogs = res.items || [];
|
||||
} catch (err) {
|
||||
console.error('Blogroll widget error:', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
13
_includes/components/widgets/categories.njk
Normal file
13
_includes/components/widgets/categories.njk
Normal file
@@ -0,0 +1,13 @@
|
||||
{# Categories/Tags Widget #}
|
||||
{% if categories and categories.length %}
|
||||
<div class="widget">
|
||||
<h3 class="widget-title">Categories</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for category in categories %}
|
||||
<a href="/categories/{{ category | slugify }}/" class="p-category">
|
||||
{{ category }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
71
_includes/components/widgets/funkwhale.njk
Normal file
71
_includes/components/widgets/funkwhale.njk
Normal file
@@ -0,0 +1,71 @@
|
||||
{# Funkwhale Now Playing Widget #}
|
||||
{% if funkwhaleActivity and (funkwhaleActivity.nowPlaying or funkwhaleActivity.stats) %}
|
||||
<div class="widget">
|
||||
<h3 class="widget-title flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
||||
</svg>
|
||||
Listening
|
||||
</h3>
|
||||
|
||||
{# Now Playing / Recently Played #}
|
||||
{% if funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.track %}
|
||||
<div class="{% if funkwhaleActivity.nowPlaying.status == 'now-playing' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800{% else %}bg-surface-50 dark:bg-surface-800{% endif %} rounded-lg p-3 mb-3">
|
||||
{% if funkwhaleActivity.nowPlaying.status == 'now-playing' %}
|
||||
<div class="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mb-2">
|
||||
<span class="flex gap-0.5 items-end h-2.5">
|
||||
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 30%;"></span>
|
||||
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 70%; animation-delay: 0.2s;"></span>
|
||||
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span>
|
||||
</span>
|
||||
Now Playing
|
||||
</div>
|
||||
{% elif funkwhaleActivity.nowPlaying.status == 'recently-played' %}
|
||||
<div class="text-xs text-surface-500 mb-2">Recently Played</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
{% if funkwhaleActivity.nowPlaying.coverUrl %}
|
||||
<img src="{{ funkwhaleActivity.nowPlaying.coverUrl }}" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy" 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 funkwhaleActivity.nowPlaying.trackUrl %}
|
||||
<a href="{{ funkwhaleActivity.nowPlaying.trackUrl }}" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank" rel="noopener">
|
||||
{{ funkwhaleActivity.nowPlaying.track }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ funkwhaleActivity.nowPlaying.track }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ funkwhaleActivity.nowPlaying.artist }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Quick Stats #}
|
||||
{% if funkwhaleActivity.stats and funkwhaleActivity.stats.summary %}
|
||||
{% set stats = funkwhaleActivity.stats.summary.all %}
|
||||
<div class="grid grid-cols-3 gap-2 text-center mb-3">
|
||||
<div class="p-2 bg-surface-50 dark:bg-surface-800 rounded">
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400 block">{{ stats.totalPlays or 0 }}</span>
|
||||
<span class="text-[10px] text-surface-500 uppercase">plays</span>
|
||||
</div>
|
||||
<div class="p-2 bg-surface-50 dark:bg-surface-800 rounded">
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400 block">{{ stats.uniqueArtists or 0 }}</span>
|
||||
<span class="text-[10px] text-surface-500 uppercase">artists</span>
|
||||
</div>
|
||||
<div class="p-2 bg-surface-50 dark:bg-surface-800 rounded">
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400 block">{{ stats.totalDurationFormatted or '0m' }}</span>
|
||||
<span class="text-[10px] text-surface-500 uppercase">listened</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="/listening/" class="text-sm text-primary-600 dark:text-primary-400 hover:underline flex items-center gap-1">
|
||||
View full listening history
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
32
_includes/components/widgets/github-repos.njk
Normal file
32
_includes/components/widgets/github-repos.njk
Normal file
@@ -0,0 +1,32 @@
|
||||
{# GitHub Repos Widget #}
|
||||
{% if githubRepos and githubRepos.length %}
|
||||
<div class="widget">
|
||||
<h3 class="widget-title flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
GitHub Projects
|
||||
</h3>
|
||||
<ul class="space-y-3">
|
||||
{% for repo in githubRepos | head(5) %}
|
||||
<li class="repo-card">
|
||||
<a href="{{ repo.html_url }}" class="font-medium text-primary-600 dark:text-primary-400 hover:underline" target="_blank" rel="noopener">
|
||||
{{ repo.name }}
|
||||
</a>
|
||||
{% if repo.description %}
|
||||
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">{{ repo.description | truncate(80) }}</p>
|
||||
{% endif %}
|
||||
<div class="repo-meta">
|
||||
{% if repo.language %}
|
||||
<span>{{ repo.language }}</span>
|
||||
{% endif %}
|
||||
<span>{{ repo.stargazers_count }} stars</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="https://github.com/{{ site.feeds.github }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 block">
|
||||
View all repositories
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
21
_includes/components/widgets/recent-posts.njk
Normal file
21
_includes/components/widgets/recent-posts.njk
Normal file
@@ -0,0 +1,21 @@
|
||||
{# Recent Posts Widget (for non-blog pages) #}
|
||||
{% if recentPosts and recentPosts.length %}
|
||||
<div class="widget">
|
||||
<h3 class="widget-title">Recent Posts</h3>
|
||||
<ul class="space-y-2">
|
||||
{% for post in recentPosts | head(5) %}
|
||||
<li>
|
||||
<a href="{{ post.url }}" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">
|
||||
{{ post.data.title or post.data.name or "Untitled" }}
|
||||
</a>
|
||||
<time class="text-xs text-surface-500 block" datetime="{{ post.data.published }}">
|
||||
{{ post.data.published | date("MMM d, yyyy") }}
|
||||
</time>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="/posts/" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 block">
|
||||
View all posts
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
94
_includes/components/widgets/social-activity.njk
Normal file
94
_includes/components/widgets/social-activity.njk
Normal file
@@ -0,0 +1,94 @@
|
||||
{# Social Feed Widget - Tabbed Bluesky/Mastodon #}
|
||||
{% if (blueskyFeed and blueskyFeed.length) or (mastodonFeed and mastodonFeed.length) %}
|
||||
<div class="widget" x-data="{ activeTab: 'bluesky' }">
|
||||
<h3 class="widget-title">Social Activity</h3>
|
||||
|
||||
{# Tab buttons #}
|
||||
<div class="flex gap-1 mb-4 border-b border-surface-200 dark:border-surface-700">
|
||||
{% if blueskyFeed and blueskyFeed.length %}
|
||||
<button
|
||||
@click="activeTab = 'bluesky'"
|
||||
:class="activeTab === 'bluesky' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
|
||||
class="flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors -mb-px"
|
||||
>
|
||||
<svg class="w-4 h-4 text-[#0085ff]" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"></path>
|
||||
</svg>
|
||||
Bluesky
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if mastodonFeed and mastodonFeed.length %}
|
||||
<button
|
||||
@click="activeTab = 'mastodon'"
|
||||
:class="activeTab === 'mastodon' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
|
||||
class="flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors -mb-px"
|
||||
>
|
||||
<svg class="w-4 h-4 text-[#6364ff]" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12v6.406z"></path>
|
||||
</svg>
|
||||
Mastodon
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Bluesky Tab Content #}
|
||||
{% if blueskyFeed and blueskyFeed.length %}
|
||||
<div x-show="activeTab === 'bluesky'" x-cloak>
|
||||
<ul class="space-y-3">
|
||||
{% for post in blueskyFeed | head(5) %}
|
||||
<li class="border-b border-surface-200 dark:border-surface-700 pb-3 last:border-0">
|
||||
<a href="{{ post.url }}" target="_blank" rel="noopener" class="block group">
|
||||
<p class="text-sm text-surface-700 dark:text-surface-300 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
|
||||
{{ post.text | truncate(140) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs text-surface-500">
|
||||
<time datetime="{{ post.createdAt }}">{{ post.createdAt | date("MMM d, yyyy") }}</time>
|
||||
{% if post.likeCount > 0 %}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
|
||||
{{ post.likeCount }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="https://bsky.app/profile/{{ site.feeds.bluesky }}" target="_blank" rel="noopener" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 inline-flex items-center gap-1">
|
||||
View on Bluesky
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Mastodon Tab Content #}
|
||||
{% if mastodonFeed and mastodonFeed.length %}
|
||||
<div x-show="activeTab === 'mastodon'" x-cloak>
|
||||
<ul class="space-y-3">
|
||||
{% for post in mastodonFeed | head(5) %}
|
||||
<li class="border-b border-surface-200 dark:border-surface-700 pb-3 last:border-0">
|
||||
<a href="{{ post.url }}" target="_blank" rel="noopener" class="block group">
|
||||
<p class="text-sm text-surface-700 dark:text-surface-300 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
|
||||
{{ post.text | truncate(140) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs text-surface-500">
|
||||
<time datetime="{{ post.createdAt }}">{{ post.createdAt | date("MMM d, yyyy") }}</time>
|
||||
{% if post.favouritesCount > 0 %}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
||||
{{ post.favouritesCount }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="https://{{ site.feeds.mastodon.instance }}/@{{ site.feeds.mastodon.username }}" target="_blank" rel="noopener" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mt-3 inline-flex items-center gap-1">
|
||||
View on Mastodon
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -114,7 +114,9 @@
|
||||
<nav class="site-nav" id="site-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/about/">About</a>
|
||||
{% if collections.pages | selectattr("url", "equalto", "/now/") | list | length %}
|
||||
<a href="/now/">Now</a>
|
||||
{% endif %}
|
||||
{# 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">
|
||||
@@ -128,13 +130,24 @@
|
||||
{% 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>
|
||||
<a href="/funkwhale/">/funkwhale</a>
|
||||
<a href="/github/">/github</a>
|
||||
<a href="/listening/">/listening</a>
|
||||
<a href="/news/">/news</a>
|
||||
<a href="/podroll/">/podroll</a>
|
||||
<a href="/youtube/">/youtube</a>
|
||||
{% 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 #}
|
||||
@@ -147,13 +160,9 @@
|
||||
</a>
|
||||
<div class="nav-dropdown-menu" x-show="open" x-transition x-cloak>
|
||||
<a href="/blog/">All Posts</a>
|
||||
<a href="/articles/">Articles</a>
|
||||
<a href="/notes/">Notes</a>
|
||||
<a href="/photos/">Photos</a>
|
||||
<a href="/bookmarks/">Bookmarks</a>
|
||||
<a href="/likes/">Likes</a>
|
||||
<a href="/replies/">Replies</a>
|
||||
<a href="/reposts/">Reposts</a>
|
||||
{% for pt in enabledPostTypes %}
|
||||
<a href="{{ pt.path }}">{{ pt.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<a href="/interactions/">Interactions</a>
|
||||
@@ -191,7 +200,9 @@
|
||||
<nav class="mobile-nav hidden" id="mobile-nav" x-data="{ blogOpen: false, slashOpen: false }">
|
||||
<a href="/">Home</a>
|
||||
<a href="/about/">About</a>
|
||||
{% if collections.pages | selectattr("url", "equalto", "/now/") | list | length %}
|
||||
<a href="/now/">Now</a>
|
||||
{% endif %}
|
||||
{# Slash pages section - all root pages in one menu #}
|
||||
<div class="mobile-nav-section">
|
||||
<button type="button" class="mobile-nav-toggle" @click="slashOpen = !slashOpen">
|
||||
@@ -205,13 +216,17 @@
|
||||
{% 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>
|
||||
<a href="/funkwhale/">/funkwhale</a>
|
||||
<a href="/github/">/github</a>
|
||||
<a href="/listening/">/listening</a>
|
||||
<a href="/news/">/news</a>
|
||||
<a href="/podroll/">/podroll</a>
|
||||
<a href="/youtube/">/youtube</a>
|
||||
{% 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 #}
|
||||
@@ -224,13 +239,9 @@
|
||||
</button>
|
||||
<div class="mobile-nav-submenu" x-show="blogOpen" x-collapse>
|
||||
<a href="/blog/">All Posts</a>
|
||||
<a href="/articles/">Articles</a>
|
||||
<a href="/notes/">Notes</a>
|
||||
<a href="/photos/">Photos</a>
|
||||
<a href="/bookmarks/">Bookmarks</a>
|
||||
<a href="/likes/">Likes</a>
|
||||
<a href="/replies/">Replies</a>
|
||||
<a href="/reposts/">Reposts</a>
|
||||
{% for pt in enabledPostTypes %}
|
||||
<a href="{{ pt.path }}">{{ pt.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<a href="/interactions/">Interactions</a>
|
||||
|
||||
@@ -22,13 +22,17 @@ withSidebar: true
|
||||
<p class="text-lg sm:text-xl text-primary-600 dark:text-primary-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">
|
||||
Hi, I geek around tech, information systems, democracy, justice, coercive groups (aka cults), and discernment.
|
||||
{{ 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">
|
||||
My blog serves as a repository for my thoughts, long-form writings (some still in draft), and a place where I bookmark interesting finds from the web. It's also my central hub for cross-posting to networks like Mastodon, Bluesky.
|
||||
{{ site.description }}
|
||||
<a href="/about/" class="text-primary-600 dark:text-primary-400 hover:underline font-medium">Read more →</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{# Social Links #}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
@@ -56,6 +60,25 @@ withSidebar: true
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Homepage content — three-tier fallback: #}
|
||||
{# 1. Plugin config (homepageConfig) — Phase 3, future #}
|
||||
{# 2. CV data — show experience/projects/skills #}
|
||||
{# 3. Default — show recent posts and activity #}
|
||||
|
||||
{% set hasCvData = (cv.experience and cv.experience.length) or
|
||||
(cv.projects and cv.projects.length) or
|
||||
(cv.skills and (cv.skills | dictsort | length)) %}
|
||||
|
||||
{# --- Tier 1: Plugin-driven layout (future) --- #}
|
||||
{% if homepageConfig and homepageConfig.sections %}
|
||||
{# Reserved for indiekit-endpoint-homepage plugin — will render configured sections here #}
|
||||
<section class="mb-8 sm:mb-12">
|
||||
<p class="text-surface-500 text-center">Homepage plugin layout will render here.</p>
|
||||
</section>
|
||||
|
||||
{# --- Tier 2: CV-based layout --- #}
|
||||
{% elif hasCvData %}
|
||||
|
||||
{# Work Experience Timeline - only show if data exists #}
|
||||
{% if cv.experience and cv.experience.length %}
|
||||
<section class="mb-8 sm:mb-12">
|
||||
@@ -210,3 +233,83 @@ withSidebar: true
|
||||
Last updated: {{ cv.lastUpdated }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{# --- Tier 3: Default — recent activity when no CV and no plugin --- #}
|
||||
{% 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-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-primary-400 dark:hover:border-primary-600 transition-colors">
|
||||
<h3 class="font-semibold text-surface-900 dark:text-surface-100 mb-1">
|
||||
<a href="{{ post.url }}" class="hover:text-primary-600 dark:hover:text-primary-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-primary-600 dark:text-primary-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 %}
|
||||
|
||||
{# Getting Started — onboarding guide for new deployments #}
|
||||
<section class="mb-8 sm:mb-12 p-6 bg-primary-50 dark:bg-primary-900/20 rounded-lg border border-primary-200 dark:border-primary-800">
|
||||
<h2 class="text-xl font-bold text-surface-900 dark:text-surface-100 mb-4">Getting Started</h2>
|
||||
|
||||
<div class="space-y-4 text-surface-700 dark:text-surface-300">
|
||||
<div class="flex gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-primary-600 text-white text-sm flex items-center justify-center font-bold">1</span>
|
||||
<div>
|
||||
<strong class="text-surface-900 dark:text-surface-100">Create your first post</strong>
|
||||
<p class="text-sm mt-1">
|
||||
<a href="/session/login" class="text-primary-600 dark:text-primary-400 hover:underline">Sign in</a>,
|
||||
then visit <a href="/create" class="text-primary-600 dark:text-primary-400 hover:underline">/create</a>
|
||||
to publish notes, articles, bookmarks, and photos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-primary-600 text-white text-sm flex items-center justify-center font-bold">2</span>
|
||||
<div>
|
||||
<strong class="text-surface-900 dark:text-surface-100">Set up syndication</strong>
|
||||
<p class="text-sm mt-1">
|
||||
Cross-post to Mastodon, Bluesky, and LinkedIn automatically.
|
||||
Add your credentials to the <code class="text-xs bg-surface-200 dark:bg-surface-700 px-1 py-0.5 rounded">.env</code> file and restart.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-primary-600 text-white text-sm flex items-center justify-center font-bold">3</span>
|
||||
<div>
|
||||
<strong class="text-surface-900 dark:text-surface-100">Enable interactions</strong>
|
||||
<p class="text-sm mt-1">
|
||||
Receive likes, replies, and reposts from across the web.
|
||||
Register at <a href="https://webmention.io" class="text-primary-600 dark:text-primary-400 hover:underline" target="_blank" rel="noopener">webmention.io</a>
|
||||
and add the token to <code class="text-xs bg-surface-200 dark:bg-surface-700 px-1 py-0.5 rounded">.env</code> as <code class="text-xs bg-surface-200 dark:bg-surface-700 px-1 py-0.5 rounded">WEBMENTION_IO_TOKEN</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endif %} {# end three-tier fallback #}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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">
|
||||
@@ -87,6 +88,7 @@ permalink: "articles/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNu
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="text-surface-600 dark:text-surface-400">No articles yet.</p>
|
||||
{% set postType = "article" %}
|
||||
{% include "components/empty-collection.njk" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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">
|
||||
@@ -100,6 +101,7 @@ permalink: "bookmarks/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageN
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="text-surface-600 dark:text-surface-400">No bookmarks yet.</p>
|
||||
{% set postType = "bookmark" %}
|
||||
{% include "components/empty-collection.njk" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ eleventyExcludeFromCollections: true
|
||||
"language": "{{ site.locale | default('en') }}",
|
||||
"authors": [
|
||||
{
|
||||
"name": "{{ site.author | default('Ricardo Mendes') }}",
|
||||
"name": "{{ site.author.name | default(site.name) }}",
|
||||
"url": "{{ site.url }}/"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -6,6 +6,7 @@ pagination:
|
||||
data: collections.likes
|
||||
size: 20
|
||||
alias: paginatedLikes
|
||||
generatePageOnEmptyData: true
|
||||
permalink: "likes/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
|
||||
---
|
||||
<div class="h-feed">
|
||||
@@ -98,6 +99,7 @@ permalink: "likes/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumbe
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="text-surface-600 dark:text-surface-400">No likes yet.</p>
|
||||
{% set postType = "like" %}
|
||||
{% include "components/empty-collection.njk" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ pagination:
|
||||
data: collections.notes
|
||||
size: 20
|
||||
alias: paginatedNotes
|
||||
generatePageOnEmptyData: true
|
||||
permalink: "notes/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
|
||||
---
|
||||
<div class="h-feed">
|
||||
@@ -84,6 +85,7 @@ permalink: "notes/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumbe
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="text-surface-600 dark:text-surface-400">No notes yet.</p>
|
||||
{% set postType = "note" %}
|
||||
{% include "components/empty-collection.njk" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rmendes-eleventy-site",
|
||||
"name": "indiekit-eleventy-theme",
|
||||
"version": "1.0.0",
|
||||
"description": "Personal website powered by Indiekit and Eleventy",
|
||||
"description": "Eleventy theme for Indiekit — IndieWeb-ready personal website",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "eleventy",
|
||||
|
||||
@@ -6,6 +6,7 @@ pagination:
|
||||
data: collections.photos
|
||||
size: 20
|
||||
alias: paginatedPhotos
|
||||
generatePageOnEmptyData: true
|
||||
permalink: "photos/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
|
||||
---
|
||||
<div class="h-feed">
|
||||
@@ -92,6 +93,7 @@ permalink: "photos/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumb
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="text-surface-600 dark:text-surface-400">No photos yet.</p>
|
||||
{% set postType = "photo" %}
|
||||
{% include "components/empty-collection.njk" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ pagination:
|
||||
data: collections.replies
|
||||
size: 20
|
||||
alias: paginatedReplies
|
||||
generatePageOnEmptyData: true
|
||||
permalink: "replies/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
|
||||
---
|
||||
<div class="h-feed">
|
||||
@@ -102,6 +103,7 @@ permalink: "replies/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNum
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="text-surface-600 dark:text-surface-400">No replies yet.</p>
|
||||
{% set postType = "reply" %}
|
||||
{% include "components/empty-collection.njk" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ pagination:
|
||||
data: collections.reposts
|
||||
size: 20
|
||||
alias: paginatedReposts
|
||||
generatePageOnEmptyData: true
|
||||
permalink: "reposts/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
|
||||
---
|
||||
<div class="h-feed">
|
||||
@@ -104,6 +105,7 @@ permalink: "reposts/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNum
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="text-surface-600 dark:text-surface-400">No reposts yet.</p>
|
||||
{% set postType = "repost" %}
|
||||
{% include "components/empty-collection.njk" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
92
slashes.njk
92
slashes.njk
@@ -11,9 +11,9 @@ permalink: /slashes/
|
||||
</p>
|
||||
|
||||
{# Dynamic pages (created via Indiekit) #}
|
||||
{% if collections.pages.length > 0 %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-surface-800 dark:text-surface-200 mb-4">Pages</h2>
|
||||
{% if collections.pages.length > 0 %}
|
||||
<ul class="post-list">
|
||||
{% for page in collections.pages %}
|
||||
<li class="h-entry post-card">
|
||||
@@ -37,21 +37,65 @@ permalink: /slashes/
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="p-4 bg-surface-100 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
|
||||
<p class="text-surface-600 dark:text-surface-400 text-sm mb-2">
|
||||
No root pages yet. To create pages like <code>/now</code>, <code>/uses</code>, or <code>/colophon</code>, you need two plugins:
|
||||
</p>
|
||||
<ul class="text-sm text-surface-600 dark:text-surface-400 list-disc list-inside space-y-1">
|
||||
<li><code>@rmdes/indiekit-post-type-page</code> — registers the "page" post type with Indiekit, using root-level URL paths (<code>/slug</code> instead of <code>/type/YYYY/MM/DD/slug</code>)</li>
|
||||
<li><code>@rmdes/indiekit-endpoint-posts</code> — publishing UI that sends the <code>h=page</code> Micropub type so pages are created at root level</li>
|
||||
</ul>
|
||||
<p class="text-surface-500 dark:text-surface-500 text-xs mt-3">
|
||||
Once both plugins are installed, "Page" appears as a post type in the Indiekit admin UI, and pages are published directly at <code>/slug</code>.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Activity pages (from Indiekit plugins) #}
|
||||
{# Activity pages — only show when their plugin backend is available #}
|
||||
{% set hasActivityPages = (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 hasActivityPages %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-surface-800 dark:text-surface-200 mb-4">Activity Feeds</h2>
|
||||
<ul class="post-list">
|
||||
{% if blogrollStatus and blogrollStatus.source == "indiekit" %}
|
||||
<li class="post-card">
|
||||
<div class="post-header">
|
||||
<h3 class="text-xl font-semibold">
|
||||
<a href="/blogroll/" class="text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400">/blogroll</a>
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Sites I follow</p>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if funkwhaleActivity and funkwhaleActivity.source == "indiekit" %}
|
||||
<li class="post-card">
|
||||
<div class="post-header">
|
||||
<h3 class="text-xl font-semibold">
|
||||
<a href="/funkwhale/" class="text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400">/funkwhale</a>
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Funkwhale activity</p>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if githubActivity and githubActivity.source != "error" %}
|
||||
<li class="post-card">
|
||||
<div class="post-header">
|
||||
<h3 class="text-xl font-semibold">
|
||||
<a href="/github/" class="text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400">/github</a>
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-surface-600 dark:text-surface-400 mt-2">My GitHub activity</p>
|
||||
<p class="text-surface-600 dark:text-surface-400 mt-2">GitHub activity</p>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if lastfmActivity and lastfmActivity.source == "indiekit" %}
|
||||
<li class="post-card">
|
||||
<div class="post-header">
|
||||
<h3 class="text-xl font-semibold">
|
||||
@@ -60,22 +104,8 @@ permalink: /slashes/
|
||||
</div>
|
||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Last.fm scrobbles</p>
|
||||
</li>
|
||||
<li class="post-card">
|
||||
<div class="post-header">
|
||||
<h3 class="text-xl font-semibold">
|
||||
<a href="/funkwhale/" class="text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400">/funkwhale</a>
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-surface-600 dark:text-surface-400 mt-2">My Funkwhale activity</p>
|
||||
</li>
|
||||
<li class="post-card">
|
||||
<div class="post-header">
|
||||
<h3 class="text-xl font-semibold">
|
||||
<a href="/youtube/" class="text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400">/youtube</a>
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-surface-600 dark:text-surface-400 mt-2">My YouTube channel</p>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if newsActivity and newsActivity.source == "indiekit" %}
|
||||
<li class="post-card">
|
||||
<div class="post-header">
|
||||
<h3 class="text-xl font-semibold">
|
||||
@@ -84,8 +114,30 @@ permalink: /slashes/
|
||||
</div>
|
||||
<p class="text-surface-600 dark:text-surface-400 mt-2">RSS feed aggregator</p>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if podrollStatus and podrollStatus.source == "indiekit" %}
|
||||
<li class="post-card">
|
||||
<div class="post-header">
|
||||
<h3 class="text-xl font-semibold">
|
||||
<a href="/podroll/" class="text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400">/podroll</a>
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Podcasts I listen to</p>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if youtubeChannel and youtubeChannel.source == "indiekit" %}
|
||||
<li class="post-card">
|
||||
<div class="post-header">
|
||||
<h3 class="text-xl font-semibold">
|
||||
<a href="/youtube/" class="text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400">/youtube</a>
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-surface-600 dark:text-surface-400 mt-2">YouTube channel</p>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Inspiration section #}
|
||||
<div class="mt-8 p-4 bg-surface-100 dark:bg-surface-800 rounded-lg">
|
||||
|
||||
Reference in New Issue
Block a user