Merge remote-tracking branch 'theme/main'
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
interactive
|
||||||
+25
-3
@@ -1,5 +1,27 @@
|
|||||||
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
_site/
|
_site/
|
||||||
.env
|
pagefind/
|
||||||
start.sh
|
css/style.css
|
||||||
*.log
|
|
||||||
|
# Cache
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Content (symlinked at runtime)
|
||||||
|
content/
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Personal overrides (should be in parent repo)
|
||||||
|
*.rmendes
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Design System — rmendes.net
|
||||||
|
|
||||||
|
## Palette
|
||||||
|
|
||||||
|
**Surfaces:** Warm stone — not yellow, not cold. The difference is felt, not seen.
|
||||||
|
|
||||||
|
| Token | Hex | Role |
|
||||||
|
|-------|-----|------|
|
||||||
|
| surface-50 | `#faf8f5` | Canvas (light) |
|
||||||
|
| surface-100 | `#f4f2ee` | Cards, elevated surfaces (light) |
|
||||||
|
| surface-200 | `#e8e5df` | Standard borders, dividers |
|
||||||
|
| surface-300 | `#d5d0c8` | Strong borders, input borders |
|
||||||
|
| surface-400 | `#a09a90` | Muted text, placeholders |
|
||||||
|
| surface-500 | `#7a746a` | Secondary text |
|
||||||
|
| surface-600 | `#5c5750` | Supporting text |
|
||||||
|
| surface-700 | `#3f3b35` | Dark mode borders |
|
||||||
|
| surface-800 | `#2a2722` | Cards, elevated surfaces (dark) |
|
||||||
|
| surface-900 | `#1c1b19` | Canvas (dark) |
|
||||||
|
| surface-950 | `#0f0e0d` | Deepest dark |
|
||||||
|
|
||||||
|
**Accent (Warm Amber):** Default interactive color — links, CTAs, focus rings.
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|-------|-----|-------|
|
||||||
|
| accent-400 | `#fbbf24` | Dark mode: links, hover |
|
||||||
|
| accent-500 | `#f59e0b` | — |
|
||||||
|
| accent-600 | `#d97706` | Light mode: links, buttons |
|
||||||
|
| accent-700 | `#b45309` | Light mode: hover, pressed |
|
||||||
|
|
||||||
|
## Domain Colors
|
||||||
|
|
||||||
|
Every section of the site has a color identity. On domain pages, links, hover states, and card borders use the domain color instead of amber.
|
||||||
|
|
||||||
|
### Domain Map (complete — every page accounted for)
|
||||||
|
|
||||||
|
| Domain | Tailwind color | Light text | Dark text | Pages |
|
||||||
|
|--------|---------------|------------|-----------|-------|
|
||||||
|
| **Writing** | amber (= accent) | amber-700 | amber-400 | `/blog/`, `/articles/`, `/notes/`, `/bookmarks/`, `/digest/`, `/news/`, `/categories/`, individual posts |
|
||||||
|
| **Social** | rose | rose-600 | rose-400 | `/likes/`, `/replies/`, `/reposts/`, `/interactions/` |
|
||||||
|
| **Code** | emerald | emerald-600 | emerald-400 | `/github/`, `/github/starred/` |
|
||||||
|
| **Music** | purple | purple-600 | purple-400 | `/funkwhale/`, `/listening/` |
|
||||||
|
| **Video** | red | red-600 | red-400 | `/youtube/` |
|
||||||
|
| **Reading** | orange | orange-600 | orange-400 | `/blogroll/`, `/podroll/`, `/readlater/` |
|
||||||
|
| **Neutral** | — (use accent) | — | — | `/` (home), `/about/`, `/cv/`, `/slashes/`, `/search/`, `/changelog/`, `/404` |
|
||||||
|
|
||||||
|
### Brand Colors (hardcoded hex — not domain colors)
|
||||||
|
|
||||||
|
These are third-party brand colors used in syndication badges and social widgets. Not part of the domain system.
|
||||||
|
|
||||||
|
| Brand | Hex | Where |
|
||||||
|
|-------|-----|-------|
|
||||||
|
| Mastodon | `#a730b8` | Syndication badges, social-activity widget |
|
||||||
|
| Bluesky | `#0085ff` | Syndication badges, social-activity widget |
|
||||||
|
| LinkedIn | `#0a66c2` | Syndication badges |
|
||||||
|
| IndieNews | `#ff5c00` | Syndication badges |
|
||||||
|
| Mastodon alt | `#6364ff` | Syndication badges |
|
||||||
|
|
||||||
|
### Domain Prominence (medium)
|
||||||
|
|
||||||
|
On a domain page, these elements adopt the domain color:
|
||||||
|
- **Page title** — tinted text or accent underline
|
||||||
|
- **Links** — `text-[domain]-600 dark:text-[domain]-400`
|
||||||
|
- **Hover states** — `hover:text-[domain]-700 dark:hover:text-[domain]-300`
|
||||||
|
- **Card borders on hover** — `hover:border-[domain]-400 dark:hover:border-[domain]-600`
|
||||||
|
- **Post-type badges** — domain-colored background/text
|
||||||
|
|
||||||
|
These stay neutral (accent or surface) regardless of domain:
|
||||||
|
- Header/navigation
|
||||||
|
- Sidebar widget containers
|
||||||
|
- Footer
|
||||||
|
- Global UI (search, theme toggle)
|
||||||
|
|
||||||
|
## Depth
|
||||||
|
|
||||||
|
**Subtle shadows.** One consistent shadow level for elevated surfaces. Borders + shadow together.
|
||||||
|
|
||||||
|
| Element | Treatment |
|
||||||
|
|---------|-----------|
|
||||||
|
| Cards, widgets | `shadow-sm` + `border border-surface-200 dark:border-surface-700` |
|
||||||
|
| Avatars, album art | `shadow-lg` (depth gives images presence against flat surfaces) |
|
||||||
|
| Modals | `shadow-xl` (overlay needs clear elevation) |
|
||||||
|
| Hover on cards | No shadow change — border color shift only |
|
||||||
|
|
||||||
|
**Gradients are allowed** for:
|
||||||
|
- Now-playing cards (domain color tinted: `bg-gradient-to-br from-[color]-500/10`)
|
||||||
|
- YouTube hero/live cards
|
||||||
|
- Icon containers in reading pages (`from-[color]-400 to-[color]-600`)
|
||||||
|
|
||||||
|
Gradients are NOT used for:
|
||||||
|
- Standard cards, widgets, or page backgrounds
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
| Role | Style | Usage |
|
||||||
|
|------|-------|-------|
|
||||||
|
| Headlines | Inter, `font-bold` | Page titles, section headings |
|
||||||
|
| Body | Inter, normal weight | Paragraphs, descriptions |
|
||||||
|
| Labels | Inter, `font-medium` or `font-semibold` | Badges, nav items, metadata labels |
|
||||||
|
| **Dates/timestamps** | **`font-mono text-sm`** | Every `<time>` element, stat numbers, version numbers |
|
||||||
|
| Code | `font-mono` | Commit SHAs, code blocks, technical identifiers |
|
||||||
|
|
||||||
|
### Date treatment rule
|
||||||
|
|
||||||
|
Every rendered date (via `dateDisplay` or `date()` filter) gets `font-mono`. This adds technical texture throughout the site — like timestamps in a log.
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
Base: 4px (Tailwind default rem scale).
|
||||||
|
|
||||||
|
Extracted dominant patterns:
|
||||||
|
- Component internal: `p-4` (cards), `p-3` (compact), `p-5` (featured)
|
||||||
|
- Gaps: `gap-2` (tight lists), `gap-3` (standard), `gap-4` (spacious)
|
||||||
|
- Section separation: `mb-6` to `mb-8`
|
||||||
|
- Micro: `px-2 py-0.5` (badges), `px-3 py-1.5` (pills)
|
||||||
|
|
||||||
|
## Border Radius
|
||||||
|
|
||||||
|
| Element | Radius |
|
||||||
|
|---------|--------|
|
||||||
|
| Cards, inputs, buttons | `rounded-lg` (dominant: 124×) |
|
||||||
|
| Avatars, status dots, badges | `rounded-full` (89×) |
|
||||||
|
| Featured/hero cards | `rounded-xl` (21×) |
|
||||||
|
| Now-playing sections | `rounded-xl sm:rounded-2xl` |
|
||||||
|
|
||||||
|
## Interaction States
|
||||||
|
|
||||||
|
Every interactive element needs:
|
||||||
|
- **hover:** color shift (`transition-colors` — already dominant at 93×)
|
||||||
|
- **focus:** visible ring (`focus:ring-2 focus:ring-accent-500` or domain equivalent)
|
||||||
|
- **active:** not currently implemented — add where it matters (buttons)
|
||||||
|
|
||||||
|
Card hover pattern: border color shifts to domain color, no shadow change.
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
- Class-based: `darkMode: "class"` — toggled via button in header
|
||||||
|
- Surfaces invert: light canvas (`surface-50`) → dark canvas (`surface-900`)
|
||||||
|
- Cards: `surface-100` → `surface-800`
|
||||||
|
- Domain colors shift to 400-weight (brighter) in dark mode
|
||||||
|
- Borders: `surface-200` → `surface-700`
|
||||||
|
- Shadows remain `shadow-sm` (less visible but still present for subtle lift)
|
||||||
|
|
||||||
|
## What Needs Implementation
|
||||||
|
|
||||||
|
Audit findings — these are the gaps between this system and the current code:
|
||||||
|
|
||||||
|
1. **font-mono on dates** — 80+ date elements need `font-mono text-sm` added
|
||||||
|
2. **Domain colors on section pages** — page titles, links, hovers, card borders need domain color on their respective pages
|
||||||
|
3. **Shadow standardization** — currently mixed; standardize to the levels defined above
|
||||||
|
4. **Gradient cleanup** — remove `to-white` (github.njk), standardize gradient pattern
|
||||||
|
5. **Focus states** — add `focus:ring-2` to all interactive elements (currently only 10 across 6 files)
|
||||||
|
6. **Active states** — add to buttons
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
layout: layouts/base.njk
|
||||||
|
title: Page Not Found
|
||||||
|
permalink: /404.html
|
||||||
|
pagefindIgnore: true
|
||||||
|
---
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<h1 class="text-4xl sm:text-6xl font-bold text-surface-300 dark:text-surface-700 mb-4">404</h1>
|
||||||
|
<h2 class="text-xl sm:text-2xl font-semibold text-surface-900 dark:text-surface-100 mb-4">Page Not Found</h2>
|
||||||
|
<p class="text-surface-600 dark:text-surface-400 mb-8">Sorry, the page you're looking for doesn't exist. Try searching for it below.</p>
|
||||||
|
<a href="/" class="inline-flex items-center gap-2 px-6 py-3 bg-accent-600 hover:bg-accent-700 text-white font-medium rounded-lg transition-colors mb-8">
|
||||||
|
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
|
||||||
|
Go back home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="search-404" class="max-w-2xl mx-auto"></div>
|
||||||
|
<script>initPagefind("#search-404", { showSubResults: true });</script>
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
# CLAUDE.md - Indiekit Eleventy Theme
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code when working with the Indiekit Eleventy theme.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This is a comprehensive Eleventy theme designed for IndieWeb-powered personal websites using Indiekit. It renders Micropub posts (articles, notes, photos, bookmarks, likes, replies, reposts), integrates with Indiekit endpoint plugins for enhanced functionality (CV, homepage builder, GitHub, Funkwhale, Last.fm, YouTube, RSS, Microsub, etc.), and includes full webmention support.
|
||||||
|
|
||||||
|
**Live Site:** https://rmendes.net
|
||||||
|
**Used as Git submodule in:**
|
||||||
|
- `/home/rick/code/indiekit-dev/indiekit-cloudron` (Cloudron deployment)
|
||||||
|
- `/home/rick/code/indiekit-dev/indiekit-deploy` (Docker Compose deployment)
|
||||||
|
|
||||||
|
## CRITICAL: Submodule Workflow
|
||||||
|
|
||||||
|
**This repo is used as a Git submodule.** After ANY changes:
|
||||||
|
|
||||||
|
1. **Edit, commit, and push** this repo (indiekit-eleventy-theme)
|
||||||
|
2. **Update submodule pointer** in parent repo(s):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/rick/code/indiekit-dev/indiekit-cloudron
|
||||||
|
git submodule update --remote eleventy-site
|
||||||
|
git add eleventy-site
|
||||||
|
git commit -m "chore: update eleventy-site submodule"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Redeploy:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/rick/code/indiekit-dev/indiekit-cloudron
|
||||||
|
make prepare # REQUIRED — copies .rmendes files to non-suffixed versions
|
||||||
|
cloudron build --no-cache && cloudron update --app rmendes.net --no-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common mistake:** Editing files in `indiekit-cloudron/eleventy-site/` instead of this repo. Those changes are ephemeral — always edit here.
|
||||||
|
|
||||||
|
## CRITICAL: Indiekit Date Handling Convention
|
||||||
|
|
||||||
|
**All dates MUST be stored and passed as ISO 8601 strings.** This is the universal pattern across Indiekit and ALL `@rmdes/*` plugins.
|
||||||
|
|
||||||
|
### The Rule
|
||||||
|
|
||||||
|
- **Storage (MongoDB):** Store dates as ISO strings (`new Date().toISOString()`), NEVER as JavaScript `Date` objects
|
||||||
|
- **Controllers:** Pass date strings through to templates unchanged — NO conversion helpers, NO `formatDate()` wrappers
|
||||||
|
- **Templates:** Use the `| date` Nunjucks filter for display formatting (e.g., `{{ value | date("PPp") }}`)
|
||||||
|
- **Template guards:** Always wrap `| date` in `{% if value %}` to protect against null/undefined
|
||||||
|
|
||||||
|
### Why This Matters
|
||||||
|
|
||||||
|
The Nunjucks `| date` filter is `@indiekit/util`'s `formatDate()`, which calls `date-fns parseISO(string)`. It ONLY accepts ISO 8601 strings:
|
||||||
|
|
||||||
|
- `Date` objects → `dateString.split is not a function` (CRASH)
|
||||||
|
- `null` / `undefined` → `Cannot read properties of undefined (reading 'match')` (CRASH)
|
||||||
|
- Pre-formatted strings (e.g., "8 Feb 2025") → Invalid Date (WRONG OUTPUT)
|
||||||
|
- ISO strings (e.g., `"2025-02-08T14:30:00.000Z"`) → Correctly formatted (WORKS)
|
||||||
|
|
||||||
|
### Correct Pattern
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// _data file - store/return ISO strings
|
||||||
|
export default async function () {
|
||||||
|
const data = await fetch(...);
|
||||||
|
return {
|
||||||
|
lastSync: new Date().toISOString(), // ← ISO string
|
||||||
|
items: data.map(item => ({
|
||||||
|
published: item.published || null, // ← already ISO string from API
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```nunjucks
|
||||||
|
{# Template - use | date filter, guard for null #}
|
||||||
|
{% if lastSync %}
|
||||||
|
{{ lastSync | date("PPp") }}
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Data Flow: Plugin → JSON → _data → Template
|
||||||
|
|
||||||
|
```
|
||||||
|
Indiekit Plugin (backend)
|
||||||
|
→ writes JSON to content/.indiekit/*.json
|
||||||
|
→ _data/*.js reads JSON file
|
||||||
|
→ Nunjucks template renders data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:** CV plugin flow
|
||||||
|
|
||||||
|
1. `@rmdes/indiekit-endpoint-cv` writes `content/.indiekit/cv.json`
|
||||||
|
2. `_data/cv.js` reads the JSON file and exports the data
|
||||||
|
3. `cv.njk` and `_includes/components/sections/cv-*.njk` render the data
|
||||||
|
4. Homepage builder can include CV sections via `homepageConfig.sections`
|
||||||
|
|
||||||
|
### Key Files by Function
|
||||||
|
|
||||||
|
#### Core Configuration
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `eleventy.config.js` | Eleventy configuration, plugins, filters, collections, post-build hooks |
|
||||||
|
| `tailwind.config.js` | Tailwind CSS configuration (colors, typography) |
|
||||||
|
| `postcss.config.js` | PostCSS pipeline (Tailwind, autoprefixer) |
|
||||||
|
| `package.json` | Dependencies, scripts (`build`, `dev`, `build:css`) |
|
||||||
|
|
||||||
|
#### Data Files (_data/)
|
||||||
|
|
||||||
|
All `_data/*.js` files are ESM modules that export functions returning data objects. Most fetch from Indiekit plugin JSON files or external APIs.
|
||||||
|
|
||||||
|
| File | Data Source | Purpose |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `site.js` | Environment variables | Site config (name, URL, author, social links) |
|
||||||
|
| `cv.js` | `content/.indiekit/cv.json` | CV data from `@rmdes/indiekit-endpoint-cv` |
|
||||||
|
| `homepageConfig.js` | `content/.indiekit/homepage.json` | Homepage layout from `@rmdes/indiekit-endpoint-homepage` |
|
||||||
|
| `enabledPostTypes.js` | `content/.indiekit/post-types.json` or env | List of enabled post types for navigation |
|
||||||
|
| `urlAliases.js` | `content/.indiekit/url-aliases.json` | Legacy URL mappings for webmentions |
|
||||||
|
| `blogrollStatus.js` | Indiekit `/blogrollapi/api/status` | Checks if blogroll plugin is available |
|
||||||
|
| `podrollStatus.js` | Indiekit `/podroll/api/status` | Checks if podroll plugin is available |
|
||||||
|
| `githubActivity.js` | Indiekit `/githubapi/api/*` or GitHub API | GitHub commits, stars, featured repos |
|
||||||
|
| `githubRepos.js` | GitHub API | Starred repositories for sidebar |
|
||||||
|
| `funkwhaleActivity.js` | Indiekit Funkwhale plugin API | Listening activity |
|
||||||
|
| `lastfmActivity.js` | Indiekit Last.fm plugin API | Scrobbles, loved tracks |
|
||||||
|
| `newsActivity.js` | Indiekit IndieNews plugin API | Submitted IndieNews posts |
|
||||||
|
| `youtubeChannel.js` | YouTube Data API v3 | Channel info, latest videos, live status |
|
||||||
|
| `blueskyFeed.js` | Bluesky AT Protocol API | Recent Bluesky posts for sidebar |
|
||||||
|
| `mastodonFeed.js` | Mastodon API | Recent Mastodon posts for sidebar |
|
||||||
|
|
||||||
|
**Data Source Pattern:**
|
||||||
|
|
||||||
|
Most plugin-dependent data files:
|
||||||
|
1. Try to fetch from Indiekit plugin API first
|
||||||
|
2. Fall back to direct API (if credentials available)
|
||||||
|
3. Return `{ source: "indiekit" | "api" | "error", ...data }`
|
||||||
|
4. Templates check `source` to conditionally display
|
||||||
|
|
||||||
|
#### Layouts (_includes/layouts/)
|
||||||
|
|
||||||
|
| File | Used By | Features |
|
||||||
|
|------|---------|----------|
|
||||||
|
| `base.njk` | All pages | Base HTML shell with header, footer, nav, meta tags |
|
||||||
|
| `home.njk` | Homepage | Two-tier fallback: plugin-driven (homepage builder) or default (hero + recent posts) |
|
||||||
|
| `post.njk` | Individual posts | h-entry microformat, Bridgy syndication, webmentions, reply context, photo gallery |
|
||||||
|
| `page.njk` | Static pages | Simple content wrapper, no post metadata |
|
||||||
|
|
||||||
|
#### Components (_includes/components/)
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `homepage-builder.njk` | Renders plugin-configured homepage layout (single/two-column, sections, sidebar) |
|
||||||
|
| `homepage-section.njk` | Router for section types (hero, cv-*, custom-html, recent-posts) |
|
||||||
|
| `homepage-sidebar.njk` | Renders plugin-configured sidebar widgets |
|
||||||
|
| `homepage-footer.njk` | Optional homepage footer with admin link |
|
||||||
|
| `sidebar.njk` | Default sidebar (author card, social activity, GitHub, Funkwhale, blogroll, categories) |
|
||||||
|
| `blog-sidebar.njk` | Sidebar for blog/post pages (recent posts, categories) |
|
||||||
|
| `h-card.njk` | Microformat2 h-card for author identity |
|
||||||
|
| `reply-context.njk` | Displays reply-to/like-of/repost-of/bookmark-of context with h-cite |
|
||||||
|
| `webmentions.njk` | Renders likes, reposts, replies from webmention.io + send form |
|
||||||
|
| `empty-collection.njk` | Fallback message when a post type collection is empty |
|
||||||
|
|
||||||
|
#### Sections (_includes/components/sections/)
|
||||||
|
|
||||||
|
Homepage builder sections:
|
||||||
|
|
||||||
|
| Section | Config Type | Purpose |
|
||||||
|
|---------|-------------|---------|
|
||||||
|
| `hero.njk` | `hero` | Full-width hero with avatar, name, bio, social links |
|
||||||
|
| `recent-posts.njk` | `recent-posts` | Recent posts grid (configurable maxItems, postTypes filter) |
|
||||||
|
| `cv-experience.njk` | `cv-experience` | Work experience timeline from CV data |
|
||||||
|
| `cv-skills.njk` | `cv-skills` | Skills with proficiency bars from CV data |
|
||||||
|
| `cv-education.njk` | `cv-education` | Education history from CV data |
|
||||||
|
| `cv-projects.njk` | `cv-projects` | Featured projects from CV data |
|
||||||
|
| `cv-interests.njk` | `cv-interests` | Personal interests from CV data |
|
||||||
|
| `custom-html.njk` | `custom-html` | Arbitrary HTML content (from admin UI) |
|
||||||
|
|
||||||
|
#### Widgets (_includes/components/widgets/)
|
||||||
|
|
||||||
|
Sidebar widgets:
|
||||||
|
|
||||||
|
| Widget | Data Source | Purpose |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `author-card.njk` | `site.author` | h-card with avatar, bio, social links |
|
||||||
|
| `social-activity.njk` | `blueskyFeed`, `mastodonFeed` | Recent posts from Bluesky/Mastodon |
|
||||||
|
| `github-repos.njk` | `githubActivity`, `githubRepos` | Featured repos, recent commits |
|
||||||
|
| `funkwhale.njk` | `funkwhaleActivity` | Now playing, listening stats |
|
||||||
|
| `recent-posts.njk` | `collections.posts` | Recent posts list (for non-blog pages) |
|
||||||
|
| `blogroll.njk` | Blogroll API | Recently updated blogs from OPML/Microsub |
|
||||||
|
| `categories.njk` | `collections.categories` | Category list with post counts |
|
||||||
|
|
||||||
|
#### Top-Level Templates (*.njk)
|
||||||
|
|
||||||
|
Page templates in the root directory:
|
||||||
|
|
||||||
|
| Template | Permalink | Purpose |
|
||||||
|
|----------|-----------|---------|
|
||||||
|
| `index.njk` | `/` | Homepage (uses `home.njk` layout) |
|
||||||
|
| `about.njk` | `/about/` | About page with full h-card |
|
||||||
|
| `cv.njk` | `/cv/` | CV page with all sections |
|
||||||
|
| `blog.njk` | `/blog/` | All posts chronologically |
|
||||||
|
| `articles.njk` | `/articles/` | Articles collection |
|
||||||
|
| `notes.njk` | `/notes/` | Notes collection |
|
||||||
|
| `photos.njk` | `/photos/` | Photos collection |
|
||||||
|
| `bookmarks.njk` | `/bookmarks/` | Bookmarks collection |
|
||||||
|
| `likes.njk` | `/likes/` | Likes collection |
|
||||||
|
| `replies.njk` | `/replies/` | Replies collection |
|
||||||
|
| `reposts.njk` | `/reposts/` | Reposts collection |
|
||||||
|
| `interactions.njk` | `/interactions/` | Combined social interactions |
|
||||||
|
| `slashes.njk` | `/slashes/` | Index of all slash pages |
|
||||||
|
| `categories.njk` | `/categories/:slug/` | Posts by category (pagination template) |
|
||||||
|
| `categories-index.njk` | `/categories/` | All categories index |
|
||||||
|
| `github.njk` | `/github/` | GitHub activity page |
|
||||||
|
| `funkwhale.njk` | `/funkwhale/` | Funkwhale listening page |
|
||||||
|
| `listening.njk` | `/listening/` | Last.fm listening page |
|
||||||
|
| `youtube.njk` | `/youtube/` | YouTube channel page |
|
||||||
|
| `blogroll.njk` | `/blogroll/` | Blogroll page (client-side data fetch) |
|
||||||
|
| `podroll.njk` | `/podroll/` | Podroll (podcast episodes) page |
|
||||||
|
| `news.njk` | `/news/` | IndieNews submissions page |
|
||||||
|
| `search.njk` | `/search/` | Pagefind search UI |
|
||||||
|
| `feed.njk` | `/feed.xml` | RSS 2.0 feed |
|
||||||
|
| `feed-json.njk` | `/feed.json` | JSON Feed 1.1 |
|
||||||
|
| `404.njk` | `/404.html` | 404 error page |
|
||||||
|
| `changelog.njk` | `/changelog/` | Site changelog |
|
||||||
|
| `webmention-debug.njk` | `/webmention-debug/` | Debug page for webmentions |
|
||||||
|
|
||||||
|
### Eleventy Configuration Highlights
|
||||||
|
|
||||||
|
#### Collections
|
||||||
|
|
||||||
|
| Collection | Glob Pattern | Purpose |
|
||||||
|
|------------|--------------|---------|
|
||||||
|
| `posts` | `content/**/*.md` | All content combined |
|
||||||
|
| `articles` | `content/articles/**/*.md` | Long-form posts |
|
||||||
|
| `notes` | `content/notes/**/*.md` | Short status updates |
|
||||||
|
| `photos` | `content/photos/**/*.md` | Photo posts |
|
||||||
|
| `bookmarks` | `content/bookmarks/**/*.md` | Saved links |
|
||||||
|
| `likes` | `content/likes/**/*.md` | Liked posts |
|
||||||
|
| `replies` | Filtered by `inReplyTo` property | Reply posts |
|
||||||
|
| `reposts` | Filtered by `repostOf` property | Repost posts |
|
||||||
|
| `pages` | `content/*.md` + `content/pages/*.md` | Slash pages (/about, /now, /uses, etc.) |
|
||||||
|
| `feed` | `content/**/*.md` (first 20) | Homepage/RSS feed |
|
||||||
|
| `categories` | Deduplicated from all posts | Category list |
|
||||||
|
|
||||||
|
**Note:** `replies` and `reposts` collections are dynamically filtered by property, not by directory. Supports both camelCase (`inReplyTo`, `repostOf`) and underscore (`in_reply_to`, `repost_of`) naming.
|
||||||
|
|
||||||
|
#### Custom Filters
|
||||||
|
|
||||||
|
| Filter | Purpose | Usage |
|
||||||
|
|--------|---------|-------|
|
||||||
|
| `dateDisplay` | Format date as "January 1, 2025" | `{{ date \| dateDisplay }}` |
|
||||||
|
| `isoDate` | Convert to ISO 8601 string | `{{ date \| isoDate }}` |
|
||||||
|
| `date` | Format date with custom format | `{{ date \| date("MMM d, yyyy") }}` |
|
||||||
|
| `truncate` | Truncate string to max length | `{{ text \| truncate(200) }}` |
|
||||||
|
| `ogDescription` | Strip HTML, decode entities, truncate | `{{ content \| ogDescription(200) }}` |
|
||||||
|
| `extractFirstImage` | Extract first `<img src>` from content | `{{ content \| extractFirstImage }}` |
|
||||||
|
| `obfuscateEmail` | Convert email to HTML entities | `{{ email \| obfuscateEmail }}` |
|
||||||
|
| `head` | Get first N items from array | `{{ array \| head(5) }}` |
|
||||||
|
| `slugify` | Convert string to slug | `{{ name \| slugify }}` |
|
||||||
|
| `hash` | MD5 hash of file for cache busting | `{{ '/css/style.css' \| hash }}` |
|
||||||
|
| `timestamp` | Current Unix timestamp | `{{ '' \| timestamp }}` |
|
||||||
|
| `webmentionsForUrl` | Filter webmentions by URL + aliases | `{{ webmentions \| webmentionsForUrl(page.url, urlAliases) }}` |
|
||||||
|
| `webmentionsByType` | Filter by type (likes, reposts, replies) | `{{ mentions \| webmentionsByType('likes') }}` |
|
||||||
|
| `jsonEncode` | JSON.stringify for JSON feed | `{{ value \| jsonEncode }}` |
|
||||||
|
| `dateToRfc822` | RFC 2822 format for RSS | `{{ date \| dateToRfc822 }}` |
|
||||||
|
|
||||||
|
#### Plugins
|
||||||
|
|
||||||
|
| Plugin | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `@11ty/eleventy-plugin-rss` | RSS feed filters (dateToRfc2822, absoluteUrl) |
|
||||||
|
| `@11ty/eleventy-plugin-syntaxhighlight` | Syntax highlighting for code blocks |
|
||||||
|
| `@11ty/eleventy-img` | Automatic image optimization (webp, lazy loading) |
|
||||||
|
| `eleventy-plugin-embed-everything` | Auto-embed YouTube, Vimeo, Mastodon, Bluesky, Spotify |
|
||||||
|
| `@chrisburnell/eleventy-cache-webmentions` | Build-time webmention caching |
|
||||||
|
| `@quasibit/eleventy-plugin-sitemap` | Sitemap generation |
|
||||||
|
| `html-minifier-terser` | HTML minification (production only) |
|
||||||
|
| `pagefind` | Search indexing (post-build via eleventy.after hook) |
|
||||||
|
|
||||||
|
#### Transforms
|
||||||
|
|
||||||
|
| Transform | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `youtube-link-to-embed` | Converts YouTube links to embeds |
|
||||||
|
| `htmlmin` | Minifies HTML (build mode only, not watch mode) |
|
||||||
|
| `eleventyImageTransformPlugin` | Optimizes `<img>` tags |
|
||||||
|
|
||||||
|
#### Post-Build Hooks (`eleventy.after`)
|
||||||
|
|
||||||
|
1. **Pagefind indexing** — indexes all HTML files for search
|
||||||
|
2. **WebSub hub notification** — notifies subscribers of feed updates (/, /feed.xml, /feed.json)
|
||||||
|
|
||||||
|
### IndieWeb Features
|
||||||
|
|
||||||
|
#### Microformats2
|
||||||
|
|
||||||
|
- **h-card** (author identity): Name, photo, bio, location, social links with `rel="me"`
|
||||||
|
- **h-entry** (post markup): All post types properly marked up
|
||||||
|
- **h-feed** (feed markup): Machine-readable post lists
|
||||||
|
- **h-cite** (reply context): Cites external content in replies/likes/reposts
|
||||||
|
|
||||||
|
#### Webmentions
|
||||||
|
|
||||||
|
- Build-time caching via `@chrisburnell/eleventy-cache-webmentions`
|
||||||
|
- Client-side real-time fetching via `/js/webmentions.js`
|
||||||
|
- Displays likes, reposts, replies with avatars
|
||||||
|
- Send webmention form on every post
|
||||||
|
- Legacy URL support via `urlAliases` (for micro.blog and old blog URLs)
|
||||||
|
|
||||||
|
#### IndieAuth
|
||||||
|
|
||||||
|
- `rel="me"` links in `<head>` for identity verification
|
||||||
|
- Bluesky uses `rel="me atproto"` for AT Protocol verification
|
||||||
|
- Fediverse creator meta tag for Mastodon verification
|
||||||
|
|
||||||
|
#### Micropub Endpoints
|
||||||
|
|
||||||
|
Base layout includes `<link>` tags pointing to Indiekit endpoints:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<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">
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Bridgy Syndication
|
||||||
|
|
||||||
|
Posts include hidden Bridgy syndication content in `post.njk`:
|
||||||
|
|
||||||
|
```nunjucks
|
||||||
|
<p class="p-summary e-bridgy-mastodon-content e-bridgy-bluesky-content hidden">
|
||||||
|
{# Interaction posts include emoji + target URL #}
|
||||||
|
🔖 {{ bookmarkedUrl }} - {{ description }}
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
Bridgy reads this content when syndicating to Bluesky/Mastodon. Interaction types (bookmarks, likes, replies, reposts) include emoji prefix and target URL.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
### TypeScript/JavaScript
|
||||||
|
|
||||||
|
- **ESM modules:** `"type": "module"` in package.json
|
||||||
|
- **Async data files:** `export default async function () { ... }`
|
||||||
|
- **Data source pattern:** Return `{ source: "indiekit" | "api" | "error", ...data }`
|
||||||
|
- **Date handling:** Always use ISO 8601 strings (`new Date().toISOString()`)
|
||||||
|
|
||||||
|
### Nunjucks Templates
|
||||||
|
|
||||||
|
- **Property name compatibility:** Support both camelCase and underscore names:
|
||||||
|
|
||||||
|
```nunjucks
|
||||||
|
{% set bookmarkedUrl = bookmarkOf or bookmark_of %}
|
||||||
|
{% set replyTo = inReplyTo or in_reply_to %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Date filter guards:** Always check for null/undefined:
|
||||||
|
|
||||||
|
```nunjucks
|
||||||
|
{% if published %}
|
||||||
|
{{ published | date("PPp") }}
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Markdown engine disabled:** `markdownTemplateEngine: false` to prevent parsing `{{` in content
|
||||||
|
- **Safe filter usage:** Use `| safe` for trusted HTML content only
|
||||||
|
- **Microformats classes:** Follow IndieWeb conventions (h-entry, p-name, dt-published, e-content, u-photo, etc.)
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
- **Tailwind CSS** for all styling
|
||||||
|
- **Dark mode:** `dark:` variants, controlled by `.dark` class on `<html>`
|
||||||
|
- **Custom color palette:** `primary` (blue) and `surface` (neutral)
|
||||||
|
- **Typography plugin:** `prose` classes for content rendering
|
||||||
|
- **Responsive design:** Mobile-first, breakpoints: `sm:`, `md:`, `lg:`
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a New Post Type
|
||||||
|
|
||||||
|
1. **Create collection** in `eleventy.config.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
eleventyConfig.addCollection("checkins", function (collectionApi) {
|
||||||
|
return collectionApi
|
||||||
|
.getFilteredByGlob("content/checkins/**/*.md")
|
||||||
|
.sort((a, b) => b.date - a.date);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create collection page** (e.g., `checkins.njk`):
|
||||||
|
|
||||||
|
```nunjucks
|
||||||
|
---
|
||||||
|
layout: layouts/page.njk
|
||||||
|
title: Check-ins
|
||||||
|
withBlogSidebar: true
|
||||||
|
permalink: /checkins/
|
||||||
|
---
|
||||||
|
{% for post in collections.checkins %}
|
||||||
|
{# render post #}
|
||||||
|
{% endfor %}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add to enabledPostTypes** (optional, for nav):
|
||||||
|
|
||||||
|
Edit `_data/enabledPostTypes.js` or set `POST_TYPES` env var.
|
||||||
|
|
||||||
|
4. **Update `reply-context.njk`** if the post type has a target URL property.
|
||||||
|
|
||||||
|
5. **Update `post.njk` Bridgy content** if the post type needs special syndication text.
|
||||||
|
|
||||||
|
6. **Commit, push, and update submodule.**
|
||||||
|
|
||||||
|
### Adding a New Data Source
|
||||||
|
|
||||||
|
1. **Create `_data/newSource.js`:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
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}/newapi/api/data`;
|
||||||
|
const data = await EleventyFetch(url, {
|
||||||
|
duration: "15m",
|
||||||
|
type: "json",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
source: "indiekit",
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[newSource] API unavailable: ${error.message}`);
|
||||||
|
return {
|
||||||
|
source: "error",
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use in template:**
|
||||||
|
|
||||||
|
```nunjucks
|
||||||
|
{% if newSource and newSource.source == "indiekit" %}
|
||||||
|
{% for item in newSource.items %}
|
||||||
|
{# render item #}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add status check** to base.njk navigation (if needed).
|
||||||
|
|
||||||
|
### Adding a New Homepage Section
|
||||||
|
|
||||||
|
1. **Create section template** in `_includes/components/sections/`:
|
||||||
|
|
||||||
|
```nunjucks
|
||||||
|
{# new-section.njk #}
|
||||||
|
{% set sectionConfig = section.config or {} %}
|
||||||
|
{% set maxItems = sectionConfig.maxItems or 5 %}
|
||||||
|
|
||||||
|
<section class="mb-8 sm:mb-12">
|
||||||
|
<h2 class="text-xl sm:text-2xl font-bold">{{ sectionConfig.title or "New Section" }}</h2>
|
||||||
|
{# render content #}
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Register in `homepage-section.njk`:**
|
||||||
|
|
||||||
|
```nunjucks
|
||||||
|
{% if section.type == "new-section" %}
|
||||||
|
{% include "components/sections/new-section.njk" %}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Plugin integration:** The plugin that provides this section should register it via `homepageSections` in Indiekit.
|
||||||
|
|
||||||
|
### Debugging Webmentions
|
||||||
|
|
||||||
|
1. **Check build-time cache:** Look at `webmention-debug.njk` page
|
||||||
|
2. **Check client-side fetch:** Open browser console, check for fetch requests to `/webmentions/api/mentions`
|
||||||
|
3. **Verify target URL:** Webmentions must match exact URL (with or without trailing slash)
|
||||||
|
4. **Check legacy URLs:** Verify `urlAliases` data includes old URLs if needed
|
||||||
|
|
||||||
|
### Theming and Customization
|
||||||
|
|
||||||
|
1. **Colors:** Edit `tailwind.config.js` → `theme.extend.colors`
|
||||||
|
2. **Typography:** Edit `tailwind.config.js` → `theme.extend.typography`
|
||||||
|
3. **CSS utilities:** Add custom utilities to `css/tailwind.css`
|
||||||
|
4. **Rebuild CSS:** `npm run build:css` (or `make build:css` in parent repo)
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
1. ❌ **Forgetting to update submodule** after changes
|
||||||
|
2. ❌ **Editing files in submodule directory** (`indiekit-cloudron/eleventy-site/`)
|
||||||
|
3. ❌ **Using Date objects instead of ISO strings** for dates
|
||||||
|
4. ❌ **Not guarding `| date` filters** against null/undefined
|
||||||
|
5. ❌ **Using only underscore property names** (support both camelCase and underscore)
|
||||||
|
6. ❌ **Using `markdownTemplateEngine: "njk"`** (breaks code samples with `{{`)
|
||||||
|
7. ❌ **Hardcoding personal data in templates** (use environment variables)
|
||||||
|
8. ❌ **Forgetting to run `make prepare`** before `cloudron build` (deploys stale config)
|
||||||
|
9. ❌ **Using unsafe HTML string assignment in client-side JS** (security hooks reject it — use `createElement` + `textContent`)
|
||||||
|
10. ❌ **Removing overrides without checking if they shadow submodule files** (causes stale data)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "dateString.split is not a function"
|
||||||
|
|
||||||
|
**Cause:** A Date object was passed to the `| date` filter.
|
||||||
|
**Fix:** Store dates as ISO strings from the start: `new Date().toISOString()`
|
||||||
|
|
||||||
|
### Stale data in homepage/CV despite correct JSON files
|
||||||
|
|
||||||
|
**Cause:** Override file in `indiekit-cloudron/overrides/eleventy-site/` shadows the submodule.
|
||||||
|
**Fix:** Delete the override file and reset submodule: `cd eleventy-site && git checkout -- _data/file.js`
|
||||||
|
|
||||||
|
### Webmentions not appearing
|
||||||
|
|
||||||
|
**Causes:**
|
||||||
|
- Build-time cache expired (rebuild to refresh)
|
||||||
|
- Client-side JS blocked by CSP (check console)
|
||||||
|
- Target URL mismatch (check with/without trailing slash)
|
||||||
|
- webmention.io down (check status)
|
||||||
|
|
||||||
|
**Fix:** Check `webmention-debug.njk` page, verify `webmentionsForUrl` filter is working.
|
||||||
|
|
||||||
|
### Plugin data not appearing in navigation
|
||||||
|
|
||||||
|
**Cause:** The plugin's status endpoint is unavailable or returning `source: "error"`.
|
||||||
|
**Fix:** Check the plugin's API is running, verify environment variables are set.
|
||||||
|
|
||||||
|
### YouTube embeds not working
|
||||||
|
|
||||||
|
**Causes:**
|
||||||
|
- URL doesn't match pattern (must be youtube.com/watch or youtu.be)
|
||||||
|
- Link text doesn't contain "youtube" or URL (transform matches specific patterns)
|
||||||
|
|
||||||
|
**Fix:** Use embed plugin shortcode or raw `<iframe>` instead.
|
||||||
|
|
||||||
|
## Workspace Context
|
||||||
|
|
||||||
|
This repo is part of the Indiekit development workspace at `/home/rick/code/indiekit-dev/`. See the workspace CLAUDE.md for the full repository map and plugin architecture.
|
||||||
@@ -1,2 +1,498 @@
|
|||||||
# blog :)
|
# Indiekit Eleventy Theme
|
||||||
try to emulate https://rmendes.net/
|
|
||||||
|
A modern, IndieWeb-native Eleventy theme designed for [Indiekit](https://getindiekit.com/)-powered personal websites. Own your content, syndicate everywhere.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### IndieWeb First
|
||||||
|
|
||||||
|
This theme is built from the ground up for the IndieWeb:
|
||||||
|
|
||||||
|
- **Microformats2** markup (h-card, h-entry, h-feed, h-cite)
|
||||||
|
- **Webmentions** via webmention.io (likes, reposts, replies)
|
||||||
|
- **IndieAuth** with rel="me" verification
|
||||||
|
- **Micropub** integration with Indiekit
|
||||||
|
- **POSSE** syndication to Bluesky, Mastodon, LinkedIn, IndieNews
|
||||||
|
|
||||||
|
### Full Post Type Support
|
||||||
|
|
||||||
|
All IndieWeb post types via Indiekit:
|
||||||
|
- **Articles** — Long-form blog posts with titles
|
||||||
|
- **Notes** — Short status updates (like tweets)
|
||||||
|
- **Photos** — Image posts with multi-photo galleries
|
||||||
|
- **Bookmarks** — Save and share links with descriptions
|
||||||
|
- **Likes** — Appreciate others' content
|
||||||
|
- **Replies** — Respond to posts across the web
|
||||||
|
- **Reposts** — Share others' content
|
||||||
|
- **Pages** — Root-level slash pages (/about, /now, /uses)
|
||||||
|
|
||||||
|
### Homepage Builder
|
||||||
|
|
||||||
|
Dynamic, plugin-configured homepage with:
|
||||||
|
- **Hero section** with avatar, bio, social links
|
||||||
|
- **Recent posts** with configurable filtering
|
||||||
|
- **CV sections** (experience, skills, education, projects, interests)
|
||||||
|
- **Custom HTML** sections from admin UI
|
||||||
|
- **Two-column layout** with configurable sidebar
|
||||||
|
- **Single-column** or **full-width hero** layouts
|
||||||
|
|
||||||
|
### Plugin Integration
|
||||||
|
|
||||||
|
Integrates with custom Indiekit endpoint plugins:
|
||||||
|
|
||||||
|
| Plugin | Features |
|
||||||
|
|--------|----------|
|
||||||
|
| `@rmdes/indiekit-endpoint-homepage` | Dynamic homepage builder with admin UI |
|
||||||
|
| `@rmdes/indiekit-endpoint-cv` | CV/resume builder with admin UI |
|
||||||
|
| `@rmdes/indiekit-endpoint-github` | GitHub activity, commits, stars, featured repos |
|
||||||
|
| `@rmdes/indiekit-endpoint-funkwhale` | Listening activity from Funkwhale |
|
||||||
|
| `@rmdes/indiekit-endpoint-lastfm` | Scrobbles and loved tracks from Last.fm |
|
||||||
|
| `@rmdes/indiekit-endpoint-youtube` | Channel info, latest videos, live status |
|
||||||
|
| `@rmdes/indiekit-endpoint-blogroll` | OPML/Microsub blog aggregator with admin UI |
|
||||||
|
| `@rmdes/indiekit-endpoint-podroll` | Podcast episode aggregator |
|
||||||
|
| `@rmdes/indiekit-endpoint-rss` | RSS feed reader with MongoDB caching |
|
||||||
|
| `@rmdes/indiekit-endpoint-microsub` | Social reader with channels and timeline |
|
||||||
|
|
||||||
|
### Modern Tech Stack
|
||||||
|
|
||||||
|
- **Eleventy 3.0** — Fast, flexible static site generator
|
||||||
|
- **Tailwind CSS** — Utility-first styling with dark mode
|
||||||
|
- **Alpine.js** — Lightweight JavaScript framework
|
||||||
|
- **Pagefind** — Fast client-side search
|
||||||
|
- **Markdown-it** — Rich markdown with auto-linking
|
||||||
|
- **Image optimization** — Automatic WebP conversion, lazy loading
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### As a Git Submodule (Recommended)
|
||||||
|
|
||||||
|
This theme is designed to be used as a Git submodule in your Indiekit deployment repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In your Indiekit deployment repo
|
||||||
|
git submodule add https://github.com/rmdes/indiekit-eleventy-theme.git eleventy-site
|
||||||
|
git submodule update --init --recursive
|
||||||
|
cd eleventy-site
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why submodule?** Keeps the theme neutral (no personal data), allows upstream updates, and separates theme development from deployment.
|
||||||
|
|
||||||
|
### Standalone Installation
|
||||||
|
|
||||||
|
For local development or testing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/rmdes/indiekit-eleventy-theme.git
|
||||||
|
cd indiekit-eleventy-theme
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
**All configuration is done via environment variables** — the theme contains no hardcoded personal data.
|
||||||
|
|
||||||
|
### Required Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Site basics
|
||||||
|
SITE_URL="https://your-site.com"
|
||||||
|
SITE_NAME="Your Site Name"
|
||||||
|
SITE_DESCRIPTION="A short description of your site"
|
||||||
|
SITE_LOCALE="en"
|
||||||
|
|
||||||
|
# Author info (displayed in h-card)
|
||||||
|
AUTHOR_NAME="Your Name"
|
||||||
|
AUTHOR_BIO="A short bio about yourself"
|
||||||
|
AUTHOR_AVATAR="/images/avatar.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Social Links
|
||||||
|
|
||||||
|
Format: `Name|URL|icon,Name|URL|icon`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SITE_SOCIAL="GitHub|https://github.com/you|github,Mastodon|https://mastodon.social/@you|mastodon,Bluesky|https://bsky.app/profile/you|bluesky"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auto-generation:** If `SITE_SOCIAL` is not set, social links are automatically generated from feed credentials (GitHub, Bluesky, Mastodon, LinkedIn).
|
||||||
|
|
||||||
|
### Optional Author Fields
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTHOR_TITLE="Software Developer"
|
||||||
|
AUTHOR_LOCATION="City, Country"
|
||||||
|
AUTHOR_LOCALITY="City"
|
||||||
|
AUTHOR_REGION="State/Province"
|
||||||
|
AUTHOR_COUNTRY="Country"
|
||||||
|
AUTHOR_ORG="Company Name"
|
||||||
|
AUTHOR_PRONOUN="they/them"
|
||||||
|
AUTHOR_CATEGORIES="IndieWeb,Open Source,Photography" # Comma-separated
|
||||||
|
AUTHOR_KEY_URL="https://keybase.io/you/pgp_keys.asc"
|
||||||
|
AUTHOR_EMAIL="you@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Social Activity Feeds
|
||||||
|
|
||||||
|
For sidebar social activity widgets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bluesky
|
||||||
|
BLUESKY_HANDLE="you.bsky.social"
|
||||||
|
|
||||||
|
# Mastodon
|
||||||
|
MASTODON_INSTANCE="https://mastodon.social"
|
||||||
|
MASTODON_USER="your-username"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin API Credentials
|
||||||
|
|
||||||
|
#### GitHub Activity
|
||||||
|
```bash
|
||||||
|
GITHUB_USERNAME="your-username"
|
||||||
|
GITHUB_TOKEN="ghp_xxxx" # Personal access token (optional, increases rate limit)
|
||||||
|
GITHUB_FEATURED_REPOS="user/repo1,user/repo2" # Comma-separated
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Funkwhale
|
||||||
|
```bash
|
||||||
|
FUNKWHALE_INSTANCE="https://your-instance.com"
|
||||||
|
FUNKWHALE_USERNAME="your-username"
|
||||||
|
FUNKWHALE_TOKEN="your-api-token"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### YouTube
|
||||||
|
```bash
|
||||||
|
YOUTUBE_API_KEY="your-api-key"
|
||||||
|
YOUTUBE_CHANNELS="@channel1,@channel2" # Comma-separated handles
|
||||||
|
```
|
||||||
|
|
||||||
|
#### LinkedIn
|
||||||
|
```bash
|
||||||
|
LINKEDIN_USERNAME="your-username"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Post Type Configuration
|
||||||
|
|
||||||
|
Control which post types appear in navigation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Environment variable (comma-separated)
|
||||||
|
POST_TYPES="article,note,photo,bookmark"
|
||||||
|
|
||||||
|
# Option 2: JSON file (written by Indiekit or deployer)
|
||||||
|
# Create content/.indiekit/post-types.json:
|
||||||
|
# ["article", "note", "photo"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Default:** All standard post types enabled (article, note, photo, bookmark, like, reply, repost).
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
indiekit-eleventy-theme/
|
||||||
|
├── _data/ # Data files
|
||||||
|
│ ├── site.js # Site config from env vars
|
||||||
|
│ ├── cv.js # CV data from plugin
|
||||||
|
│ ├── homepageConfig.js # Homepage layout from plugin
|
||||||
|
│ ├── enabledPostTypes.js # Post types for navigation
|
||||||
|
│ ├── githubActivity.js # GitHub data (Indiekit API → GitHub API fallback)
|
||||||
|
│ ├── funkwhaleActivity.js # Funkwhale listening activity
|
||||||
|
│ ├── lastfmActivity.js # Last.fm scrobbles
|
||||||
|
│ ├── youtubeChannel.js # YouTube channel info
|
||||||
|
│ ├── blueskyFeed.js # Bluesky posts for sidebar
|
||||||
|
│ ├── mastodonFeed.js # Mastodon posts for sidebar
|
||||||
|
│ ├── blogrollStatus.js # Blogroll API availability check
|
||||||
|
│ └── urlAliases.js # Legacy URL mappings for webmentions
|
||||||
|
├── _includes/
|
||||||
|
│ ├── layouts/
|
||||||
|
│ │ ├── base.njk # Base HTML shell (header, footer, nav)
|
||||||
|
│ │ ├── home.njk # Homepage layout (plugin vs default)
|
||||||
|
│ │ ├── post.njk # Individual post (h-entry, webmentions)
|
||||||
|
│ │ └── page.njk # Simple page layout
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── homepage-builder.njk # Renders plugin homepage config
|
||||||
|
│ │ ├── homepage-section.njk # Section router
|
||||||
|
│ │ ├── sidebar.njk # Default sidebar
|
||||||
|
│ │ ├── h-card.njk # Author identity card
|
||||||
|
│ │ ├── reply-context.njk # Reply/like/repost context
|
||||||
|
│ │ └── webmentions.njk # Webmention display + form
|
||||||
|
│ │ ├── sections/
|
||||||
|
│ │ │ ├── hero.njk # Homepage hero
|
||||||
|
│ │ │ ├── recent-posts.njk # Recent posts grid
|
||||||
|
│ │ │ ├── cv-experience.njk # Work experience timeline
|
||||||
|
│ │ │ ├── cv-skills.njk # Skills with proficiency
|
||||||
|
│ │ │ ├── cv-education.njk # Education history
|
||||||
|
│ │ │ ├── cv-projects.njk # Featured projects
|
||||||
|
│ │ │ ├── cv-interests.njk # Personal interests
|
||||||
|
│ │ │ └── custom-html.njk # Custom HTML content
|
||||||
|
│ │ └── widgets/
|
||||||
|
│ │ ├── author-card.njk # Sidebar h-card
|
||||||
|
│ │ ├── social-activity.njk # Bluesky/Mastodon feed
|
||||||
|
│ │ ├── github-repos.njk # GitHub featured repos
|
||||||
|
│ │ ├── funkwhale.njk # Now playing widget
|
||||||
|
│ │ ├── blogroll.njk # Recently updated blogs
|
||||||
|
│ │ └── categories.njk # Category list
|
||||||
|
├── css/
|
||||||
|
│ ├── tailwind.css # Tailwind source
|
||||||
|
│ ├── style.css # Compiled output (generated)
|
||||||
|
│ └── prism-theme.css # Syntax highlighting theme
|
||||||
|
├── js/
|
||||||
|
│ ├── webmentions.js # Client-side webmention fetcher
|
||||||
|
│ └── admin.js # Admin auth detection (shows FAB + dashboard link)
|
||||||
|
├── images/ # Static images
|
||||||
|
├── *.njk # Page templates (blog, about, cv, etc.)
|
||||||
|
├── eleventy.config.js # Eleventy configuration
|
||||||
|
├── tailwind.config.js # Tailwind configuration
|
||||||
|
├── postcss.config.js # PostCSS pipeline
|
||||||
|
└── package.json # Dependencies and scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Development server with hot reload
|
||||||
|
npm run dev
|
||||||
|
# → http://localhost:8080
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
# → Output to _site/
|
||||||
|
|
||||||
|
# Build CSS only (after Tailwind config changes)
|
||||||
|
npm run build:css
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content Directory
|
||||||
|
|
||||||
|
The theme expects content in a `content/` directory (typically a symlink to Indiekit's content store):
|
||||||
|
|
||||||
|
```
|
||||||
|
content/
|
||||||
|
├── .indiekit/ # Plugin data files
|
||||||
|
│ ├── homepage.json # Homepage builder config
|
||||||
|
│ ├── cv.json # CV data
|
||||||
|
│ └── post-types.json # Enabled post types
|
||||||
|
├── articles/
|
||||||
|
│ └── 2025-01-15-post.md
|
||||||
|
├── notes/
|
||||||
|
│ └── 2025-01-15-note.md
|
||||||
|
├── photos/
|
||||||
|
│ └── 2025-01-15-photo.md
|
||||||
|
└── pages/
|
||||||
|
└── about.md # Slash page
|
||||||
|
```
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Colors and Typography
|
||||||
|
|
||||||
|
Edit `tailwind.config.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
500: "#3b82f6", // Your primary color
|
||||||
|
600: "#2563eb",
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Your Font", "system-ui", "sans-serif"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then rebuild CSS: `npm run build:css`
|
||||||
|
|
||||||
|
### Dark Mode
|
||||||
|
|
||||||
|
The theme includes full dark mode support with `dark:` variants. Toggle is available in header/mobile nav, syncs with system preference.
|
||||||
|
|
||||||
|
### Override Files
|
||||||
|
|
||||||
|
When using as a submodule, place override files in your parent repo:
|
||||||
|
|
||||||
|
```
|
||||||
|
your-deployment-repo/
|
||||||
|
├── overrides/
|
||||||
|
│ └── eleventy-site/
|
||||||
|
│ ├── _data/ # Override data files
|
||||||
|
│ ├── images/ # Your images
|
||||||
|
│ └── about.njk # Override templates
|
||||||
|
└── eleventy-site/ # This theme (submodule)
|
||||||
|
```
|
||||||
|
|
||||||
|
Override files are copied over the submodule during build.
|
||||||
|
|
||||||
|
**Warning:** Be careful with `_data/` overrides — they can shadow dynamic plugin data. Use only for truly static customizations.
|
||||||
|
|
||||||
|
## Plugin Integration
|
||||||
|
|
||||||
|
### How Plugins Provide Data
|
||||||
|
|
||||||
|
Indiekit plugins write JSON files to `content/.indiekit/*.json`. The theme's `_data/*.js` files read these JSON files at build time.
|
||||||
|
|
||||||
|
**Example flow:**
|
||||||
|
|
||||||
|
1. User edits CV in Indiekit admin UI (`/cv`)
|
||||||
|
2. `@rmdes/indiekit-endpoint-cv` saves to `content/.indiekit/cv.json`
|
||||||
|
3. Eleventy rebuild triggers (`_data/cv.js` reads the JSON file)
|
||||||
|
4. CV sections render with new data
|
||||||
|
|
||||||
|
### Homepage Builder
|
||||||
|
|
||||||
|
The homepage builder is controlled by `@rmdes/indiekit-endpoint-homepage`:
|
||||||
|
|
||||||
|
1. Plugin provides admin UI at `/homepage`
|
||||||
|
2. User configures layout, sections, sidebar widgets
|
||||||
|
3. Plugin writes `content/.indiekit/homepage.json`
|
||||||
|
4. Theme renders configured layout (or falls back to default)
|
||||||
|
|
||||||
|
**Fallback:** If no homepage plugin is installed, the theme shows a default layout (hero + recent posts + sidebar).
|
||||||
|
|
||||||
|
### Adding Custom Sections
|
||||||
|
|
||||||
|
To add a custom homepage section:
|
||||||
|
|
||||||
|
1. Create template in `_includes/components/sections/your-section.njk`
|
||||||
|
2. Register in `_includes/components/homepage-section.njk`:
|
||||||
|
|
||||||
|
```nunjucks
|
||||||
|
{% if section.type == "your-section" %}
|
||||||
|
{% include "components/sections/your-section.njk" %}
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Plugin should register the section via `homepageSections` in Indiekit
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Cloudron
|
||||||
|
|
||||||
|
See `indiekit-cloudron` repository for Cloudron deployment with this theme as submodule.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
See `indiekit-deploy` repository for Docker Compose deployment with this theme as submodule.
|
||||||
|
|
||||||
|
### Static Host (Netlify, Vercel, etc.)
|
||||||
|
|
||||||
|
1. **Not recommended** — Indiekit needs a server for Micropub/Webmentions
|
||||||
|
2. For static-only use (no Indiekit), set all env vars and run `npm run build`
|
||||||
|
3. Deploy `_site/` directory
|
||||||
|
|
||||||
|
## Pages Included
|
||||||
|
|
||||||
|
| Page | URL | Description |
|
||||||
|
|------|-----|-------------|
|
||||||
|
| Home | `/` | Dynamic homepage (plugin or default) |
|
||||||
|
| About | `/about/` | Full h-card with bio |
|
||||||
|
| CV | `/cv/` | Resume with all sections |
|
||||||
|
| Blog | `/blog/` | All posts chronologically |
|
||||||
|
| Articles | `/articles/` | Long-form articles |
|
||||||
|
| Notes | `/notes/` | Short status updates |
|
||||||
|
| Photos | `/photos/` | Photo posts |
|
||||||
|
| Bookmarks | `/bookmarks/` | Saved links |
|
||||||
|
| Likes | `/likes/` | Liked posts |
|
||||||
|
| Replies | `/replies/` | Responses to others |
|
||||||
|
| Reposts | `/reposts/` | Shared content |
|
||||||
|
| Interactions | `/interactions/` | Combined social interactions |
|
||||||
|
| Slashes | `/slashes/` | Index of all slash pages |
|
||||||
|
| Categories | `/categories/` | Posts by category |
|
||||||
|
| GitHub | `/github/` | GitHub activity (if plugin enabled) |
|
||||||
|
| Funkwhale | `/funkwhale/` | Listening history (if plugin enabled) |
|
||||||
|
| Last.fm | `/listening/` | Last.fm scrobbles (if plugin enabled) |
|
||||||
|
| YouTube | `/youtube/` | YouTube channel (if plugin enabled) |
|
||||||
|
| Blogroll | `/blogroll/` | Blog aggregator (if plugin enabled) |
|
||||||
|
| Podroll | `/podroll/` | Podcast episodes (if plugin enabled) |
|
||||||
|
| IndieNews | `/news/` | IndieNews submissions (if plugin enabled) |
|
||||||
|
| Search | `/search/` | Pagefind search UI |
|
||||||
|
| RSS Feed | `/feed.xml` | RSS 2.0 feed |
|
||||||
|
| JSON Feed | `/feed.json` | JSON Feed 1.1 |
|
||||||
|
| Changelog | `/changelog/` | Site changelog |
|
||||||
|
|
||||||
|
## IndieWeb Resources
|
||||||
|
|
||||||
|
- [IndieWebify.me](https://indiewebify.me/) — Test your IndieWeb implementation
|
||||||
|
- [Microformats Wiki](https://microformats.org/wiki/h-card) — Microformats2 reference
|
||||||
|
- [webmention.io](https://webmention.io/) — Webmention service
|
||||||
|
- [IndieAuth](https://indieauth.com/) — Authentication protocol
|
||||||
|
- [Bridgy](https://brid.gy/) — Backfeed social interactions
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Webmentions not appearing
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check `SITE_URL` matches your live domain exactly
|
||||||
|
2. Verify webmention.io API is responding: `https://webmention.io/api/mentions?target=https://your-site.com/`
|
||||||
|
3. Check build-time cache at `/webmention-debug/`
|
||||||
|
4. Ensure post URLs match exactly (with/without trailing slash)
|
||||||
|
|
||||||
|
### Plugin data not showing
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Verify the plugin is installed and running in Indiekit
|
||||||
|
2. Check environment variables are set correctly
|
||||||
|
3. Check `content/.indiekit/*.json` files exist and are valid JSON
|
||||||
|
4. Rebuild Eleventy to refresh data: `npm run build`
|
||||||
|
|
||||||
|
### Dark mode not working
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check browser console for JavaScript errors
|
||||||
|
2. Verify Alpine.js loaded: `<script src="...alpinejs..."></script>`
|
||||||
|
3. Clear localStorage: `localStorage.removeItem('theme')`
|
||||||
|
|
||||||
|
### Search not working
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check Pagefind indexed: `_site/_pagefind/` directory exists
|
||||||
|
2. Rebuild with search indexing: `npm run build`
|
||||||
|
3. Check search page is not blocked by CSP headers
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
This theme is tailored for a specific Indiekit deployment but designed to be adaptable. Contributions welcome:
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Test with `npm run dev`
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
**Guidelines:**
|
||||||
|
- Keep theme neutral (no hardcoded personal data)
|
||||||
|
- Use environment variables for all configuration
|
||||||
|
- Maintain microformats2 markup
|
||||||
|
- Test dark mode
|
||||||
|
- Follow existing code style (ESM, Nunjucks, Tailwind)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
- Built for [Indiekit](https://getindiekit.com/) by Paul Robert Lloyd
|
||||||
|
- Inspired by the [IndieWeb](https://indieweb.org/) community
|
||||||
|
- Styled with [Tailwind CSS](https://tailwindcss.com/)
|
||||||
|
- Icons from [Heroicons](https://heroicons.com/)
|
||||||
|
- Search by [Pagefind](https://pagefind.app/)
|
||||||
|
- Static site generation by [Eleventy](https://11ty.dev/)
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
|
- [Indiekit](https://github.com/getindiekit/indiekit) — Micropub server
|
||||||
|
- [indiekit-cloudron](https://github.com/rmdes/indiekit-cloudron) — Cloudron deployment
|
||||||
|
- [indiekit-deploy](https://github.com/rmdes/indiekit-deploy) — Docker Compose deployment
|
||||||
|
- [@rmdes/indiekit-endpoint-*](https://github.com/rmdes?tab=repositories) — Custom Indiekit plugins
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Mastodon Feed Data
|
||||||
|
* Fetches recent posts from Mastodon using the public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import EleventyFetch from "@11ty/eleventy-fetch";
|
||||||
|
|
||||||
|
export default async function () {
|
||||||
|
const instance = process.env.MASTODON_INSTANCE?.replace("https://", "") || "";
|
||||||
|
const username = process.env.MASTODON_USER || "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, look up the account ID
|
||||||
|
const lookupUrl = `https://${instance}/api/v1/accounts/lookup?acct=${username}`;
|
||||||
|
|
||||||
|
const account = await EleventyFetch(lookupUrl, {
|
||||||
|
duration: "1h", // Cache account lookup for 1 hour
|
||||||
|
type: "json",
|
||||||
|
fetchOptions: {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account || !account.id) {
|
||||||
|
console.log("Mastodon account not found:", username);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch recent statuses (excluding replies and boosts for cleaner feed)
|
||||||
|
const statusesUrl = `https://${instance}/api/v1/accounts/${account.id}/statuses?limit=10&exclude_replies=true&exclude_reblogs=true`;
|
||||||
|
|
||||||
|
const statuses = await EleventyFetch(statusesUrl, {
|
||||||
|
duration: "15m", // Cache for 15 minutes
|
||||||
|
type: "json",
|
||||||
|
fetchOptions: {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!statuses || !Array.isArray(statuses)) {
|
||||||
|
console.log("No Mastodon statuses found for:", username);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform statuses into a simpler format
|
||||||
|
return statuses.map((status) => ({
|
||||||
|
id: status.id,
|
||||||
|
url: status.url,
|
||||||
|
text: stripHtml(status.content),
|
||||||
|
htmlContent: status.content,
|
||||||
|
createdAt: status.created_at,
|
||||||
|
author: {
|
||||||
|
username: status.account.username,
|
||||||
|
displayName: status.account.display_name || status.account.username,
|
||||||
|
avatar: status.account.avatar,
|
||||||
|
url: status.account.url,
|
||||||
|
},
|
||||||
|
favouritesCount: status.favourites_count || 0,
|
||||||
|
reblogsCount: status.reblogs_count || 0,
|
||||||
|
repliesCount: status.replies_count || 0,
|
||||||
|
// Media attachments
|
||||||
|
media: status.media_attachments
|
||||||
|
? status.media_attachments.map((m) => ({
|
||||||
|
type: m.type,
|
||||||
|
url: m.url,
|
||||||
|
previewUrl: m.preview_url,
|
||||||
|
description: m.description,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Mastodon feed:", error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple HTML stripper for plain text display
|
||||||
|
function stripHtml(html) {
|
||||||
|
if (!html) return "";
|
||||||
|
return html
|
||||||
|
.replace(/<br\s*\/?>/gi, " ")
|
||||||
|
.replace(/<\/p>/gi, " ")
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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] || [];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 ↓
|
||||||
|
</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" %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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">
|
||||||
|
← 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">← 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 →
|
||||||
|
</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 →</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 %} · {{ 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 %} · {{ item.location }}{% endif %}
|
||||||
|
{% if item.type %} · <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 →
|
||||||
|
</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 →</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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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">⋮</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">♥</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 →
|
||||||
|
</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 →
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 →</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 #}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
---
|
||||||
|
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 %}
|
||||||
|
<p class="mt-2 text-xs"><a href="/ai/" class="text-accent-600 dark:text-accent-400 hover:underline">Learn more about AI usage on this site →</a></p>
|
||||||
|
</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>
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
---
|
||||||
|
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 %}↩ Reply{% elif likedUrl %}♥ Like{% elif repostedUrl %}♻ Repost{% elif bookmarkedUrl %}🔖 Bookmark{% else %}✎ 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 %}
|
||||||
|
<p class="mt-1 pl-5"><a href="/ai/" class="hover:text-accent-600 dark:hover:text-accent-400 underline">Learn more about AI usage on this site</a></p>
|
||||||
|
</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">×</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">‹</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">›</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" %}
|
||||||
@@ -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
@@ -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 →
|
||||||
|
</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>
|
||||||
@@ -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 →
|
||||||
|
</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>
|
||||||
+425
@@ -0,0 +1,425 @@
|
|||||||
|
---
|
||||||
|
layout: layouts/base.njk
|
||||||
|
title: Blogroll
|
||||||
|
permalink: /blogroll/
|
||||||
|
---
|
||||||
|
<div class="blogroll-page" x-data="blogrollApp()" x-init="init()">
|
||||||
|
<header class="mb-6 sm:mb-8">
|
||||||
|
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">
|
||||||
|
<svg class="w-8 h-8 inline-block mr-2 text-orange-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>
|
||||||
|
Blogroll
|
||||||
|
</h1>
|
||||||
|
<p class="text-surface-600 dark:text-surface-400">
|
||||||
|
Blogs I follow - <span x-text="blogs.length" class="font-medium"></span> feeds
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-surface-500 mt-2" x-show="status?.lastSync">
|
||||||
|
Last synced: <span x-text="formatDate(status?.lastSync, 'full')"></span>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{# Tab Navigation - All Blogs first, then one tab per category #}
|
||||||
|
<div class="mb-6 border-b border-surface-200 dark:border-surface-700">
|
||||||
|
<nav class="flex gap-1 overflow-x-auto -mb-px" aria-label="Tabs">
|
||||||
|
<button
|
||||||
|
@click="switchTab('blogs')"
|
||||||
|
:class="activeTab === 'blogs' ? 'border-orange-500 text-orange-600 dark:text-orange-400' : 'border-transparent text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
|
||||||
|
class="pb-3 px-3 border-b-2 font-medium text-sm transition-colors whitespace-nowrap flex-shrink-0"
|
||||||
|
>
|
||||||
|
All Blogs
|
||||||
|
</button>
|
||||||
|
<template x-for="cat in categories" :key="cat.name">
|
||||||
|
<button
|
||||||
|
@click="switchTab('category:' + cat.name)"
|
||||||
|
:class="activeTab === 'category:' + cat.name ? 'border-orange-500 text-orange-600 dark:text-orange-400' : 'border-transparent text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
|
||||||
|
class="pb-3 px-3 border-b-2 font-medium text-sm transition-colors whitespace-nowrap flex-shrink-0"
|
||||||
|
>
|
||||||
|
<span x-text="cat.name"></span>
|
||||||
|
<span class="text-xs opacity-60 ml-1" x-text="`(${cat.count})`"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layout-with-sidebar">
|
||||||
|
{# Main Content #}
|
||||||
|
<div class="main-content">
|
||||||
|
{# Loading State #}
|
||||||
|
<div x-show="loading" class="text-center py-12">
|
||||||
|
<svg class="w-8 h-8 mx-auto text-orange-600 animate-spin mb-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-surface-600 dark:text-surface-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Error State #}
|
||||||
|
<div x-show="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
||||||
|
<p class="text-red-700 dark:text-red-400" x-text="error"></p>
|
||||||
|
<button @click="fetchData()" class="mt-2 text-sm text-red-600 hover:text-red-700 underline">Try again</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# All Blogs Tab #}
|
||||||
|
<div x-show="activeTab === 'blogs' && !loading" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<template x-for="blog in blogs" :key="blog.id">
|
||||||
|
<a
|
||||||
|
:href="blog.siteUrl || blog.feedUrl"
|
||||||
|
class="block bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 hover:border-orange-400 dark:hover:border-orange-600 transition-colors group"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||||
|
<img x-show="blog.photo" :src="blog.photo" class="w-10 h-10 object-cover" loading="lazy" />
|
||||||
|
<span x-show="!blog.photo" class="text-white text-sm font-bold" x-text="blog.title?.charAt(0)?.toUpperCase()"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate group-hover:text-orange-600 dark:group-hover:text-orange-400 transition-colors" x-text="blog.title"></h3>
|
||||||
|
<p x-show="blog.category" class="text-xs text-surface-500 truncate" x-text="blog.category"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p x-show="blog.description" class="text-sm text-surface-600 dark:text-surface-400 line-clamp-2 mb-3" x-text="blog.description"></p>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-surface-500">
|
||||||
|
<span x-text="`${blog.itemCount || 0} posts`"></span>
|
||||||
|
<span :class="blog.status === 'active' ? 'text-green-500' : 'text-red-500'">
|
||||||
|
<span x-show="blog.status === 'active'" class="flex items-center gap-1">
|
||||||
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
<span x-show="blog.status === 'error'" class="flex items-center gap-1">
|
||||||
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>
|
||||||
|
Error
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Empty State for Blogs #}
|
||||||
|
<div x-show="!loading && blogs.length === 0 && activeTab === 'blogs' && !error" class="text-center py-12">
|
||||||
|
<svg class="w-16 h-16 mx-auto text-surface-300 dark:text-surface-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-surface-600 dark:text-surface-400 text-lg">No blogs yet.</p>
|
||||||
|
<p class="text-surface-500 text-sm mt-2">Add blogs via the admin dashboard.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Category Items Tab (one for each category) #}
|
||||||
|
<div x-show="activeTab.startsWith('category:') && !loading" class="space-y-4">
|
||||||
|
<template x-for="item in categoryItems" :key="item.id">
|
||||||
|
<article class="bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 sm:p-6 hover:border-orange-400 dark:hover:border-orange-600 transition-colors">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<h2 class="font-semibold text-lg text-surface-900 dark:text-surface-100">
|
||||||
|
<a :href="item.url" class="hover:text-orange-600 dark:hover:text-orange-400" target="_blank" rel="noopener" x-text="item.title"></a>
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
x-show="item.isFuture"
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded-full text-xs font-medium flex-shrink-0"
|
||||||
|
>
|
||||||
|
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
Upcoming
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm text-surface-500 mb-3">
|
||||||
|
<a
|
||||||
|
:href="item.blog?.siteUrl || '#'"
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded-full hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<circle cx="6.18" cy="17.82" r="2.18"/>
|
||||||
|
<path d="M4 4.44v2.83c7.03 0 12.73 5.7 12.73 12.73h2.83c0-8.59-6.97-15.56-15.56-15.56zm0 5.66v2.83c3.9 0 7.07 3.17 7.07 7.07h2.83c0-5.47-4.43-9.9-9.9-9.9z"/>
|
||||||
|
</svg>
|
||||||
|
<span x-text="item.blog?.title || 'Unknown'"></span>
|
||||||
|
</a>
|
||||||
|
<time :datetime="item.published" x-text="formatDate(item.published)"></time>
|
||||||
|
</div>
|
||||||
|
<p x-show="item.summary" class="text-sm text-surface-600 dark:text-surface-400 line-clamp-3" x-text="item.summary"></p>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
x-show="item.photo && item.photo[0]"
|
||||||
|
:src="item.photo?.[0]"
|
||||||
|
class="w-24 h-24 rounded-lg object-cover flex-shrink-0 hidden sm:block"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Actions #}
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mt-4 pt-4 border-t border-surface-200 dark:border-surface-700">
|
||||||
|
<a
|
||||||
|
:href="item.url"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-orange-600 hover:text-orange-700 dark:text-orange-400"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||||
|
</svg>
|
||||||
|
Read Post
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
x-show="item.blog?.siteUrl"
|
||||||
|
:href="item.blog?.siteUrl"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-surface-500 hover:text-surface-700 dark:hover:text-surface-300"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<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 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||||
|
</svg>
|
||||||
|
Visit Blog
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="share-post-btn"
|
||||||
|
:data-share-url="item.url"
|
||||||
|
:data-share-title="item.title"
|
||||||
|
title="Create post"
|
||||||
|
aria-label="Create post"
|
||||||
|
>
|
||||||
|
<span class="share-post-icon">✏️</span>
|
||||||
|
<span class="share-post-label">Post</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="save-later-btn"
|
||||||
|
:data-save-url="item.url"
|
||||||
|
:data-save-title="item.title"
|
||||||
|
data-save-source="blogroll"
|
||||||
|
title="Save for later"
|
||||||
|
aria-label="Save for later"
|
||||||
|
>
|
||||||
|
<span class="save-later-icon">📑</span>
|
||||||
|
<span class="save-later-label">Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# Load More #}
|
||||||
|
<div x-show="categoryHasMore" class="text-center mt-8">
|
||||||
|
<button
|
||||||
|
@click="loadMoreCategory()"
|
||||||
|
:disabled="loadingMore"
|
||||||
|
class="px-6 py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span x-show="!loadingMore">Load More</span>
|
||||||
|
<span x-show="loadingMore" class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<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>
|
||||||
|
Loading...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Empty State for Category Items #}
|
||||||
|
<div x-show="categoryItems.length === 0 && !error" class="text-center py-12">
|
||||||
|
<svg class="w-16 h-16 mx-auto text-surface-300 dark:text-surface-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-surface-600 dark:text-surface-400 text-lg">No posts in this category yet.</p>
|
||||||
|
<p class="text-surface-500 text-sm mt-2">Posts will appear once blogs are synced.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Sidebar #}
|
||||||
|
<aside class="sidebar">
|
||||||
|
{# OPML Download Widget #}
|
||||||
|
<div class="widget">
|
||||||
|
<h3 class="widget-title">Subscribe</h3>
|
||||||
|
<p class="text-sm text-surface-600 dark:text-surface-400 mb-4">
|
||||||
|
Import this blogroll into your feed reader.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/blogrollapi/api/opml"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 rounded-lg text-sm transition-colors"
|
||||||
|
download="blogroll.opml"
|
||||||
|
>
|
||||||
|
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||||
|
</svg>
|
||||||
|
Download OPML
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Stats Widget #}
|
||||||
|
<div class="widget" x-show="status">
|
||||||
|
<h3 class="widget-title">Stats</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-center">
|
||||||
|
<div class="p-3 bg-surface-50 dark:bg-surface-800 rounded-lg">
|
||||||
|
<span class="text-2xl font-bold text-orange-600 dark:text-orange-400 block" x-text="status?.blogs?.count || 0"></span>
|
||||||
|
<span class="text-xs text-surface-500 uppercase">Blogs</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-surface-50 dark:bg-surface-800 rounded-lg">
|
||||||
|
<span class="text-2xl font-bold text-orange-600 dark:text-orange-400 block" x-text="status?.items?.count || 0"></span>
|
||||||
|
<span class="text-xs text-surface-500 uppercase">Posts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Category Quick Links (for when on blogs tab) #}
|
||||||
|
<div class="widget" x-show="categories.length > 0">
|
||||||
|
<h3 class="widget-title flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-orange-600" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<circle cx="6.18" cy="17.82" r="2.18"/>
|
||||||
|
<path d="M4 4.44v2.83c7.03 0 12.73 5.7 12.73 12.73h2.83c0-8.59-6.97-15.56-15.56-15.56zm0 5.66v2.83c3.9 0 7.07 3.17 7.07 7.07h2.83c0-5.47-4.43-9.9-9.9-9.9z"/>
|
||||||
|
</svg>
|
||||||
|
Categories
|
||||||
|
</h3>
|
||||||
|
<ul class="space-y-1 mt-4">
|
||||||
|
<template x-for="cat in categories" :key="cat.name">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
@click="switchTab('category:' + cat.name)"
|
||||||
|
:class="activeTab === 'category:' + cat.name ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400' : 'hover:bg-surface-100 dark:hover:bg-surface-700'"
|
||||||
|
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<span x-text="cat.name"></span>
|
||||||
|
<span class="text-surface-500" x-text="`(${cat.count})`"></span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function blogrollApp() {
|
||||||
|
return {
|
||||||
|
blogs: [],
|
||||||
|
categories: [],
|
||||||
|
status: null,
|
||||||
|
loading: true,
|
||||||
|
loadingMore: false,
|
||||||
|
error: null,
|
||||||
|
activeTab: 'blogs',
|
||||||
|
// Category items (loaded per-tab)
|
||||||
|
categoryItems: [],
|
||||||
|
categoryOffset: 0,
|
||||||
|
categoryHasMore: false,
|
||||||
|
limit: 20,
|
||||||
|
// Cache category items to avoid re-fetching on tab switch
|
||||||
|
categoryCache: {},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.fetchData();
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchData() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [blogsRes, catsRes, statusRes] = await Promise.all([
|
||||||
|
fetch('/blogrollapi/api/blogs?limit=200').then(r => r.json()),
|
||||||
|
fetch('/blogrollapi/api/categories').then(r => r.json()),
|
||||||
|
fetch('/blogrollapi/api/status').then(r => r.json())
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.blogs = blogsRes.items || [];
|
||||||
|
this.categories = catsRes.items || [];
|
||||||
|
this.status = statusRes;
|
||||||
|
} catch (err) {
|
||||||
|
this.error = 'Failed to load blogroll: ' + err.message;
|
||||||
|
console.error('Blogroll fetch error:', err);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async switchTab(tab) {
|
||||||
|
this.activeTab = tab;
|
||||||
|
|
||||||
|
if (tab.startsWith('category:')) {
|
||||||
|
const categoryName = tab.replace('category:', '');
|
||||||
|
await this.fetchCategoryItems(categoryName);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchCategoryItems(category) {
|
||||||
|
// Use cache if available
|
||||||
|
if (this.categoryCache[category]) {
|
||||||
|
this.categoryItems = this.categoryCache[category].items;
|
||||||
|
this.categoryHasMore = this.categoryCache[category].hasMore;
|
||||||
|
this.categoryOffset = this.categoryCache[category].offset;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.categoryOffset = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const catParam = encodeURIComponent(category);
|
||||||
|
const res = await fetch(`/blogrollapi/api/items?limit=${this.limit}&category=${catParam}`).then(r => r.json());
|
||||||
|
|
||||||
|
this.categoryItems = res.items || [];
|
||||||
|
this.categoryHasMore = res.hasMore || false;
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
this.categoryCache[category] = {
|
||||||
|
items: this.categoryItems,
|
||||||
|
hasMore: this.categoryHasMore,
|
||||||
|
offset: 0
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
this.error = 'Failed to load posts: ' + err.message;
|
||||||
|
console.error('Category fetch error:', err);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadMoreCategory() {
|
||||||
|
const categoryName = this.activeTab.replace('category:', '');
|
||||||
|
this.loadingMore = true;
|
||||||
|
const newOffset = this.categoryOffset + this.limit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const catParam = encodeURIComponent(categoryName);
|
||||||
|
const res = await fetch(`/blogrollapi/api/items?limit=${this.limit}&offset=${newOffset}&category=${catParam}`).then(r => r.json());
|
||||||
|
|
||||||
|
this.categoryItems = [...this.categoryItems, ...(res.items || [])];
|
||||||
|
this.categoryHasMore = res.hasMore || false;
|
||||||
|
this.categoryOffset = newOffset;
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
this.categoryCache[categoryName] = {
|
||||||
|
items: this.categoryItems,
|
||||||
|
hasMore: this.categoryHasMore,
|
||||||
|
offset: newOffset
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load more error:', err);
|
||||||
|
} finally {
|
||||||
|
this.loadingMore = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate(dateStr, format = 'short') {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (isNaN(date.getTime())) return '';
|
||||||
|
|
||||||
|
if (format === 'full') {
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: 'short', day: 'numeric', year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+109
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 →
|
||||||
|
</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">← All categories</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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 %}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user