Merge branch 'feature/resources-stuff'
This commit is contained in:
commit
f9f2d528f6
17 changed files with 2296 additions and 448 deletions
70
assets/js/code-copy.js
Normal file
70
assets/js/code-copy.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// Add copy buttons to all code blocks
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Find all <pre> elements that contain <code>
|
||||||
|
const codeBlocks = document.querySelectorAll("pre code");
|
||||||
|
|
||||||
|
codeBlocks.forEach((codeBlock) => {
|
||||||
|
const pre = codeBlock.parentElement;
|
||||||
|
|
||||||
|
// Create wrapper for positioning
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.style.position = "relative";
|
||||||
|
|
||||||
|
// Wrap the pre element
|
||||||
|
pre.parentNode.insertBefore(wrapper, pre);
|
||||||
|
wrapper.appendChild(pre);
|
||||||
|
|
||||||
|
// Create copy button
|
||||||
|
const copyButton = document.createElement("button");
|
||||||
|
copyButton.className = "code-copy-btn";
|
||||||
|
copyButton.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
copyButton.setAttribute("aria-label", "Copy code to clipboard");
|
||||||
|
|
||||||
|
// Add click handler
|
||||||
|
copyButton.addEventListener("click", async () => {
|
||||||
|
const code = codeBlock.textContent;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
|
||||||
|
// Show success feedback
|
||||||
|
copyButton.classList.add("copied");
|
||||||
|
|
||||||
|
// Reset after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
copyButton.classList.remove("copied");
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy code:", err);
|
||||||
|
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = code;
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
textArea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
copyButton.classList.add("copied");
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
copyButton.classList.remove("copied");
|
||||||
|
}, 2000);
|
||||||
|
} catch (err2) {
|
||||||
|
console.error("Fallback copy failed:", err2);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(copyButton);
|
||||||
|
});
|
||||||
|
});
|
||||||
260
assets/js/lastfm-stats.js
Normal file
260
assets/js/lastfm-stats.js
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
// Last.fm Stats Interactive Module
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/";
|
||||||
|
const LASTFM_API_KEY = "3a4fef48fecc593d25e0f9a40df1fefe";
|
||||||
|
|
||||||
|
// Store current stats for export
|
||||||
|
let currentStats = {
|
||||||
|
artists: [],
|
||||||
|
totalTracks: 0,
|
||||||
|
period: "",
|
||||||
|
username: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate timestamps based on period
|
||||||
|
function getTimestamps(period) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
let from;
|
||||||
|
|
||||||
|
if (period === "7day") {
|
||||||
|
from = now - 7 * 24 * 60 * 60; // 7 days
|
||||||
|
} else if (period === "1month") {
|
||||||
|
from = now - 30 * 24 * 60 * 60; // 30 days
|
||||||
|
}
|
||||||
|
|
||||||
|
return { from, to: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch top artists for the specified period
|
||||||
|
async function fetchTopArtists(username, period) {
|
||||||
|
const url = `${LASTFM_API_URL}?method=user.gettopartists&user=${username}&api_key=${LASTFM_API_KEY}&format=json&period=${period}&limit=5`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch top artists: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Check for Last.fm API errors
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.message || "Last.fm API error");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.topartists?.artist || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch recent tracks to count total scrobbles in period
|
||||||
|
async function fetchTrackCount(username, period) {
|
||||||
|
const { from, to } = getTimestamps(period);
|
||||||
|
const url = `${LASTFM_API_URL}?method=user.getrecenttracks&user=${username}&api_key=${LASTFM_API_KEY}&format=json&from=${from}&to=${to}&limit=1`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch track count: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Check for Last.fm API errors
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.message || "Last.fm API error");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.recenttracks?.["@attr"]?.total || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate markdown format
|
||||||
|
function generateMarkdown() {
|
||||||
|
const periodText =
|
||||||
|
currentStats.period === "7day" ? "Past Week" : "Past Month";
|
||||||
|
let markdown = `## Last.fm Stats - ${periodText}\n\n`;
|
||||||
|
markdown += `**Total Tracks:** ${currentStats.totalTracks}\n\n`;
|
||||||
|
markdown += `**Top 5 Artists:**\n\n`;
|
||||||
|
|
||||||
|
currentStats.artists.forEach((artist) => {
|
||||||
|
markdown += `- [${artist.name}](${artist.url}) - ${artist.playcount} plays\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate plain text format
|
||||||
|
function generatePlainText() {
|
||||||
|
const periodText =
|
||||||
|
currentStats.period === "7day" ? "Past Week" : "Past Month";
|
||||||
|
let text = `Last.fm Stats - ${periodText}\n\n`;
|
||||||
|
text += `Total Tracks: ${currentStats.totalTracks}\n\n`;
|
||||||
|
text += `Top 5 Artists:\n\n`;
|
||||||
|
|
||||||
|
currentStats.artists.forEach((artist) => {
|
||||||
|
text += `- ${artist.name} - ${artist.playcount} plays\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
async function copyToClipboard(text, button) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
const originalText = button.textContent;
|
||||||
|
button.textContent = "Copied!";
|
||||||
|
button.classList.add("copied");
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
button.textContent = originalText;
|
||||||
|
button.classList.remove("copied");
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy:", err);
|
||||||
|
alert("Failed to copy to clipboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the stats
|
||||||
|
function displayStats(artists, totalTracks, period, username) {
|
||||||
|
const artistsList = document.getElementById("top-artists");
|
||||||
|
const totalTracksEl = document.getElementById("total-tracks");
|
||||||
|
|
||||||
|
// Store current stats for export
|
||||||
|
currentStats = { artists, totalTracks, period, username };
|
||||||
|
|
||||||
|
// Update total tracks
|
||||||
|
totalTracksEl.textContent = totalTracks;
|
||||||
|
|
||||||
|
// Clear and populate artists list
|
||||||
|
artistsList.innerHTML = "";
|
||||||
|
|
||||||
|
if (artists.length === 0) {
|
||||||
|
artistsList.innerHTML = "<li>No artists found for this period</li>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
artists.forEach((artist) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.innerHTML = `<a href="${artist.url}" target="_blank">${artist.name}</a> - ${artist.playcount} plays`;
|
||||||
|
artistsList.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show export buttons
|
||||||
|
const exportButtons = document.getElementById("export-buttons");
|
||||||
|
if (exportButtons) {
|
||||||
|
exportButtons.style.display = "flex";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide UI elements
|
||||||
|
function setLoadingState(isLoading) {
|
||||||
|
const loading = document.getElementById("stats-loading");
|
||||||
|
const content = document.getElementById("stats-content");
|
||||||
|
const error = document.getElementById("stats-error");
|
||||||
|
const results = document.getElementById("stats-results");
|
||||||
|
|
||||||
|
results.style.display = "block";
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
loading.style.display = "block";
|
||||||
|
content.style.display = "none";
|
||||||
|
error.style.display = "none";
|
||||||
|
} else {
|
||||||
|
loading.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
const error = document.getElementById("stats-error");
|
||||||
|
const errorMessage = document.getElementById("error-message");
|
||||||
|
const content = document.getElementById("stats-content");
|
||||||
|
|
||||||
|
error.style.display = "block";
|
||||||
|
content.style.display = "none";
|
||||||
|
errorMessage.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showContent() {
|
||||||
|
const error = document.getElementById("stats-error");
|
||||||
|
const content = document.getElementById("stats-content");
|
||||||
|
|
||||||
|
error.style.display = "none";
|
||||||
|
content.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main fetch function
|
||||||
|
async function fetchStats() {
|
||||||
|
const username = document.getElementById("lastfm-username").value.trim();
|
||||||
|
const period = document.getElementById("time-period").value;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
showError("Please enter a Last.fm username");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingState(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch both stats in parallel
|
||||||
|
const [artists, totalTracks] = await Promise.all([
|
||||||
|
fetchTopArtists(username, period),
|
||||||
|
fetchTrackCount(username, period),
|
||||||
|
]);
|
||||||
|
|
||||||
|
displayStats(artists, totalTracks, period, username);
|
||||||
|
showContent();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Last.fm stats:", error);
|
||||||
|
showError(
|
||||||
|
error.message ||
|
||||||
|
"Failed to fetch stats. Please check the username and try again.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingState(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
function init() {
|
||||||
|
const fetchButton = document.getElementById("fetch-stats");
|
||||||
|
const usernameInput = document.getElementById("lastfm-username");
|
||||||
|
const copyMarkdownBtn = document.getElementById("copy-markdown");
|
||||||
|
const copyPlainTextBtn = document.getElementById("copy-plaintext");
|
||||||
|
|
||||||
|
if (!fetchButton || !usernameInput) {
|
||||||
|
return; // Not on the stats page
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch stats on button click
|
||||||
|
fetchButton.addEventListener("click", fetchStats);
|
||||||
|
|
||||||
|
// Also fetch on Enter key in username input
|
||||||
|
usernameInput.addEventListener("keypress", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
fetchStats();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy buttons
|
||||||
|
if (copyMarkdownBtn) {
|
||||||
|
copyMarkdownBtn.addEventListener("click", () => {
|
||||||
|
const markdown = generateMarkdown();
|
||||||
|
copyToClipboard(markdown, copyMarkdownBtn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copyPlainTextBtn) {
|
||||||
|
copyPlainTextBtn.addEventListener("click", () => {
|
||||||
|
const plainText = generatePlainText();
|
||||||
|
copyToClipboard(plainText, copyPlainTextBtn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run init when DOM is loaded
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
33
assets/js/pgp-copy.js
Normal file
33
assets/js/pgp-copy.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// PGP Key copy functionality
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const copyButtons = document.querySelectorAll('.pgp-copy-trigger');
|
||||||
|
|
||||||
|
copyButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', async function() {
|
||||||
|
const feedback = button.closest('.contact-pgp').querySelector('.pgp-copy-feedback');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/publickey.asc');
|
||||||
|
const pgpKey = await response.text();
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(pgpKey);
|
||||||
|
|
||||||
|
feedback.textContent = 'PGP key copied to clipboard!';
|
||||||
|
feedback.classList.add('show', 'success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
feedback.classList.remove('show');
|
||||||
|
}, 3000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy PGP key:', err);
|
||||||
|
|
||||||
|
feedback.textContent = 'Failed to copy key';
|
||||||
|
feedback.classList.add('show', 'error');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
feedback.classList.remove('show');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -559,7 +559,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contact section styling
|
// Contact section styling
|
||||||
.blog-contact-section {
|
.contact-section {
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: rgba(0, 255, 0, 0.05);
|
background: rgba(0, 255, 0, 0.05);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -26,7 +26,7 @@
|
||||||
@import "pages/media";
|
@import "pages/media";
|
||||||
@import "pages/resources";
|
@import "pages/resources";
|
||||||
|
|
||||||
@import url("https://fonts.bunny.net/css2?family=Caveat:wght@400..700&family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=Neonderthaw&display=swap");
|
@import url(https://fonts.bunny.net/css?family=abel:400|barlow-condensed:400,500|caveat:400|lato:300,300i,400,400i|neonderthaw:400);
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "DSEG7-Classic";
|
font-family: "DSEG7-Classic";
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@ title: "Resources"
|
||||||
draft: false
|
draft: false
|
||||||
---
|
---
|
||||||
|
|
||||||
# Resources
|
Welcome to my test chamber, here you'll find various experiements, tests and resources. Take a look around.
|
||||||
|
|
||||||
Welcome to my whiteboard of resources. Here you'll find various tools, scripts, and experiments I've built and wanted to share.
|
The cake isn't a lie.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
---
|
---
|
||||||
title: "Last.fm Weekly Stats Script"
|
title: "Last.fm Weekly Stats"
|
||||||
date: 2026-01-04
|
date: 2026-01-04
|
||||||
tags: ["javascript", "api", "last.fm"]
|
|
||||||
description: "Fetch and display your weekly listening stats from Last.fm"
|
description: "Fetch and display your weekly listening stats from Last.fm"
|
||||||
icon: "lastfm-stats"
|
icon: "lastfm-stats"
|
||||||
demo_url: ""
|
demo_url: ""
|
||||||
|
|
@ -9,26 +8,115 @@ source_url: ""
|
||||||
draft: false
|
draft: false
|
||||||
---
|
---
|
||||||
|
|
||||||
A handy script for pulling your weekly listening statistics from Last.fm's API. Perfect for tracking your music habits and creating weekly listening reports.
|
Get your weekly listening statistics from Last.fm's API. Enter your username below to see your top artists and track counts for different time periods.
|
||||||
|
|
||||||
## Features
|
I made this so I could easily add in listening stats to my [weekly posts](/tags/weekly-update/), if you find it useful please let me know.
|
||||||
|
|
||||||
- Fetches weekly top tracks from Last.fm
|
{{< lastfm-stats-form >}}
|
||||||
- Displays track count and listening time
|
|
||||||
- Formats data in a clean, readable format
|
|
||||||
- Easy to integrate into blogs or dashboards
|
|
||||||
|
|
||||||
## Setup
|
## Download the Shell Script
|
||||||
|
|
||||||
1. Get your Last.fm API key from [Last.fm API](https://www.last.fm/api)
|
Want to run this locally or integrate it into your own workflows? Here is a bash script I was using to generate this before I decided to make a web version.
|
||||||
2. Configure your username in the script
|
|
||||||
3. Run the script to fetch your weekly stats
|
|
||||||
|
|
||||||
## Output
|
```
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
The script returns your top tracks for the week, including:
|
# Last.fm Weekly Stats Script
|
||||||
- Track name and artist
|
# Fetches your Last.fm listening statistics for the past week
|
||||||
- Play count
|
#
|
||||||
- Listening duration
|
# Requirements:
|
||||||
|
# - curl (for API requests)
|
||||||
|
# - jq (for JSON parsing)
|
||||||
|
#
|
||||||
|
# Usage: ./lastfm-week.sh
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# Create a .env file with:
|
||||||
|
# LASTFM_API_KEY=your_api_key_here
|
||||||
|
# LASTFM_USERNAME=your_username_here
|
||||||
|
#
|
||||||
|
# Output: Markdown-formatted stats with top artists and track counts
|
||||||
|
#
|
||||||
|
# Download from: https://ritual.sh/resources/lastfm-stats/
|
||||||
|
|
||||||
Great for weekly blog posts or personal music tracking!
|
# Load environment variables from .env file
|
||||||
|
if [ -f .env ]; then
|
||||||
|
export $(cat .env | grep -v '^#' | xargs)
|
||||||
|
else
|
||||||
|
echo "Error: .env file not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check required variables
|
||||||
|
if [ -z "$LASTFM_API_KEY" ] || [ -z "$LASTFM_USERNAME" ]; then
|
||||||
|
echo "Error: LASTFM_API_KEY and LASTFM_USERNAME must be set in .env file"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
API_BASE="http://ws.audioscrobbler.com/2.0/"
|
||||||
|
|
||||||
|
# Get current timestamp
|
||||||
|
NOW=$(date +%s)
|
||||||
|
# Get timestamp from 7 days ago
|
||||||
|
WEEK_AGO=$((NOW - 604800))
|
||||||
|
|
||||||
|
# Fetch top artists for the week
|
||||||
|
TOP_ARTISTS=$(curl -s "${API_BASE}?method=user.gettopartists&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&period=7day&limit=5")
|
||||||
|
|
||||||
|
# Fetch recent tracks to count this week's scrobbles
|
||||||
|
RECENT_TRACKS=$(curl -s "${API_BASE}?method=user.getrecenttracks&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&from=${WEEK_AGO}&to=${NOW}&limit=1")
|
||||||
|
|
||||||
|
# Get total track count
|
||||||
|
TOTAL_TRACKS=$(echo "$RECENT_TRACKS" | jq -r '.recenttracks["@attr"].total')
|
||||||
|
|
||||||
|
# Output in markdown format
|
||||||
|
echo "## Last.fm Weekly Stats"
|
||||||
|
echo ""
|
||||||
|
echo "**Total Tracks:** ${TOTAL_TRACKS}"
|
||||||
|
echo ""
|
||||||
|
echo "**Top 5 Artists:**"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Parse and display top 5 artists as markdown links
|
||||||
|
echo "$TOP_ARTISTS" | jq -r '.topartists.artist[] | "- [\(.name)](\(.url)) - \(.playcount) plays"'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shell Script Usage
|
||||||
|
|
||||||
|
The script fetches your Last.fm stats and outputs them in markdown format.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
|
||||||
|
- `curl` for API requests
|
||||||
|
- `jq` for JSON parsing
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
|
||||||
|
1. Create a `.env` file with your credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LASTFM_API_KEY=your_api_key_here
|
||||||
|
LASTFM_USERNAME=your_username_here
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Make the script executable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x lastfm-week.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./lastfm-week.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
The script prints markdown-formatted stats including:
|
||||||
|
|
||||||
|
- Total tracks scrobbled this week
|
||||||
|
- Top 5 artists with play counts
|
||||||
|
- Direct links to artist pages
|
||||||
|
|
||||||
|
Enjoy!
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
---
|
---
|
||||||
title: "HTML/CSS Lavalamp"
|
title: "HTML/CSS Lavalamp"
|
||||||
date: 2026-01-04
|
date: 2026-01-04
|
||||||
tags: ["css", "html", "animation"]
|
|
||||||
description: "A pure CSS lavalamp animation with floating blobs"
|
description: "A pure CSS lavalamp animation with floating blobs"
|
||||||
icon: "lavalamp"
|
icon: "lavalamp"
|
||||||
demo_url: ""
|
demo_url: ""
|
||||||
source_url: ""
|
source_url: ""
|
||||||
draft: false
|
draft: true
|
||||||
---
|
---
|
||||||
|
|
||||||
A mesmerizing lavalamp effect created entirely with HTML and CSS. Features smooth floating animations and gradient blobs that rise and fall like a classic lava lamp.
|
A mesmerizing lavalamp effect created entirely with HTML and CSS. Features smooth floating animations and gradient blobs that rise and fall like a classic lava lamp.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,22 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Last.fm Weekly Stats Script
|
# Last.fm Weekly Stats Script
|
||||||
# Usage: ./lastfm-weekly.sh
|
# Fetches your Last.fm listening statistics for the past week
|
||||||
|
#
|
||||||
|
# Requirements:
|
||||||
|
# - curl (for API requests)
|
||||||
|
# - jq (for JSON parsing)
|
||||||
|
#
|
||||||
|
# Usage: ./lastfm-week.sh
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# Create a .env file with:
|
||||||
|
# LASTFM_API_KEY=your_api_key_here
|
||||||
|
# LASTFM_USERNAME=your_username_here
|
||||||
|
#
|
||||||
|
# Output: Markdown-formatted stats with top artists and track counts
|
||||||
|
#
|
||||||
|
# Download from: https://ritual.sh/resources/lastfm-stats/
|
||||||
|
|
||||||
# Load environment variables from .env file
|
# Load environment variables from .env file
|
||||||
if [ -f .env ]; then
|
if [ -f .env ]; then
|
||||||
|
|
|
||||||
|
|
@ -23,36 +23,7 @@
|
||||||
<div class="blog-summary">{{ .Content }}</div>
|
<div class="blog-summary">{{ .Content }}</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<section class="blog-contact-section">
|
{{ partial "contact-section.html" . }}
|
||||||
<h2 class="contact-title">Contact</h2>
|
|
||||||
<div class="contact-content">
|
|
||||||
<p>
|
|
||||||
If you found this interesting, have any comments, questions,
|
|
||||||
corrections, or just fancy saying "hello" please feel free to get
|
|
||||||
in touch.
|
|
||||||
</p>
|
|
||||||
<div class="contact-email">
|
|
||||||
<span class="contact-label">Email:</span>
|
|
||||||
<a href="mailto:dan@ritual.sh">dan@ritual.sh</a>
|
|
||||||
</div>
|
|
||||||
<div class="contact-pgp">
|
|
||||||
<span class="contact-label">PGP Public Key:</span>
|
|
||||||
<div class="pgp-actions">
|
|
||||||
<a
|
|
||||||
href="/publickey.asc"
|
|
||||||
download
|
|
||||||
class="pgp-button download-key"
|
|
||||||
>
|
|
||||||
<span class="button-icon">↓</span> Download
|
|
||||||
</a>
|
|
||||||
<button id="copy-pgp-key" class="pgp-button copy-key">
|
|
||||||
<span class="button-icon">⎘</span> Copy to Clipboard
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="copy-feedback" class="copy-feedback"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<nav class="blog-post-navigation">
|
<nav class="blog-post-navigation">
|
||||||
<div class="post-nav-links">
|
<div class="post-nav-links">
|
||||||
|
|
@ -80,36 +51,4 @@
|
||||||
|
|
||||||
<script src="/js/footnote-scroll.js"></script>
|
<script src="/js/footnote-scroll.js"></script>
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
const copyButton = document.getElementById("copy-pgp-key");
|
|
||||||
const feedback = document.getElementById("copy-feedback");
|
|
||||||
|
|
||||||
if (copyButton) {
|
|
||||||
copyButton.addEventListener("click", async function () {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/publickey.asc");
|
|
||||||
const pgpKey = await response.text();
|
|
||||||
|
|
||||||
await navigator.clipboard.writeText(pgpKey);
|
|
||||||
|
|
||||||
feedback.textContent = "PGP key copied to clipboard!";
|
|
||||||
feedback.classList.add("show", "success");
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
feedback.classList.remove("show");
|
|
||||||
}, 3000);
|
|
||||||
} catch (err) {
|
|
||||||
feedback.textContent = "Failed to copy key";
|
|
||||||
feedback.classList.add("show", "error");
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
feedback.classList.remove("show");
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
||||||
30
layouts/partials/contact-section.html
Normal file
30
layouts/partials/contact-section.html
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<section class="contact-section">
|
||||||
|
<h2 class="contact-title">Contact</h2>
|
||||||
|
<div class="contact-content">
|
||||||
|
<p>
|
||||||
|
If you found this interesting, have any comments, questions,
|
||||||
|
corrections, or just fancy saying "hello" please feel free to get
|
||||||
|
in touch.
|
||||||
|
</p>
|
||||||
|
<div class="contact-email">
|
||||||
|
<span class="contact-label">Email:</span>
|
||||||
|
<a href="mailto:dan@ritual.sh">dan@ritual.sh</a>
|
||||||
|
</div>
|
||||||
|
<div class="contact-pgp">
|
||||||
|
<span class="contact-label">PGP Public Key:</span>
|
||||||
|
<div class="pgp-actions">
|
||||||
|
<a
|
||||||
|
href="/publickey.asc"
|
||||||
|
download
|
||||||
|
class="pgp-button download-key"
|
||||||
|
>
|
||||||
|
<span class="button-icon">↓</span> Download
|
||||||
|
</a>
|
||||||
|
<button class="pgp-button copy-key pgp-copy-trigger">
|
||||||
|
<span class="button-icon">⎘</span> Copy to Clipboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="copy-feedback pgp-copy-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
@ -1,15 +1,37 @@
|
||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
<div class="resources-page">
|
<div class="resources-page portal-theme">
|
||||||
<div class="resources-container">
|
<div class="portal-bg-elements">
|
||||||
<div class="whiteboard">
|
<div class="aperture-logo"></div>
|
||||||
<div class="whiteboard-header">
|
<div class="test-chamber-grid"></div>
|
||||||
<div class="whiteboard-description">{{ .Content }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="whiteboard-pins">
|
<div class="resources-container">
|
||||||
|
<div class="portal-header">
|
||||||
|
<div class="portal-icon blue-portal hidden-lg-down"></div>
|
||||||
|
<h1 class="portal-title">Resources</h1>
|
||||||
|
<div class="portal-icon orange-portal hidden-md-down"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="portal-sign">
|
||||||
|
<div class="portal-sign-decor">
|
||||||
|
<div class="portal-sign-text">{{ .Content }}</div>
|
||||||
|
<div class="portal-sign-number">06</div>
|
||||||
|
<div class="portal-sign-sub">06/19</div>
|
||||||
|
</div>
|
||||||
|
<div class="portal-sign-lines"></div>
|
||||||
|
|
||||||
|
<div class="portal-sign-content">
|
||||||
|
<div class="portal-sign-header">
|
||||||
|
<div class="portal-description"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="portal-sign-items">
|
||||||
{{ range .Pages }} {{ .Render "summary" }} {{ end }}
|
{{ range .Pages }} {{ .Render "summary" }} {{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="companion-cube"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
<div class="resource-single">
|
<div class="resource-single">
|
||||||
|
<div class="test-chamber-grid">
|
||||||
|
</div>
|
||||||
|
|
||||||
<article class="resource-content">
|
<article class="resource-content">
|
||||||
|
<!-- Aperture Science hazard stripes in corners -->
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Animated energy particles -->
|
||||||
|
<div class="energy-particles"></div>
|
||||||
|
|
||||||
<header class="resource-header">
|
<header class="resource-header">
|
||||||
<div class="resource-icon-large {{ .Params.icon }}"></div>
|
|
||||||
<h1>{{ .Title }}</h1>
|
<h1>{{ .Title }}</h1>
|
||||||
{{ if .Params.description }}
|
{{ if .Params.description }}
|
||||||
<p class="lead">{{ .Params.description }}</p>
|
<p class="lead">{{ .Params.description }}</p>
|
||||||
|
|
@ -28,6 +36,8 @@
|
||||||
{{ .Content }}
|
{{ .Content }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{ partial "contact-section.html" . }}
|
||||||
|
|
||||||
<nav class="resource-navigation">
|
<nav class="resource-navigation">
|
||||||
{{ with .PrevInSection }}
|
{{ with .PrevInSection }}
|
||||||
<a href="{{ .Permalink }}" class="nav-link prev">← {{ .Title }}</a>
|
<a href="{{ .Permalink }}" class="nav-link prev">← {{ .Title }}</a>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,8 @@
|
||||||
<div class="resource-pin" data-icon="{{ .Params.icon }}">
|
<div class="resource-pin" data-icon="{{ .Params.icon }}">
|
||||||
<div class="pin-tack"></div>
|
<a href="{{ .Permalink }}">
|
||||||
<div class="resource-card">
|
<div class="resource-card">
|
||||||
<div class="resource-icon {{ .Params.icon }}"></div>
|
<div class="resource-icon {{ .Params.icon }}"></div>
|
||||||
<div class="resource-info">
|
<div class="resource-info">{{ .Title }}</div>
|
||||||
<h2><a href="{{ .Permalink }}">{{ .Title }}</a></h2>
|
|
||||||
<p class="resource-description">{{ .Params.description }}</p>
|
|
||||||
{{ if .Params.tags }}
|
|
||||||
<div class="resource-tags">
|
|
||||||
{{ range .Params.tags }}
|
|
||||||
<span class="tag">{{ . }}</span>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
<div class="resource-links">
|
|
||||||
{{ if .Params.demo_url }}
|
|
||||||
<a href="{{ .Params.demo_url }}" class="resource-link demo" target="_blank">Demo</a>
|
|
||||||
{{ end }}
|
|
||||||
{{ if .Params.source_url }}
|
|
||||||
<a href="{{ .Params.source_url }}" class="resource-link source" target="_blank">Source</a>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
51
layouts/shortcodes/lastfm-stats-form.html
Normal file
51
layouts/shortcodes/lastfm-stats-form.html
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<div id="lastfm-stats-app">
|
||||||
|
<div class="stats-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lastfm-username">Last.fm Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="lastfm-username"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="time-period">Time Period</label>
|
||||||
|
<select id="time-period">
|
||||||
|
<option value="7day">Past Week (7 days)</option>
|
||||||
|
<option value="1month">Past Month (30 days)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="fetch-stats" class="btn-primary">Get Stats</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stats-results" class="stats-results" style="display: none">
|
||||||
|
<div id="stats-loading" class="loading" style="display: none">
|
||||||
|
<p>Loading your stats...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stats-content" style="display: none">
|
||||||
|
<h2>Your Last.fm Stats</h2>
|
||||||
|
|
||||||
|
<div class="stat-box">
|
||||||
|
<h3>Total Tracks</h3>
|
||||||
|
<p id="total-tracks" class="stat-number">-</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-box">
|
||||||
|
<h3>Top 5 Artists</h3>
|
||||||
|
<ul id="top-artists" class="artist-list"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="export-buttons" class="export-buttons" style="display: none">
|
||||||
|
<button id="copy-markdown" class="btn-export">Copy as Markdown</button>
|
||||||
|
<button id="copy-plaintext" class="btn-export">Copy as Plain Text</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stats-error" class="error" style="display: none">
|
||||||
|
<p id="error-message"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
60
static/lastfm-week.sh
Executable file
60
static/lastfm-week.sh
Executable file
|
|
@ -0,0 +1,60 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Last.fm Weekly Stats Script
|
||||||
|
# Fetches your Last.fm listening statistics for the past week
|
||||||
|
#
|
||||||
|
# Requirements:
|
||||||
|
# - curl (for API requests)
|
||||||
|
# - jq (for JSON parsing)
|
||||||
|
#
|
||||||
|
# Usage: ./lastfm-week.sh
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# Create a .env file with:
|
||||||
|
# LASTFM_API_KEY=your_api_key_here
|
||||||
|
# LASTFM_USERNAME=your_username_here
|
||||||
|
#
|
||||||
|
# Output: Markdown-formatted stats with top artists and track counts
|
||||||
|
#
|
||||||
|
# Download from: https://ritual.sh/resources/lastfm-stats/
|
||||||
|
|
||||||
|
# Load environment variables from .env file
|
||||||
|
if [ -f .env ]; then
|
||||||
|
export $(cat .env | grep -v '^#' | xargs)
|
||||||
|
else
|
||||||
|
echo "Error: .env file not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check required variables
|
||||||
|
if [ -z "$LASTFM_API_KEY" ] || [ -z "$LASTFM_USERNAME" ]; then
|
||||||
|
echo "Error: LASTFM_API_KEY and LASTFM_USERNAME must be set in .env file"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
API_BASE="http://ws.audioscrobbler.com/2.0/"
|
||||||
|
|
||||||
|
# Get current timestamp
|
||||||
|
NOW=$(date +%s)
|
||||||
|
# Get timestamp from 7 days ago
|
||||||
|
WEEK_AGO=$((NOW - 604800))
|
||||||
|
|
||||||
|
# Fetch top artists for the week
|
||||||
|
TOP_ARTISTS=$(curl -s "${API_BASE}?method=user.gettopartists&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&period=7day&limit=5")
|
||||||
|
|
||||||
|
# Fetch recent tracks to count this week's scrobbles
|
||||||
|
RECENT_TRACKS=$(curl -s "${API_BASE}?method=user.getrecenttracks&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&from=${WEEK_AGO}&to=${NOW}&limit=1")
|
||||||
|
|
||||||
|
# Get total track count
|
||||||
|
TOTAL_TRACKS=$(echo "$RECENT_TRACKS" | jq -r '.recenttracks["@attr"].total')
|
||||||
|
|
||||||
|
# Output in markdown format
|
||||||
|
echo "## Last.fm Weekly Stats"
|
||||||
|
echo ""
|
||||||
|
echo "**Total Tracks:** ${TOTAL_TRACKS}"
|
||||||
|
echo ""
|
||||||
|
echo "**Top 5 Artists:**"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Parse and display top 5 artists as markdown links
|
||||||
|
echo "$TOP_ARTISTS" | jq -r '.topartists.artist[] | "- [\(.name)](\(.url)) - \(.playcount) plays"'
|
||||||
Loading…
Add table
Add a link
Reference in a new issue