the home of serif.blue

Compare changes

Choose any two refs to compare.

.github/images/modal.webp

This is a binary file and will not be displayed.

.github/images/preview.webp

This is a binary file and will not be displayed.

.github/images/settings.webp

This is a binary file and will not be displayed.

+25
LICENSE.md
···
+
The MIT License (MIT)
+
=====================
+
+
Copyright © `2025` `Kieran Klukas`
+
+
Permission is hereby granted, free of charge, to any person
+
obtaining a copy of this software and associated documentation
+
files (the “Software”), to deal in the Software without
+
restriction, including without limitation the rights to use,
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
+
copies of the Software, and to permit persons to whom the
+
Software is furnished to do so, subject to the following
+
conditions:
+
+
The above copyright notice and this permission notice shall be
+
included in all copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+
OTHER DEALINGS IN THE SOFTWARE.
+44
README.md
···
+
# Bluesky Community Verifications
+
+
<img src="https://cachet.dunkirk.sh/emojis/bluesky/r" height="175" align="right" alt="Bluesky logo">
+
+
> ### Verify profiles you trust 💙
+
>
+
> A userscript that lets you appoint your own trusted verifiers and display verifications from their `app.bsky.graph.verification` collection.
+
>
+
> ⚠️ **extremely rapidly iterating so nothing is stable** - so far everything is still backwards compatible though!
+
+
## 🏗️ Usage
+
+
By default we have `bsky.app`, `nytimes.com`, `wired.com`, and `theathletic.bsky.social` as trusted verifiers. You can add more by going to bluesky settings and clicking on "Community Verifications"
+
+
That will open a modal where you can add more trusted verifiers and modify how the verification badge looks.
+
+
![preview](https://raw.githubusercontent.com/taciturnaxolotl/serif/main/.github/images/preview.webp)
+
+
## 🔧 Installation
+
+
1. Install a userscript manager for your browser:
+
- Chrome: [Tampermonkey](https://chrome.google.com/webstore/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo)
+
- Firefox: [Tampermonkey](https://addons.mozilla.org/en-US/firefox/addon/tampermonkey/)
+
- Safari: [Tampermonkey](https://apps.apple.com/app/tampermonkey/id1482490089)
+
- Edge: [Tampermonkey](https://microsoftedge.microsoft.com/addons/detail/tampermonkey/iikmkjmpaadaobahmlepeloendndfphd)
+
- Opera: [Tampermonkey](https://addons.opera.com/en/extensions/details/tampermonkey-beta/)
+
+
2. Click [here](https://github.com/taciturnaxolotl/serif/raw/refs/heads/main/bluesky-community-verifications.user.js) to install the script.
+
+
## 📜 License
+
+
The code is licensed under `AGPL 3.0`! That means AGPL 3.0 requires publishing source code changes when the software is used over a network, guaranteeing that users can access the code. All artwork and images are copyright reserved but may be used with proper attribution to the authors.
+
+
<p align="center">
+
<img src="https://raw.githubusercontent.com/taciturnaxolotl/carriage/master/.github/images/line-break.svg" />
+
</p>
+
+
<p align="center">
+
<i><code>&copy 2025-present <a href="https://github.com/taciturnaxolotl">Kieran Klukas</a></code></i>
+
</p>
+
+
<p align="center">
+
<a href="https://github.com/taciturnaxolotl/serif/blob/master/LICENSE.md"><img src="https://img.shields.io/static/v1.svg?style=for-the-badge&label=License&message=MIT&logoColor=d9e0ee&colorA=363a4f&colorB=b7bdf8"/></a>
+
</p>
+12
bluesky-community-verifications.meta.js
···
+
// ==UserScript==
+
// @name Bluesky Community Verifications
+
// @namespace https://tangled.sh/@dunkirk.sh/serif/verifications
+
// @version 0.2.1
+
// @description Shows verification badges from trusted community members on Bluesky
+
// @author Kieran Klukas
+
// @match https://bsky.app/*
+
// @grant none
+
// @run-at document-end
+
// @updateURL https://tangled.sh/@dunkirk.sh/serif/raw/main/bluesky-community-verifications.user.meta.js
+
// @downloadURL https://tangled.sh/@dunkirk.sh/serif/raw/main/bluesky-community-verifications.user.js
+
// ==/UserScript==
+650 -145
bluesky-community-verifications.user.js
···
+
// ==UserScript==
+
// @name Bluesky Community Verifications
+
// @namespace https://tangled.sh/@dunkirk.sh/serif/verifications
+
// @version 0.2.1
+
// @description Shows verification badges from trusted community members on Bluesky
+
// @author Kieran Klukas
+
// @match https://bsky.app/*
+
// @grant none
+
// @run-at document-end
+
// @updateURL https://tangled.sh/@dunkirk.sh/serif/raw/main/bluesky-community-verifications.user.meta.js
+
// @downloadURL https://tangled.sh/@dunkirk.sh/serif/raw/main/bluesky-community-verifications.user.js
+
// ==/UserScript==
+
(() => {
// Script has already been initialized check
if (window.bskyTrustedUsersInitialized) {
···
const TRUSTED_USERS_STORAGE_KEY = "bsky_trusted_users";
const VERIFICATION_CACHE_STORAGE_KEY = "bsky_verification_cache";
const CACHE_EXPIRY_TIME = 24 * 60 * 60 * 1000; // 24 hours
+
const BADGE_TYPE_STORAGE_KEY = "bsky_verification_badge_type";
+
const BADGE_COLOR_STORAGE_KEY = "bsky_verification_badge_color";
+
+
// Default badge configuration
+
const DEFAULT_BADGE_TYPE = "checkmark";
+
const DEFAULT_BADGE_COLOR = "#0070ff";
+
+
// Functions to get/set badge configuration
+
const getBadgeType = () => {
+
return localStorage.getItem(BADGE_TYPE_STORAGE_KEY) || DEFAULT_BADGE_TYPE;
+
};
+
+
const getBadgeColor = () => {
+
return localStorage.getItem(BADGE_COLOR_STORAGE_KEY) || DEFAULT_BADGE_COLOR;
+
};
+
+
const saveBadgeType = (type) => {
+
localStorage.setItem(BADGE_TYPE_STORAGE_KEY, type);
+
};
+
+
const saveBadgeColor = (color) => {
+
localStorage.setItem(BADGE_COLOR_STORAGE_KEY, color);
+
};
+
+
const getBadgeContent = (type) => {
+
switch (type) {
+
case "checkmark":
+
return "✓";
+
case "star":
+
return "★";
+
case "heart":
+
return "♥";
+
case "shield":
+
return "🛡️";
+
case "lock":
+
return "🔒";
+
case "verified":
+
return `<svg viewBox="0 0 24 24" width="16" height="16">
+
<path fill="white" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path>
+
</svg>`;
+
default:
+
return "✓";
+
}
+
};
// Function to get trusted users from local storage
const getTrustedUsers = () => {
···
const saveTrustedUsers = (users) => {
localStorage.setItem(TRUSTED_USERS_STORAGE_KEY, JSON.stringify(users));
};
+
+
// Populate default trusted users if not already set
+
if (!localStorage.getItem(TRUSTED_USERS_STORAGE_KEY)) {
+
const defaultTrustedUsers = [
+
"bsky.app",
+
"nytimes.com",
+
"wired.com",
+
"theathletic.bsky.social",
+
];
+
saveTrustedUsers(defaultTrustedUsers);
+
console.log("Added default trusted users:", defaultTrustedUsers);
+
}
// Function to add a trusted user
const addTrustedUser = (handle) => {
···
console.log(`${profileDid} is not verified by any trusted users`);
-
// Add recheck button even when no verifications are found
-
createPillButtons();
-
return false;
};
-
// Function to create a pill with recheck and settings buttons
-
const createPillButtons = () => {
-
// Remove existing buttons if any
-
const existingPill = document.getElementById(
-
"trusted-users-pill-container",
-
);
-
if (existingPill) {
-
existingPill.remove();
-
}
-
-
// Create pill container
-
const pillContainer = document.createElement("div");
-
pillContainer.id = "trusted-users-pill-container";
-
pillContainer.style.cssText = `
-
position: fixed;
-
bottom: 20px;
-
right: 20px;
-
z-index: 10000;
-
display: flex;
-
border-radius: 20px;
-
overflow: hidden;
-
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
-
`;
-
-
// Create recheck button (left half of pill)
-
const recheckButton = document.createElement("button");
-
recheckButton.id = "trusted-users-recheck-button";
-
recheckButton.innerHTML = "↻ Recheck";
-
recheckButton.style.cssText = `
-
padding: 10px 15px;
-
background-color: #2D578D;
-
color: white;
-
border: none;
-
cursor: pointer;
-
font-weight: bold;
-
border-top-left-radius: 20px;
-
border-bottom-left-radius: 20px;
-
`;
-
-
// Add click event to recheck
-
recheckButton.addEventListener("click", async () => {
-
if (currentProfileDid) {
-
// Remove any existing badges when rechecking
-
const existingBadge = document.getElementById(
-
"user-trusted-verification-badge",
-
);
-
if (existingBadge) {
-
existingBadge.remove();
-
}
-
-
// Show loading state
-
recheckButton.innerHTML = "⟳ Checking...";
-
recheckButton.disabled = true;
-
-
// Recheck verifications
-
await checkTrustedUserVerifications(currentProfileDid);
-
-
// Reset button
-
recheckButton.innerHTML = "↻ Recheck";
-
recheckButton.disabled = false;
-
}
-
});
-
-
// Create vertical divider
-
const divider = document.createElement("div");
-
divider.style.cssText = `
-
width: 1px;
-
background-color: rgba(255, 255, 255, 0.3);
-
`;
-
-
// Create settings button (right half of pill)
-
const settingsButton = document.createElement("button");
-
settingsButton.id = "bsky-trusted-settings-button";
-
settingsButton.textContent = "Settings";
-
settingsButton.style.cssText = `
-
padding: 10px 15px;
-
background-color: #2D578D;
-
color: white;
-
border: none;
-
cursor: pointer;
-
font-weight: bold;
-
border-top-right-radius: 20px;
-
border-bottom-right-radius: 20px;
-
`;
-
-
// Add elements to pill
-
pillContainer.appendChild(recheckButton);
-
pillContainer.appendChild(divider);
-
pillContainer.appendChild(settingsButton);
-
-
// Add pill to page
-
document.body.appendChild(pillContainer);
-
-
// Add event listener to settings button
-
settingsButton.addEventListener("click", () => {
-
if (settingsModal) {
-
settingsModal.style.display = "flex";
-
updateTrustedUsersList();
-
} else {
-
createSettingsModal();
-
}
-
});
-
};
-
// Function to display verification badge on the profile
const displayVerificationBadge = (verifierHandles) => {
// Find the profile header or name element to add the badge to
···
);
const nameElement = nameElements[nameElements.length - 1];
-
console.log(nameElement);
+
console.log("nameElement", nameElement);
if (nameElement) {
// Remove existing badge if present
···
const badge = document.createElement("span");
badge.id = "user-trusted-verification-badge";
-
badge.innerHTML = "✓";
+
+
// Get user badge preferences
+
const badgeType = getBadgeType();
+
const badgeColor = getBadgeColor();
+
+
// Set badge content based on type
+
badge.innerHTML = getBadgeContent(badgeType);
+
+
// check if there is a div with button underneath
+
// Check if this user is verified by Bluesky
+
const isBlueskyVerified = nameElement.querySelector("div button");
+
if (isBlueskyVerified) isBlueskyVerified.remove();
// Create tooltip text with all verifiers
const verifiersText =
···
badge.title = verifiersText;
badge.style.cssText = `
-
background-color: #0070ff;
+
background-color: ${badgeColor};
color: white;
border-radius: 50%;
-
width: 18px;
-
height: 18px;
+
width: 22px;
+
height: 22px;
margin-left: 8px;
-
font-size: 12px;
+
font-size: 14px;
font-weight: bold;
cursor: help;
display: inline-flex;
align-items: center;
justify-content: center;
+
vertical-align: 5px;
`;
// Add a click event to show all verifiers
···
nameElement.appendChild(badge);
}
-
-
// Also add pill buttons when verification is found
-
createPillButtons();
};
// Function to show a popup with all verifiers
···
});
};
+
// Function to import verifications from the current user
+
const importVerificationsFromSelf = async () => {
+
try {
+
// Check if we can determine the current user
+
const bskyStorageData = localStorage.getItem("BSKY_STORAGE");
+
let userData = null;
+
+
if (bskyStorageData) {
+
try {
+
const bskyStorage = JSON.parse(bskyStorageData);
+
if (bskyStorage.session.currentAccount) {
+
userData = bskyStorage.session.currentAccount;
+
}
+
} catch (error) {
+
console.error("Error parsing BSKY_STORAGE data:", error);
+
}
+
}
+
+
if (!userData || !userData.handle) {
+
alert(
+
"Could not determine your Bluesky handle. Please ensure you're logged in.",
+
);
+
return;
+
}
+
+
if (!userData || !userData.handle) {
+
alert(
+
"Unable to determine your Bluesky handle. Make sure you're logged in.",
+
);
+
return;
+
}
+
+
const userHandle = userData.handle;
+
+
// Show loading state
+
const importButton = document.getElementById("importVerificationsBtn");
+
const originalText = importButton.textContent;
+
importButton.textContent = "Importing...";
+
importButton.disabled = true;
+
+
// Fetch verification records from the user's account with pagination
+
let allRecords = [];
+
let cursor = null;
+
let hasMore = true;
+
+
while (hasMore) {
+
const url = cursor
+
? `https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${userHandle}&collection=app.bsky.graph.verification&cursor=${cursor}`
+
: `https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${userHandle}&collection=app.bsky.graph.verification`;
+
+
const verificationResponse = await fetch(url);
+
const data = await verificationResponse.json();
+
+
if (data.records && data.records.length > 0) {
+
allRecords = [...allRecords, ...data.records];
+
}
+
+
if (data.cursor) {
+
cursor = data.cursor;
+
} else {
+
hasMore = false;
+
}
+
}
+
+
const verificationData = { records: allRecords };
+
+
if (!verificationData.records || verificationData.records.length === 0) {
+
alert("No verification records found in your account.");
+
importButton.textContent = originalText;
+
importButton.disabled = false;
+
return;
+
}
+
+
// Extract the handles of verified users
+
const verifiedUsers = [];
+
for (const record of verificationData.records) {
+
console.log(record.value.handle);
+
verifiedUsers.push(record.value.handle);
+
}
+
+
// Add all found users to trusted users
+
let addedCount = 0;
+
for (const handle of verifiedUsers) {
+
const existingUsers = getTrustedUsers();
+
if (!existingUsers.includes(handle)) {
+
addTrustedUser(handle);
+
addedCount++;
+
}
+
}
+
+
// Update the UI
+
updateTrustedUsersList();
+
+
// Reset button state
+
importButton.textContent = originalText;
+
importButton.disabled = false;
+
+
// Show result
+
alert(
+
`Successfully imported ${addedCount} verified users from your account.`,
+
);
+
} catch (error) {
+
console.error("Error importing verifications:", error);
+
alert("Error importing verifications. Check console for details.");
+
const importButton = document.getElementById("importVerificationsBtn");
+
if (importButton) {
+
importButton.textContent = "Import Verifications";
+
importButton.disabled = false;
+
}
+
}
+
};
+
+
const addSettingsButton = () => {
+
// Check if we're on the settings page
+
if (!window.location.href.includes("bsky.app/settings")) {
+
return;
+
}
+
+
// Check if our button already exists to avoid duplicates
+
if (document.getElementById("community-verifications-settings-button")) {
+
return;
+
}
+
+
// Find the right place to insert our button (after content-and-media link)
+
const contentMediaLink = document.querySelector(
+
'a[href="/settings/content-and-media"]',
+
);
+
if (!contentMediaLink) {
+
console.log("Could not find content-and-media link to insert after");
+
return;
+
}
+
+
// Clone the existing link and modify it
+
const verificationButton = contentMediaLink.cloneNode(true);
+
verificationButton.id = "community-verifications-settings-button";
+
verificationButton.href = "#"; // No actual link, we'll handle click with JS
+
verificationButton.setAttribute("aria-label", "Community Verifications");
+
+
const highlightColor =
+
verificationButton.firstChild.style.backgroundColor || "rgb(30,41,54)";
+
+
// Add hover effect to highlight the button
+
verificationButton.addEventListener("mouseover", () => {
+
verificationButton.firstChild.style.backgroundColor = highlightColor;
+
});
+
+
verificationButton.addEventListener("mouseout", () => {
+
verificationButton.firstChild.style.backgroundColor = null;
+
});
+
+
// Update the text content
+
const textDiv = verificationButton.querySelector(".css-146c3p1");
+
if (textDiv) {
+
textDiv.textContent = "Community Verifications";
+
}
+
+
// Update the icon
+
const iconDiv = verificationButton.querySelector(
+
".css-175oi2r[style*='width: 28px']",
+
);
+
if (iconDiv) {
+
iconDiv.innerHTML = `
+
<svg fill="none" width="28" viewBox="0 0 24 24" height="28" style="color: rgb(241, 243, 245);">
+
<path fill="hsl(211, 20%, 95.3%)" d="M21.2,9.3c-0.5-0.5-1.1-0.7-1.8-0.7h-2.3V6.3c0-2.1-1.7-3.7-3.7-3.7h-3c-2.1,0-3.7,1.7-3.7,3.7v2.3H4.6
+
c-0.7,0-1.3,0.3-1.8,0.7c-0.5,0.5-0.7,1.1-0.7,1.8v9.3c0,0.7,0.3,1.3,0.7,1.8c0.5,0.5,1.1,0.7,1.8,0.7h14.9c0.7,0,1.3-0.3,1.8-0.7
+
c0.5-0.5,0.7-1.1,0.7-1.8v-9.3C22,10.4,21.7,9.8,21.2,9.3z M14.1,15.6l-1.3,1.3c-0.1,0.1-0.3,0.2-0.5,0.2c-0.2,0-0.3-0.1-0.5-0.2l-3.3-3.3
+
c-0.1-0.1-0.2-0.3-0.2-0.5c0-0.2,0.1-0.3,0.2-0.5l1.3-1.3c0.1-0.1,0.3-0.2,0.5-0.2c0.2,0,0.3,0.1,0.5,0.2l1.5,1.5l4.2-4.2
+
c0.1-0.1,0.3-0.2,0.5-0.2c0.2,0,0.3,0.1,0.5,0.2l1.3,1.3c0.1,0.1,0.2,0.3,0.2,0.5c0,0.2-0.1,0.3-0.2,0.5L14.1,15.6z M9.7,6.3
+
c0-0.9,0.7-1.7,1.7-1.7h3c0.9,0,1.7,0.7,1.7,1.7v2.3H9.7V6.3z"/>
+
</svg>
+
`;
+
}
+
+
// Insert our button after the content-and-media link
+
const parentElement = contentMediaLink.parentElement;
+
parentElement.insertBefore(
+
verificationButton,
+
contentMediaLink.nextSibling,
+
);
+
+
// Add click event to open our settings modal
+
verificationButton.addEventListener("click", (e) => {
+
e.preventDefault();
+
if (settingsModal) {
+
settingsModal.style.display = "flex";
+
updateTrustedUsersList();
+
} else {
+
createSettingsModal();
+
}
+
});
+
+
console.log("Added Community Verifications button to settings page");
+
};
+
// Function to create the settings modal
const createSettingsModal = () => {
// Create modal container
settingsModal = document.createElement("div");
settingsModal.id = "bsky-trusted-settings-modal";
settingsModal.style.cssText = `
-
display: none;
+
display: flex;
position: fixed;
top: 0;
left: 0;
···
const modalHeader = document.createElement("div");
modalHeader.innerHTML = `<h2 style="margin-top: 0;">Trusted Bluesky Users</h2>`;
+
const badgeCustomization = document.createElement("div");
+
badgeCustomization.style.cssText = `
+
margin-top: 15px;
+
padding-top: 15px;
+
border-top: 1px solid #eee;
+
`;
+
+
badgeCustomization.innerHTML = `
+
<h2 style="margin-top: 0; color: white;">Badge Customization</h3>
+
+
<div style="margin-bottom: 1rem;">
+
<p style="margin-bottom: 8px; color: white;">Badge Type:</p>
+
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
+
<label style="display: flex; align-items: center; cursor: pointer; color: white;">
+
<input type="radio" name="badgeType" value="checkmark" ${getBadgeType() === "checkmark" ? "checked" : ""}>
+
<span style="margin-left: 5px;">Checkmark (✓)</span>
+
</label>
+
<label style="display: flex; align-items: center; cursor: pointer; color: white;">
+
<input type="radio" name="badgeType" value="star" ${getBadgeType() === "star" ? "checked" : ""}>
+
<span style="margin-left: 5px;">Star (★)</span>
+
</label>
+
<label style="display: flex; align-items: center; cursor: pointer; color: white;">
+
<input type="radio" name="badgeType" value="heart" ${getBadgeType() === "heart" ? "checked" : ""}>
+
<span style="margin-left: 5px;">Heart (♥)</span>
+
</label>
+
<label style="display: flex; align-items: center; cursor: pointer; color: white;">
+
<input type="radio" name="badgeType" value="shield" ${getBadgeType() === "shield" ? "checked" : ""}>
+
<span style="margin-left: 5px;">Shield (🛡️)</span>
+
</label>
+
<label style="display: flex; align-items: center; cursor: pointer; color: white;">
+
<input type="radio" name="badgeType" value="lock" ${getBadgeType() === "lock" ? "checked" : ""}>
+
<span style="margin-left: 5px;">Lock (🔒)</span>
+
</label>
+
<label style="display: flex; align-items: center; cursor: pointer; color: white;">
+
<input type="radio" name="badgeType" value="verified" ${getBadgeType() === "verified" ? "checked" : ""}>
+
<span style="margin-left: 5px;">Verified</span>
+
</label>
+
</div>
+
</div>
+
+
<div>
+
<p style="margin-bottom: 8px; color: white;">Badge Color:</p>
+
<div style="display: flex; align-items: center;">
+
<input type="color" id="badgeColorPicker" value="${getBadgeColor()}" style="margin-right: 10px;">
+
<span id="badgeColorPreview" style="display: inline-block; width: 24px; height: 24px; background-color: ${getBadgeColor()}; border-radius: 50%; margin-right: 10px;"></span>
+
<button id="resetBadgeColor" style="padding: 5px 10px; background: #473A3A; color: white; border: none; border-radius: 4px; cursor: pointer;">Reset to Default</button>
+
</div>
+
</div>
+
+
<div style="margin-top: 20px; margin-bottom: 1rem;">
+
<p style="color: white;">Preview:</p>
+
<div style="display: flex; align-items: center; margin-top: 8px;">
+
<span style="color: white; font-weight: bold;">User Name</span>
+
<span id="badgePreview" style="
+
background-color: ${getBadgeColor()};
+
color: white;
+
border-radius: 50%;
+
width: 22px;
+
height: 22px;
+
margin-left: 8px;
+
font-size: 14px;
+
font-weight: bold;
+
display: inline-flex;
+
align-items: center;
+
justify-content: center;
+
">${getBadgeContent(getBadgeType())}</span>
+
</div>
+
</div>
+
`;
+
+
// Add the badge customization section to the modal content
+
modalContent.appendChild(badgeCustomization);
+
+
// Add event listeners for the badge customization controls
+
setTimeout(() => {
+
// Badge type selection
+
const badgeTypeRadios = document.querySelectorAll(
+
'input[name="badgeType"]',
+
);
+
for (const radio of badgeTypeRadios) {
+
radio.addEventListener("change", (e) => {
+
const selectedType = e.target.value;
+
saveBadgeType(selectedType);
+
updateBadgePreview();
+
});
+
}
+
+
// Badge color picker
+
const colorPicker = document.getElementById("badgeColorPicker");
+
const colorPreview = document.getElementById("badgeColorPreview");
+
+
colorPicker.addEventListener("input", (e) => {
+
const selectedColor = e.target.value;
+
colorPreview.style.backgroundColor = selectedColor;
+
saveBadgeColor(selectedColor);
+
updateBadgePreview();
+
});
+
+
// Reset color button
+
const resetColorBtn = document.getElementById("resetBadgeColor");
+
resetColorBtn.addEventListener("click", () => {
+
colorPicker.value = DEFAULT_BADGE_COLOR;
+
colorPreview.style.backgroundColor = DEFAULT_BADGE_COLOR;
+
saveBadgeColor(DEFAULT_BADGE_COLOR);
+
updateBadgePreview();
+
});
+
+
// Function to update the badge preview
+
function updateBadgePreview() {
+
const badgePreview = document.getElementById("badgePreview");
+
const selectedType = getBadgeType();
+
const selectedColor = getBadgeColor();
+
+
badgePreview.innerHTML = getBadgeContent(selectedType);
+
badgePreview.style.backgroundColor = selectedColor;
+
}
+
+
// Initialize preview
+
updateBadgePreview();
+
}, 100);
+
// Create input form
const form = document.createElement("div");
form.innerHTML = `
···
</div>
`;
+
// Create import button
+
const importContainer = document.createElement("div");
+
importContainer.style.cssText = `
+
margin-top: 10px;
+
margin-bottom: 15px;
+
`;
+
+
const importButton = document.createElement("button");
+
importButton.id = "importVerificationsBtn";
+
importButton.textContent = "Import Your Verifications";
+
importButton.style.cssText = `
+
background-color: #2D578D;
+
color: white;
+
border: none;
+
border-radius: 4px;
+
padding: 8px 15px;
+
cursor: pointer;
+
width: 100%;
+
`;
+
+
importButton.addEventListener("click", importVerificationsFromSelf);
+
importContainer.appendChild(importButton);
+
// Create trusted users list
const trustedUsersList = document.createElement("div");
trustedUsersList.id = "trustedUsersList";
···
// Assemble modal
modalContent.appendChild(modalHeader);
modalContent.appendChild(form);
+
modalContent.appendChild(importContainer);
modalContent.appendChild(trustedUsersList);
modalContent.appendChild(cacheControls);
modalContent.appendChild(closeButton);
···
updateTrustedUsersList();
};
-
// Function to create the settings UI if it doesn't exist yet
-
const createSettingsUI = () => {
-
// Create pill with buttons
-
createPillButtons();
-
-
// Create the settings modal if it doesn't exist yet
-
if (!settingsModal) {
-
createSettingsModal();
-
}
-
};
-
// Function to check the current profile
const checkCurrentProfile = () => {
const currentUrl = window.location.href;
···
) {
const handle = currentUrl.split("/profile/")[1].split("/")[0];
console.log("Detected profile page for:", handle);
-
-
// Create and add the settings UI (only once)
-
createSettingsUI();
// Fetch user profile data
fetch(
···
if (existingBadge) {
existingBadge.remove();
}
+
}
+
};
-
const existingPill = document.getElementById(
-
"trusted-users-pill-container",
-
);
-
if (existingPill) {
-
existingPill.remove();
+
const checkUserLinksOnPage = async () => {
+
// Look for profile links with handles
+
// Find all profile links and filter to get only one link per parent
+
const allProfileLinks = Array.from(
+
document.querySelectorAll('a[href^="/profile/"]:not(:has(*))'),
+
);
+
+
// Use a Map to keep track of parent elements and their first child link
+
const parentMap = new Map();
+
+
// For each link, store only the first one found for each parent
+
for (const link of allProfileLinks) {
+
const parent = link.parentElement;
+
if (parent && !parentMap.has(parent)) {
+
parentMap.set(parent, link);
+
}
+
}
+
+
// Get only the first link for each parent
+
const profileLinks = Array.from(parentMap.values());
+
+
if (profileLinks.length === 0) return;
+
+
console.log(`Found ${profileLinks.length} possible user links on page`);
+
+
// Process profile links to identify user containers
+
for (const link of profileLinks) {
+
try {
+
// Check if we already processed this link
+
if (link.getAttribute("data-verification-checked") === "true") continue;
+
+
// Mark as checked
+
link.setAttribute("data-verification-checked", "true");
+
+
// Extract handle from href
+
const handle = link.getAttribute("href").split("/profile/")[1];
+
if (!handle) continue;
+
+
// check if there is anything after the handle
+
const handleTrailing = handle.split("/").length > 1;
+
if (handleTrailing) continue;
+
+
// Find parent container that might contain the handle and verification icon
+
// Look for containers where this link is followed by another link with the same handle
+
const parent = link.parentElement;
+
+
// If we found a container with the verification icon
+
if (parent) {
+
// Check if this user already has our verification badge
+
if (parent.querySelector(".trusted-user-inline-badge")) continue;
+
+
try {
+
// Fetch user profile data to get DID
+
const response = await fetch(
+
`https://public.api.bsky.app/xrpc/com.atproto.repo.getRecord?repo=${handle}&collection=app.bsky.actor.profile&rkey=self`,
+
);
+
const data = await response.json();
+
+
// Extract the DID from the profile data
+
const did = data.uri.split("/")[2];
+
+
// Check if this user is verified by our trusted users
+
const trustedUsers = getTrustedUsers();
+
let isVerified = false;
+
const verifiers = [];
+
+
// Check cache first for each trusted user
+
for (const trustedUser of trustedUsers) {
+
const cachedData = getCachedVerifications(trustedUser);
+
+
if (cachedData && isCacheValid(cachedData)) {
+
// Use cached verification data
+
const records = cachedData.records;
+
+
for (const record of records) {
+
if (record.value && record.value.subject === did) {
+
isVerified = true;
+
verifiers.push(trustedUser);
+
break;
+
}
+
}
+
}
+
}
+
+
// If verified, add a small badge
+
if (isVerified && verifiers.length > 0) {
+
// Create a badge element
+
const smallBadge = document.createElement("span");
+
smallBadge.className = "trusted-user-inline-badge";
+
+
// Get user badge preferences
+
const badgeType = getBadgeType();
+
const badgeColor = getBadgeColor();
+
+
smallBadge.innerHTML = getBadgeContent(badgeType);
+
+
// Create tooltip text with all verifiers
+
const verifiersText =
+
verifiers.length > 1
+
? `Verified by: ${verifiers.join(", ")}`
+
: `Verified by ${verifiers[0]}`;
+
+
smallBadge.title = verifiersText;
+
smallBadge.style.cssText = `
+
background-color: ${badgeColor};
+
color: white;
+
border-radius: 50%;
+
width: 16px;
+
height: 16px;
+
font-size: 11px;
+
font-weight: bold;
+
cursor: help;
+
display: inline-flex;
+
align-items: center;
+
justify-content: center;
+
margin-left: 4px;
+
`;
+
+
// Add click event to show verifiers
+
smallBadge.addEventListener("click", (e) => {
+
e.stopPropagation();
+
showVerifiersPopup(verifiers);
+
});
+
+
// Insert badge after the SVG element
+
parent.firstChild.after(smallBadge);
+
parent.style.flexDirection = "row";
+
parent.style.alignItems = "center";
+
+
// Look for verification SVG icon in the parent and remove it if it exists
+
const badgeSvgIcon = Array.from(parent.childNodes).find(
+
(node) =>
+
node.nodeType === Node.ELEMENT_NODE &&
+
node.tagName === "DIV" &&
+
node.querySelector("svg"),
+
);
+
if (badgeSvgIcon) {
+
badgeSvgIcon.remove();
+
}
+
}
+
} catch (error) {
+
console.error(`Error checking verification for ${handle}:`, error);
+
}
+
}
+
} catch (error) {
+
console.error("Error processing profile link:", error);
}
}
};
-
// Initial check
-
checkCurrentProfile();
+
const observeContentChanges = () => {
+
// Use a debounced function to check for new user links
+
const debouncedCheck = () => {
+
clearTimeout(window.userLinksCheckTimeout);
+
window.userLinksCheckTimeout = setTimeout(() => {
+
checkUserLinksOnPage();
+
}, 300);
+
};
+
+
// Create a mutation observer that watches for DOM changes
+
const observer = new MutationObserver((mutations) => {
+
let hasRelevantChanges = false;
+
+
// Check if any mutations involve adding new nodes
+
for (const mutation of mutations) {
+
if (mutation.addedNodes.length > 0) {
+
for (const node of mutation.addedNodes) {
+
if (node.nodeType === Node.ELEMENT_NODE) {
+
// Check if this element or its children might contain profile links
+
if (
+
node.querySelector('a[href^="/profile/"]') ||
+
(node.tagName === "A" &&
+
node.getAttribute("href")?.startsWith("/profile/"))
+
) {
+
hasRelevantChanges = true;
+
break;
+
}
+
}
+
}
+
}
+
if (hasRelevantChanges) break;
+
}
+
+
if (hasRelevantChanges) {
+
debouncedCheck();
+
}
+
});
+
+
// Observe the entire document for content changes that might include profile links
+
observer.observe(document.body, { childList: true, subtree: true });
+
+
// Also check periodically for posts that might have been loaded but not caught by the observer
+
setInterval(debouncedCheck, 5000);
+
};
+
+
// Wait for DOM to be fully loaded before initializing
+
document.addEventListener("DOMContentLoaded", () => {
+
// Initial check for user links
+
checkUserLinksOnPage();
+
+
// Initial check
+
setTimeout(checkCurrentProfile, 2000);
+
+
// Add settings button if we're on the settings page
+
if (window.location.href.includes("bsky.app/settings")) {
+
// Wait for the content-and-media link to appear before adding our button
+
const waitForSettingsLink = setInterval(() => {
+
const contentMediaLink = document.querySelector(
+
'a[href="/settings/content-and-media"]',
+
);
+
if (contentMediaLink) {
+
clearInterval(waitForSettingsLink);
+
addSettingsButton();
+
}
+
}, 200);
+
}
+
});
+
+
// Start observing for content changes to detect newly loaded posts
+
observeContentChanges();
// Set up a MutationObserver to watch for URL changes
const observeUrlChanges = () => {
···
existingBadge.remove();
}
-
const existingPill = document.getElementById(
-
"trusted-users-pill-container",
-
);
-
if (existingPill) {
-
existingPill.remove();
-
}
-
// Check if we're on a profile page now
setTimeout(checkCurrentProfile, 500); // Small delay to ensure DOM has updated
+
+
if (window.location.href.includes("bsky.app/settings")) {
+
// Give the page a moment to fully load
+
setTimeout(addSettingsButton, 200);
+
}
}
});
site/favicon/apple-touch-icon.png

This is a binary file and will not be displayed.

site/favicon/favicon-96x96.png

This is a binary file and will not be displayed.

site/favicon/favicon.ico

This is a binary file and will not be displayed.

+26
site/favicon/favicon.svg
···
+
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
+
<defs></defs>
+
<path d="M120.27,7.69 C117.89,5.31 114.72,4 111.36,4 C108,4 104.83,5.31 102.45,7.69 C97.54,12.61 97.54,20.61 102.45,25.53 C104.83,27.91 108,29.22 111.36,29.22 C114.73,29.22 117.89,27.91 120.28,25.53 C125.19,20.61 125.19,12.61 120.27,7.69 Z M106.38,11.63 C107.71,10.3 109.48,9.57 111.35,9.57 C113.22,9.57 115,10.3 116.33,11.63 C119.08,14.38 119.08,18.85 116.33,21.59 C115,22.92 113.23,23.65 111.35,23.65 C109.47,23.65 107.7,22.92 106.38,21.59 C103.64,18.85 103.64,14.38 106.38,11.63 Z" fill="#E2A610"></path>
+
<path d="M105.51,30.95 L97.04,22.47 L103.08,16.42 C104.42,15.08 106.59,15.08 107.94,16.42 L111.56,20.04 C112.9,21.38 112.9,23.55 111.56,24.89 L105.51,30.95 Z" fill="#FFCA28"></path>
+
<path d="M107.69,28.76 C107.69,28.76 109.02,26.09 105.45,22.52 C101.88,18.95 99.18,20.32 99.18,20.32 L97.04,22.47 L105.51,30.95 L107.69,28.76 Z" fill="#E2A610"></path>
+
<path d="M120.7,65.63 A58.33 58.37 0 1 1 4.04,65.63 A58.33 58.37 0 1 1 120.7,65.63 Z" fill="#FFCA28"></path>
+
<path d="M109.66,65.63 A47.29 47.32 0 1 1 15.08,65.63 A47.29 47.32 0 1 1 109.66,65.63 Z" fill="#FFFFFF"></path>
+
<path d="M68.78,72.05 L62.37,99.76 L55.96,72.05 L28.26,65.63 L55.96,59.22 L62.37,31.5 L68.78,59.22 L96.48,65.63 Z" fill="#E0C3AB"></path>
+
<path d="M71.43,65.63 L86.49,89.77 L62.37,74.7 L38.25,89.77 L53.31,65.63 L38.25,41.5 L62.37,56.56 L86.49,41.5 Z" fill="#B2947C"></path>
+
<path d="M50.45,86.94 C50.45,86.94 63.27,77.85 68.13,72.99 C73,68.14 83.26,53.87 83.26,53.87 C86.01,50.82 89.1,43.11 89.96,40.87 C90.05,40.63 89.82,40.39 89.58,40.48 C87.62,41.23 81.41,43.68 77.08,46.44 C71.67,49.9 61.99,57.81 57.48,62.32 C52.97,66.83 47.11,75.52 43.78,80.25 C41,84.21 37.52,91.57 36.47,93.86 C36.35,94.11 36.61,94.37 36.86,94.26 C39.22,93.27 46.87,89.9 50.45,86.94 Z" fill="#212121" opacity="0.32"></path>
+
<path d="M82.66,52.46 C85.52,48.81 88.23,41.62 89.03,39.39 C89.13,39.12 88.87,38.86 88.59,38.95 C86.36,39.75 79.18,42.47 75.53,45.33 C71.18,48.74 56.71,60.63 56.71,60.63 L67.36,71.29 C67.37,71.29 79.25,56.81 82.66,52.46 Z" fill="#F44336"></path>
+
<path d="M41.41,79.48 C38.55,83.13 35.84,90.32 35.04,92.55 C34.94,92.82 35.2,93.08 35.48,92.99 C37.71,92.19 44.89,89.47 48.54,86.61 C52.89,83.2 67.36,71.31 67.36,71.31 L56.7,60.65 C56.7,60.65 44.82,75.12 41.41,79.48 Z" fill="#2F7889"></path>
+
<path d="M69.59,65.98 A7.56 7.57 0 1 1 54.47,65.98 A7.56 7.57 0 1 1 69.59,65.98 Z" fill="#94D1E0"></path>
+
<path d="M90.46,37.52 L90.46,37.52 C89.12,36.18 89.12,34 90.46,32.66 L94.02,29.1 L98.88,33.96 L95.33,37.52 C93.98,38.87 91.8,38.87 90.46,37.52 Z" fill="#F44336"></path>
+
<path d="M34.28,93.74 L34.28,93.74 C35.62,95.08 35.62,97.26 34.28,98.6 L30.72,102.16 L25.86,97.3 L29.41,93.74 C30.76,92.4 32.94,92.4 34.28,93.74 Z" fill="#B2947C"></path>
+
<path d="M102.1,65.63 L102.1,65.63 C102.1,63.73 103.64,62.19 105.54,62.19 L110.56,62.19 L110.56,69.07 L105.54,69.07 C103.64,69.07 102.1,67.53 102.1,65.63 Z" fill="#E0C3AB"></path>
+
<path d="M22.65,65.63 L22.65,65.63 C22.65,67.53 21.11,69.07 19.21,69.07 L14.19,69.07 L14.19,62.19 L19.21,62.19 C21.11,62.19 22.65,63.73 22.65,65.63 Z" fill="#E0C3AB"></path>
+
<path d="M90.46,93.74 L90.46,93.74 C91.8,92.4 93.98,92.4 95.32,93.74 L98.87,97.3 L94.01,102.16 L90.45,98.6 C89.12,97.26 89.12,95.09 90.46,93.74 Z" fill="#B2947C"></path>
+
<path d="M34.28,37.52 L34.28,37.52 C32.94,38.86 30.76,38.86 29.42,37.52 L25.87,33.96 L30.73,29.1 L34.29,32.66 C35.62,34 35.62,36.18 34.28,37.52 Z" fill="#B2947C"></path>
+
<path d="M62.37,105.38 L62.37,105.38 C64.27,105.38 65.81,106.92 65.81,108.82 L65.81,113.85 L58.94,113.85 L58.94,108.82 C58.93,106.92 60.47,105.38 62.37,105.38 Z" fill="#E0C3AB"></path>
+
<path d="M62.37,25.88 L62.37,25.88 C60.47,25.88 58.93,24.34 58.93,22.44 L58.93,17.41 L65.8,17.41 L65.8,22.44 C65.81,24.34 64.27,25.88 62.37,25.88 Z" fill="#E0C3AB"></path>
+
<path d="M111.16,65.63 Q111.16,85.852 96.87,100.151 Q82.58,114.45 62.37,114.45 Q42.16,114.45 27.87,100.151 Q13.58,85.852 13.58,65.63 Q13.58,45.408 27.87,31.109 Q42.16,16.81 62.37,16.81 Q82.58,16.81 96.87,31.109 Q111.16,45.408 111.16,65.63 Z M108.16,65.63 Q108.16,46.65 94.748,33.23 Q81.337,19.81 62.37,19.81 Q43.403,19.81 29.992,33.23 Q16.58,46.65 16.58,65.63 Q16.58,84.609 29.992,98.03 Q43.403,111.45 62.37,111.45 Q81.337,111.45 94.748,98.03 Q108.16,84.609 108.16,65.63 Z" fill="#E2A610"></path>
+
<path d="M8.72,64.92 C8.72,64.92 6.6,54.18 12.25,40.88 C20.59,21.26 38.6,10.46 57.97,9.62 C72.5,8.99 80.88,13.78 80.88,13.78 C80.88,13.78 69.06,12.81 60.91,13.17 C41.25,14.04 27.59,23.01 18.23,38.35 C10.53,50.99 8.72,64.92 8.72,64.92 Z" fill="#FFF59D"></path>
+
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
+
@media (prefers-color-scheme: dark) { :root { filter: none; } }
+
</style></svg>
+21
site/favicon/site.webmanifest
···
+
{
+
"name": "Serif.blue",
+
"short_name": "Serif.blue",
+
"icons": [
+
{
+
"src": "/web-app-manifest-192x192.png",
+
"sizes": "192x192",
+
"type": "image/png",
+
"purpose": "maskable"
+
},
+
{
+
"src": "/web-app-manifest-512x512.png",
+
"sizes": "512x512",
+
"type": "image/png",
+
"purpose": "maskable"
+
}
+
],
+
"theme_color": "#B6E2FF",
+
"background_color": "#B6E2FF",
+
"display": "standalone"
+
}
site/favicon/web-app-manifest-192x192.png

This is a binary file and will not be displayed.

site/favicon/web-app-manifest-512x512.png

This is a binary file and will not be displayed.

+262
site/index.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+
<link
+
rel="icon"
+
type="image/png"
+
href="/favicon/favicon-96x96.png"
+
sizes="96x96"
+
/>
+
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
+
<link rel="shortcut icon" href="/favicon/favicon.ico" />
+
<link
+
rel="apple-touch-icon"
+
sizes="180x180"
+
href="/favicon/apple-touch-icon.png"
+
/>
+
<meta name="apple-mobile-web-app-title" content="Serif.blue" />
+
<link rel="manifest" href="/favicon/site.webmanifest" />
+
+
<meta
+
name="description"
+
content="Serif.blue - Fancy projects by Kieran"
+
/>
+
<meta name="color-scheme" content="light" />
+
+
<meta property="og:title" content="Serif.blue" />
+
<meta property="og:type" content="website" />
+
<meta property="og:url" content="https://serif.blue/" />
+
<meta property="og:image" content="/og.png" />
+
+
<link rel="me" href="https://dunkirk.sh" />
+
<link rel="me" href="https://bsky.app/profile/dunkirk.sh" />
+
<link rel="me" href="https://github.com/taciturnaxolotl" />
+
+
<title>Serif.blue</title>
+
<style>
+
:root {
+
--alice-blue: #d9f0ffff;
+
--light-sky-blue: #a3d5ffff;
+
--light-sky-blue-2: #83c9f4ff;
+
--medium-slate-blue: #6f73d2ff;
+
--glaucous: #7681b3ff;
+
}
+
+
html,
+
body {
+
height: 100%;
+
margin: 0;
+
padding: 0;
+
}
+
+
body {
+
font-family: "Georgia", serif;
+
background-color: var(--alice-blue);
+
color: #333;
+
line-height: 1.6;
+
max-width: 1200px;
+
margin: 0 auto;
+
display: flex;
+
flex-direction: column;
+
}
+
+
.container {
+
max-width: 800px;
+
margin: 0 auto;
+
padding: 20px;
+
flex: 1 0 auto;
+
}
+
+
header {
+
background-color: var(--medium-slate-blue);
+
color: white;
+
padding: 2rem 1rem;
+
text-align: center;
+
margin-bottom: 20px;
+
border-bottom: 1px solid var(--light-sky-blue);
+
}
+
+
header h1 {
+
margin: 0;
+
font-size: 2.5rem;
+
letter-spacing: 0.5px;
+
}
+
+
header p {
+
margin: 0.5rem 0 0;
+
font-style: italic;
+
opacity: 0.9;
+
}
+
+
main {
+
padding: 30px;
+
background-color: white;
+
border-radius: 5px;
+
margin-top: 20px;
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+
}
+
+
main h2 {
+
color: var(--medium-slate-blue);
+
border-bottom: 2px solid var(--light-sky-blue);
+
padding-bottom: 10px;
+
margin-top: 0;
+
}
+
+
.card {
+
background-color: var(--alice-blue);
+
border-radius: 5px;
+
padding: 25px;
+
margin-bottom: 25px;
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+
border-left: 4px solid var(--light-sky-blue);
+
}
+
+
.card h3 {
+
color: var(--glaucous);
+
margin-top: 0;
+
}
+
+
.btn {
+
display: inline-block;
+
background-color: var(--medium-slate-blue);
+
color: white;
+
padding: 8px 16px;
+
border-radius: 4px;
+
text-decoration: none;
+
transition: background-color 0.3s;
+
font-family: Arial, sans-serif;
+
font-size: 0.9rem;
+
letter-spacing: 0.5px;
+
margin-right: 10px;
+
margin-bottom: 10px;
+
}
+
+
.btn:hover {
+
background-color: var(--glaucous);
+
}
+
+
.btn-bsky {
+
background-color: var(--bsky-blue);
+
}
+
+
.btn-bsky:hover {
+
background-color: #0066cc;
+
}
+
+
code {
+
background-color: var(--light-sky-blue);
+
padding: 2px 4px;
+
border-radius: 4px;
+
font-family: monospace;
+
font-size: 0.9rem;
+
letter-spacing: 0.5px;
+
}
+
+
footer {
+
text-align: center;
+
padding: 20px;
+
color: var(--glaucous);
+
font-size: 0.9rem;
+
border-top: 1px solid var(--light-sky-blue);
+
flex-shrink: 0;
+
}
+
</style>
+
</head>
+
<body>
+
<header>
+
<h1>Serif.blue</h1>
+
<p>Fancy projects by Kieran</p>
+
</header>
+
+
<div class="container">
+
<main>
+
<h2>Some fancy tools</h2>
+
<p>Hiiiii :)</p>
+
<p>
+
This is a random collection of tools and such that I have
+
made / collected. It's fairly empty rn but should fill up
+
quickly enough!
+
</p>
+
+
<div class="card">
+
<h3>Community Verifications</h3>
+
<p>
+
This tool allows you to designate trusted verifiers in
+
the Bluesky ecosystem. When these trusted accounts
+
verify other users, you'll see verification badges next
+
to those profiles while browsing Bluesky.
+
</p>
+
<a
+
href="https://tangled.sh/@dunkirk.sh/serif/raw/main/bluesky-community-verifications.user.js"
+
class="btn"
+
>Install with Tampermonkey</a
+
>
+
</div>
+
+
<div class="card">
+
<h3>
+
Dynamic bluesky (and various assorted services) pfps!
+
</h3>
+
<p>
+
I made this inspired by
+
<a
+
href="https://bsky.app/profile/did:plc:gq4fo3u6tqzzdkjlwzpb23tj"
+
>@dame.is</a
+
>'s (dame.is's sounds hilarious lol) profile picture
+
which changes with a sky gradient every hour. I wanted
+
to do something similar but my profile picture has me in
+
the foreground so I had to do some masking shenanagins
+
to get it to work.
+
</p>
+
<p>
+
Anyway if you want to set this up for yourself then grab
+
a background removed version of your profile from
+
<a href="https://remove.bg">remove.bg</a> (low res
+
preview version is fine since this will just be a mask)
+
and then run
+
<code
+
>magick pfp-removebg-preview.png -alpha extract
+
pfp_matte.png</code
+
>. Now you can head over to the timeline site linked
+
below and customize your timeline! When you are done
+
simply download the zip and extract it wherever you want
+
it to live. Then <code>crontab -e</code> and add your
+
script (<code
+
>2 * * * * /home/usrname/pfp/bsky-pfp-updates.sh
+
>/dev/null 2>&1</code
+
>) to run 2 minutes after the hour (or at really
+
whatever time you want)!
+
</p>
+
<p>
+
btw you need to make the script executable and then run
+
it once manually to generate the config
+
</p>
+
<a href="/pfp-updates" class="btn"
+
>Customize your gradients!</a
+
>
+
</div>
+
+
<div class="card">
+
<h3>More things soon?</h3>
+
<p>
+
Yeah probably lol; I just need to find the right next
+
project 🤔
+
</p>
+
</div>
+
</main>
+
</div>
+
+
<footer>
+
<p>
+
Made with 💙 by <a href="https://dunkirk.sh">Kieran Klukas</a> |
+
<a href="https://bsky.app/profile/dunkirk.sh">
+
@dunkirk.sh on Bluesky
+
</a>
+
</p>
+
</footer>
+
</body>
+
</html>
site/og.png

This is a binary file and will not be displayed.

+204
site/og.svg
···
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+
<svg
+
version="1.1"
+
width="1200"
+
height="630"
+
id="svg22"
+
sodipodi:docname="og.svg"
+
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
+
inkscape:export-filename="og.png"
+
inkscape:export-xdpi="96"
+
inkscape:export-ydpi="96"
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+
xmlns="http://www.w3.org/2000/svg"
+
xmlns:svg="http://www.w3.org/2000/svg">
+
<defs
+
id="defs22">
+
<rect
+
x="309.78151"
+
y="154.89076"
+
width="787.36134"
+
height="260.30252"
+
id="rect1" />
+
<rect
+
x="309.78152"
+
y="154.89076"
+
width="787.36133"
+
height="260.30252"
+
id="rect1-9" />
+
</defs>
+
<sodipodi:namedview
+
id="namedview22"
+
pagecolor="#ffffff"
+
bordercolor="#000000"
+
borderopacity="0.25"
+
inkscape:showpageshadow="2"
+
inkscape:pageopacity="0.0"
+
inkscape:pagecheckerboard="0"
+
inkscape:deskcolor="#d1d1d1"
+
inkscape:zoom="0.9296875"
+
inkscape:cx="571.15966"
+
inkscape:cy="248.47059"
+
inkscape:window-width="2256"
+
inkscape:window-height="1504"
+
inkscape:window-x="0"
+
inkscape:window-y="0"
+
inkscape:window-maximized="1"
+
inkscape:current-layer="svg21" />
+
<svg
+
viewBox="0 0 128 128"
+
version="1.1"
+
id="svg21"
+
width="100%"
+
height="100%"
+
transform="translate(536)">
+
<defs
+
id="defs1" />
+
<rect
+
style="fill:#b7e2ff;stroke:#9fb4de;stroke-width:0.212711"
+
id="rect22"
+
width="243.5968"
+
height="127.78729"
+
x="-57.798409"
+
y="0.10635584"
+
sodipodi:insensitive="true" />
+
<path
+
d="m -23.077937,6.6686188 c -0.595,-0.5950003 -1.3875,-0.9225003 -2.2275,-0.9225003 -0.84,0 -1.6325,0.3275 -2.2275,0.9225003 -1.2275,1.2300002 -1.2275,3.2300002 0,4.4600002 0.595,0.595 1.3875,0.9225 2.2275,0.9225 0.8425,0 1.6325,-0.3275 2.23,-0.9225 1.2275,-1.23 1.2275,-3.23 -0.002,-4.4600002 z m -3.4725,0.9850002 c 0.3325,-0.3325002 0.775,-0.5150002 1.2425,-0.5150002 0.4675,0 0.9125,0.1825 1.245,0.5150002 0.6875,0.6875 0.6875,1.805 0,2.49 -0.3325,0.3325 -0.775,0.515 -1.245,0.515 -0.47,0 -0.9125,-0.1825 -1.2425,-0.515 -0.685,-0.685 -0.685,-1.8025 0,-2.49 z"
+
fill="#e2a610"
+
id="path1"
+
style="stroke-width:0.25" />
+
<path
+
d="m -26.767937,12.483619 -2.1175,-2.12 1.51,-1.5125 c 0.335,-0.335 0.8775,-0.335 1.215,0 l 0.905,0.905 c 0.335,0.335 0.335,0.8775 0,1.2125 z"
+
fill="#ffca28"
+
id="path2"
+
style="stroke-width:0.25" />
+
<path
+
d="m -26.222937,11.936119 c 0,0 0.3325,-0.6675 -0.56,-1.56 -0.8925,-0.8925 -1.5675,-0.55 -1.5675,-0.55 l -0.535,0.5375 2.1175,2.12 z"
+
fill="#e2a610"
+
id="path3"
+
style="stroke-width:0.25" />
+
<path
+
d="m -22.970437,21.153619 a 14.5825,14.5925 0 1 1 -29.165,0 14.5825,14.5925 0 1 1 29.165,0 z"
+
fill="#ffca28"
+
id="path4"
+
style="stroke-width:0.25" />
+
<path
+
d="m -25.730437,21.153619 a 11.8225,11.83 0 1 1 -23.645,0 11.8225,11.83 0 1 1 23.645,0 z"
+
fill="#ffffff"
+
id="path5"
+
style="stroke-width:0.25" />
+
<path
+
d="m -35.950437,22.758619 -1.6025,6.9275 -1.6025,-6.9275 -6.925,-1.605 6.925,-1.6025 1.6025,-6.93 1.6025,6.93 6.925,1.6025 z"
+
fill="#e0c3ab"
+
id="path6"
+
style="stroke-width:0.25" />
+
<path
+
d="m -35.287937,21.153619 3.765,6.035 -6.03,-3.7675 -6.03,3.7675 3.765,-6.035 -3.765,-6.0325 6.03,3.765 6.03,-3.765 z"
+
fill="#b2947c"
+
id="path7"
+
style="stroke-width:0.25" />
+
<path
+
d="m -40.532937,26.481119 c 0,0 3.205,-2.2725 4.42,-3.4875 1.2175,-1.2125 3.7825,-4.78 3.7825,-4.78 0.6875,-0.7625 1.46,-2.69 1.675,-3.25 0.0225,-0.06 -0.035,-0.12 -0.095,-0.0975 -0.49,0.1875 -2.0425,0.8 -3.125,1.49 -1.3525,0.865 -3.7725,2.8425 -4.9,3.97 -1.1275,1.1275 -2.5925,3.3 -3.425,4.4825 -0.695,0.99 -1.565,2.83 -1.8275,3.4025 -0.03,0.0625 0.035,0.1275 0.0975,0.1 0.59,-0.2475 2.5025,-1.09 3.3975,-1.83 z"
+
fill="#212121"
+
opacity="0.32"
+
id="path8"
+
style="stroke-width:0.25" />
+
<path
+
d="m -32.480437,17.861119 c 0.715,-0.9125 1.3925,-2.71 1.5925,-3.2675 0.025,-0.0675 -0.04,-0.1325 -0.11,-0.11 -0.5575,0.2 -2.3525,0.88 -3.265,1.595 -1.0875,0.8525 -4.705,3.825 -4.705,3.825 l 2.6625,2.665 c 0.002,0 2.9725,-3.62 3.825,-4.7075 z"
+
fill="#f44336"
+
id="path9"
+
style="stroke-width:0.25" />
+
<path
+
d="m -42.792937,24.616119 c -0.715,0.9125 -1.3925,2.71 -1.5925,3.2675 -0.025,0.0675 0.04,0.1325 0.11,0.11 0.5575,-0.2 2.3525,-0.88 3.265,-1.595 1.0875,-0.8525 4.705,-3.825 4.705,-3.825 l -2.665,-2.665 c 0,0 -2.97,3.6175 -3.8225,4.7075 z"
+
fill="#2f7889"
+
id="path10"
+
style="stroke-width:0.25" />
+
<path
+
d="m -35.747937,21.241119 a 1.89,1.8925 0 1 1 -3.78,0 1.89,1.8925 0 1 1 3.78,0 z"
+
fill="#94d1e0"
+
id="path11"
+
style="stroke-width:0.25" />
+
<path
+
d="m -30.530437,14.126119 v 0 c -0.335,-0.335 -0.335,-0.88 0,-1.215 l 0.89,-0.89 1.215,1.215 -0.8875,0.89 c -0.3375,0.3375 -0.8825,0.3375 -1.2175,0 z"
+
fill="#f44336"
+
id="path12"
+
style="stroke-width:0.25" />
+
<path
+
d="m -44.575437,28.181119 v 0 c 0.335,0.335 0.335,0.88 0,1.215 l -0.89,0.89 -1.215,-1.215 0.8875,-0.89 c 0.3375,-0.335 0.8825,-0.335 1.2175,0 z"
+
fill="#b2947c"
+
id="path13"
+
style="stroke-width:0.25" />
+
<path
+
d="m -27.620437,21.153619 v 0 c 0,-0.475 0.385,-0.86 0.86,-0.86 h 1.255 v 1.72 h -1.255 c -0.475,0 -0.86,-0.385 -0.86,-0.86 z"
+
fill="#e0c3ab"
+
id="path14"
+
style="stroke-width:0.25" />
+
<path
+
d="m -47.482937,21.153619 v 0 c 0,0.475 -0.385,0.86 -0.86,0.86 h -1.255 v -1.72 h 1.255 c 0.475,0 0.86,0.385 0.86,0.86 z"
+
fill="#e0c3ab"
+
id="path15"
+
style="stroke-width:0.25" />
+
<path
+
d="m -30.530437,28.181119 v 0 c 0.335,-0.335 0.88,-0.335 1.215,0 l 0.8875,0.89 -1.215,1.215 -0.89,-0.89 c -0.3325,-0.335 -0.3325,-0.8775 0.002,-1.215 z"
+
fill="#b2947c"
+
id="path16"
+
style="stroke-width:0.25" />
+
<path
+
d="m -44.575437,14.126119 v 0 c -0.335,0.335 -0.88,0.335 -1.215,0 l -0.8875,-0.89 1.215,-1.215 0.89,0.89 c 0.3325,0.335 0.3325,0.88 -0.002,1.215 z"
+
fill="#b2947c"
+
id="path17"
+
style="stroke-width:0.25" />
+
<path
+
d="m -37.552937,31.091119 v 0 c 0.475,0 0.86,0.385 0.86,0.86 v 1.2575 h -1.7175 v -1.2575 c -0.002,-0.475 0.3825,-0.86 0.8575,-0.86 z"
+
fill="#e0c3ab"
+
id="path18"
+
style="stroke-width:0.25" />
+
<path
+
d="m -37.552937,11.216119 v 0 c -0.475,0 -0.86,-0.385 -0.86,-0.86 v -1.2575 h 1.7175 v 1.2575 c 0.002,0.475 -0.3825,0.86 -0.8575,0.86 z"
+
fill="#e0c3ab"
+
id="path19"
+
style="stroke-width:0.25" />
+
<path
+
d="m -25.355437,21.153619 q 0,5.0555 -3.5725,8.63025 -3.5725,3.57475 -8.625,3.57475 -5.0525,0 -8.625,-3.57475 -3.5725,-3.57475 -3.5725,-8.63025 0,-5.0555 3.5725,-8.63025 3.5725,-3.57475 8.625,-3.57475 5.0525,0 8.625,3.57475 3.5725,3.57475 3.5725,8.63025 z m -0.75,0 q 0,-4.745 -3.353,-8.1 -3.35275,-3.355 -8.0945,-3.355 -4.74175,0 -8.0945,3.355 -3.353,3.355 -3.353,8.1 0,4.74475 3.353,8.1 3.35275,3.355 8.0945,3.355 4.74175,0 8.0945,-3.355 3.353,-3.35525 3.353,-8.1 z"
+
fill="#e2a610"
+
id="path20"
+
style="stroke-width:0.25" />
+
<path
+
d="m -50.965437,20.976119 c 0,0 -0.53,-2.685 0.8825,-6.01 2.085,-4.905 6.5875,-7.6050002 11.43,-7.8150002 3.6325,-0.1575 5.7275,1.0400002 5.7275,1.0400002 0,0 -2.955,-0.2425 -4.9925,-0.1525 -4.915,0.2175 -8.33,2.46 -10.67,6.295 -1.925,3.16 -2.3775,6.6425 -2.3775,6.6425 z"
+
fill="#fff59d"
+
id="path21"
+
style="stroke-width:0.25" />
+
<text
+
xml:space="preserve"
+
transform="matrix(0.20317468,0,0,0.2031746,-79.435012,10.708496)"
+
id="text1"
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:146.667px;font-family:Serif;-inkscape-font-specification:'Serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:center;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect1);display:inline;fill:#6f73d2;fill-opacity:1;stroke:none;stroke-width:0.895748"
+
x="358.14536"
+
y="0"><tspan
+
x="342.45196"
+
y="284.65655"
+
id="tspan4">Serif.blue</tspan></text>
+
<text
+
xml:space="preserve"
+
transform="matrix(0.20317449,0,0,0.2031746,-79.065988,41.636014)"
+
id="text1-5"
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:48px;font-family:Serif;-inkscape-font-specification:'Serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect1-9);display:inline;fill:#6f73d2;fill-opacity:1;stroke:none;stroke-width:0.895748"
+
x="323.03906"
+
y="0"><tspan
+
x="379.77832"
+
y="197.35938"
+
id="tspan6"><tspan
+
style="text-align:center;text-anchor:middle"
+
id="tspan5">little tweaks, projects, and </tspan></tspan><tspan
+
x="428.3877"
+
y="257.35938"
+
id="tspan10"><tspan
+
style="text-align:center;text-anchor:middle"
+
id="tspan7">userscripts for atproto</tspan></tspan></text>
+
</svg>
+
<style
+
id="style21">@media (prefers-color-scheme: light) { :root { filter: none; } }
+
@media (prefers-color-scheme: dark) { :root { filter: none; } }
+
</style>
+
</svg>
+1225
site/pfp-updates/bsky-pfp-updates.sh
···
+
#!/usr/bin/env bash
+
+
# Dynamic Profile Picture Updater
+
# Automatically updates your profile pictures across platforms based on time and weather
+
# Usage: ./auto_pfp.sh [options]
+
+
set -euo pipefail
+
+
# Default configuration
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
CONFIG_FILE="${SCRIPT_DIR}/config.json"
+
IMAGES_DIR="${SCRIPT_DIR}/rendered_timelines"
+
LOG_FILE="${SCRIPT_DIR}/auto_pfp.log"
+
SESSION_FILE="${SCRIPT_DIR}/.bluesky_session"
+
DEFAULT_TIMELINE="sunny"
+
+
# Colors for output
+
RED='\033[0;31m'
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
BLUE='\033[0;34m'
+
NC='\033[0m' # No Color
+
+
# Logging function
+
log() {
+
local level="$1"
+
shift
+
local message="$*"
+
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
+
echo -e "${timestamp} - ${level} - ${message}" | tee -a "$LOG_FILE" >&2
+
}
+
+
log_info() { log "${BLUE}[INFO]${NC}" "$@"; }
+
log_success() { log "${GREEN}[SUCCESS]${NC}" "$@"; }
+
log_warning() { log "${YELLOW}[WARNING]${NC}" "$@"; }
+
log_error() { log "${RED}[ERROR]${NC}" "$@"; }
+
+
# Check dependencies
+
check_dependencies() {
+
local missing_deps=()
+
+
if ! command -v curl &> /dev/null; then
+
missing_deps+=("curl")
+
fi
+
+
if ! command -v jq &> /dev/null; then
+
missing_deps+=("jq")
+
fi
+
+
if ! command -v sha256sum &> /dev/null; then
+
missing_deps+=("sha256sum")
+
fi
+
+
if [ ${#missing_deps[@]} -ne 0 ]; then
+
log_error "Missing required dependencies:"
+
for dep in "${missing_deps[@]}"; do
+
echo " - $dep"
+
done
+
exit 1
+
fi
+
}
+
+
# Create default config file
+
create_default_config() {
+
cat > "$CONFIG_FILE" << 'EOF'
+
{
+
"platforms": {
+
"bluesky": {
+
"enabled": true,
+
"handle": "your-handle.bsky.social",
+
"password": "your-app-password"
+
},
+
"slack": {
+
"enabled": false,
+
"user_token": ""
+
}
+
},
+
"weather": {
+
"enabled": false,
+
"api_key": "",
+
"location": "auto",
+
"timeline_mapping": {
+
"clear": "sunny",
+
"clouds": "cloudy",
+
"rain": "rainy",
+
"drizzle": "rainy",
+
"thunderstorm": "stormy",
+
"snow": "snowy",
+
"mist": "cloudy",
+
"fog": "cloudy"
+
}
+
},
+
"settings": {
+
"default_timeline": "sunny",
+
"images_dir": "./rendered_timelines"
+
}
+
}
+
EOF
+
log_info "Created default config file: $CONFIG_FILE"
+
log_warning "Please edit the config file with your platform credentials"
+
}
+
+
# Load configuration
+
load_config() {
+
if [ ! -f "$CONFIG_FILE" ]; then
+
log_warning "Config file not found, creating default..."
+
create_default_config
+
exit 1
+
fi
+
+
# Read platform settings
+
BLUESKY_ENABLED=$(jq -r '.platforms.bluesky.enabled' "$CONFIG_FILE")
+
BLUESKY_HANDLE=$(jq -r '.platforms.bluesky.handle' "$CONFIG_FILE")
+
BLUESKY_PASSWORD=$(jq -r '.platforms.bluesky.password' "$CONFIG_FILE")
+
SLACK_ENABLED=$(jq -r '.platforms.slack.enabled' "$CONFIG_FILE")
+
SLACK_USER_TOKEN=$(jq -r '.platforms.slack.user_token' "$CONFIG_FILE")
+
+
# Read weather settings
+
WEATHER_ENABLED=$(jq -r '.weather.enabled' "$CONFIG_FILE")
+
WEATHER_API_KEY=$(jq -r '.weather.api_key' "$CONFIG_FILE")
+
WEATHER_LOCATION=$(jq -r '.weather.location' "$CONFIG_FILE")
+
+
# Read general settings
+
DEFAULT_TIMELINE=$(jq -r '.settings.default_timeline' "$CONFIG_FILE")
+
IMAGES_DIR=$(jq -r '.settings.images_dir' "$CONFIG_FILE")
+
+
# Validate at least one platform is enabled and configured
+
local enabled_platforms=()
+
+
# Check Bluesky
+
if [ "$BLUESKY_ENABLED" = "true" ]; then
+
if [ "$BLUESKY_HANDLE" = "your-handle.bsky.social" ] || [ "$BLUESKY_HANDLE" = "null" ] || [ -z "$BLUESKY_HANDLE" ]; then
+
log_warning "Bluesky enabled but handle not configured - will be skipped"
+
BLUESKY_ENABLED="false"
+
elif [ "$BLUESKY_PASSWORD" = "your-app-password" ] || [ "$BLUESKY_PASSWORD" = "null" ] || [ -z "$BLUESKY_PASSWORD" ]; then
+
log_warning "Bluesky enabled but password not configured - will be skipped"
+
BLUESKY_ENABLED="false"
+
else
+
enabled_platforms+=("Bluesky")
+
fi
+
fi
+
+
# Check Slack
+
if [ "$SLACK_ENABLED" = "true" ]; then
+
if [ -z "$SLACK_USER_TOKEN" ] || [ "$SLACK_USER_TOKEN" = "null" ]; then
+
log_warning "Slack enabled but no user token provided - will be skipped"
+
SLACK_ENABLED="false"
+
else
+
enabled_platforms+=("Slack")
+
fi
+
fi
+
+
# Ensure at least one platform is enabled
+
if [ ${#enabled_platforms[@]} -eq 0 ]; then
+
log_error "No platforms are properly configured. Please check your config file."
+
exit 1
+
fi
+
+
# Convert relative paths to absolute
+
if [[ ! "$IMAGES_DIR" =~ ^/ ]]; then
+
IMAGES_DIR="${SCRIPT_DIR}/${IMAGES_DIR}"
+
fi
+
+
log_info "Loaded configuration with enabled platforms: ${enabled_platforms[*]}"
+
}
+
+
# Authenticate with Bluesky
+
authenticate_bluesky() {
+
if [ "$BLUESKY_ENABLED" != "true" ]; then
+
return 1
+
fi
+
+
log_info "Authenticating with Bluesky..."
+
+
local auth_response
+
auth_response=$(curl -s -X POST \
+
"https://bsky.social/xrpc/com.atproto.server.createSession" \
+
-H "Content-Type: application/json" \
+
-d "{\"identifier\":\"$BLUESKY_HANDLE\",\"password\":\"$BLUESKY_PASSWORD\"}")
+
+
if echo "$auth_response" | jq -e '.accessJwt' > /dev/null 2>&1; then
+
echo "$auth_response" > "$SESSION_FILE"
+
log_success "Successfully authenticated with Bluesky"
+
return 0
+
else
+
log_error "Bluesky authentication failed: $(echo "$auth_response" | jq -r '.message // "Unknown error"')"
+
return 1
+
fi
+
}
+
+
# Get session token
+
get_session_token() {
+
if [ ! -f "$SESSION_FILE" ]; then
+
return 1
+
fi
+
+
# Check if session is still valid (sessions typically last 24 hours)
+
local session_age=$(($(date +%s) - $(stat -c %Y "$SESSION_FILE" 2>/dev/null || echo 0)))
+
if [ $session_age -gt 86400 ]; then # 24 hours
+
log_info "Session expired, re-authenticating..."
+
rm -f "$SESSION_FILE"
+
return 1
+
fi
+
+
jq -r '.accessJwt' "$SESSION_FILE" 2>/dev/null || return 1
+
}
+
+
# Calculate SHA256 hash of image file
+
calculate_image_hash() {
+
local image_path="$1"
+
if [ ! -f "$image_path" ]; then
+
return 1
+
fi
+
sha256sum "$image_path" | cut -d' ' -f1
+
}
+
+
# Get blob reference from ATProto record
+
get_cached_blob() {
+
local weather_type="$1"
+
local hour="$2"
+
local image_hash="$3"
+
local token="$4"
+
local did
+
+
did=$(jq -r '.did' "$SESSION_FILE")
+
local rkey="${weather_type}_hour_${hour}"
+
+
# Validate DID
+
if [ -z "$did" ] || [ "$did" = "null" ]; then
+
log_error "Could not get DID from session file"
+
return 1
+
fi
+
+
log_info "Checking for cached blob: $weather_type hour $hour (DID: ${did:0:20}...)"
+
+
# Try to get existing record
+
local record_response
+
record_response=$(curl -s \
+
"https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=$did&collection=pfp.updates.${weather_type}&rkey=$rkey" \
+
-H "Authorization: Bearer $token")
+
+
if echo "$record_response" | jq -e '.value' > /dev/null 2>&1; then
+
local stored_hash=$(echo "$record_response" | jq -r '.value.imageHash // empty')
+
local stored_blob=$(echo "$record_response" | jq -c '.value.blobRef // empty')
+
+
if [ "$stored_hash" = "$image_hash" ] && [ -n "$stored_blob" ] && [ "$stored_blob" != "empty" ]; then
+
log_success "Found cached blob with matching hash"
+
echo "$stored_blob"
+
return 0
+
else
+
log_info "Cached blob found but hash mismatch or missing blob reference"
+
return 1
+
fi
+
else
+
log_info "No cached blob record found"
+
return 1
+
fi
+
}
+
+
# Store blob reference in ATProto record
+
store_blob_reference() {
+
local weather_type="$1"
+
local hour="$2"
+
local image_hash="$3"
+
local blob_ref="$4"
+
local token="$5"
+
local did
+
+
did=$(jq -r '.did' "$SESSION_FILE")
+
local rkey="${weather_type}_hour_${hour}"
+
+
# Validate DID
+
if [ -z "$did" ] || [ "$did" = "null" ]; then
+
log_error "Could not get DID from session file"
+
return 1
+
fi
+
+
log_info "Storing blob reference for $weather_type hour $hour (DID: ${did:0:20}...)"
+
+
# Create record data
+
local record_data
+
record_data=$(jq -n \
+
--arg type_field "pfp.updates.${weather_type}" \
+
--arg hash "$image_hash" \
+
--argjson blob "$blob_ref" \
+
--arg weather "$weather_type" \
+
--arg hour "$hour" \
+
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)" \
+
'{
+
"$type": $type_field,
+
"timeline": $weather,
+
"hour": $hour,
+
"imageHash": $hash,
+
"blobRef": $blob,
+
"createdAt": $timestamp
+
}')
+
+
log_info "Record data: $record_data"
+
+
# Create the request payload using direct JSON construction
+
local request_payload
+
request_payload=$(jq -n \
+
--arg repo "$did" \
+
--arg collection "pfp.updates.${weather_type}" \
+
--arg rkey "$rkey" \
+
--argjson record "$record_data" \
+
'{
+
"repo": $repo,
+
"collection": $collection,
+
"rkey": $rkey,
+
"record": $record
+
}')
+
+
log_info "Request payload preview: $(echo "$request_payload" | jq -c '.' | head -c 200)..."
+
+
# Validate the payload has required fields
+
if ! echo "$request_payload" | jq -e '.repo' > /dev/null 2>&1; then
+
log_error "Request payload missing 'repo' field"
+
log_error "DID value: '$did'"
+
log_error "Full payload: $request_payload"
+
return 1
+
fi
+
+
# In dry run mode, don't actually store
+
if [ "${DRY_RUN:-false}" = "true" ]; then
+
log_info "DRY RUN MODE - Would store blob reference"
+
return 0
+
fi
+
+
# Store the record
+
local store_response
+
store_response=$(curl -s -X POST \
+
"https://bsky.social/xrpc/com.atproto.repo.putRecord" \
+
-H "Authorization: Bearer $token" \
+
-H "Content-Type: application/json" \
+
-d "$request_payload")
+
+
if echo "$store_response" | jq -e '.uri' > /dev/null 2>&1; then
+
log_success "Successfully stored blob reference"
+
return 0
+
else
+
log_error "Failed to store blob reference: $(echo "$store_response" | jq -r '.message // "Unknown error"')"
+
log_error "Full response: $store_response"
+
return 1
+
fi
+
}
+
+
# Upload image as blob
+
upload_blob() {
+
local image_path="$1"
+
local token="$2"
+
+
if [ ! -f "$image_path" ]; then
+
log_error "Image file not found: $image_path"
+
return 1
+
fi
+
+
# Check image file size (should be reasonable)
+
local file_size=$(stat -c%s "$image_path" 2>/dev/null || echo 0)
+
if [ "$file_size" -lt 1000 ]; then
+
log_error "Image file too small ($file_size bytes): $image_path"
+
return 1
+
fi
+
+
if [ "$file_size" -gt 10000000 ]; then # 10MB limit
+
log_error "Image file too large ($file_size bytes): $image_path"
+
return 1
+
fi
+
+
log_info "Uploading image: $(basename "$image_path") ($(numfmt --to=iec "$file_size"))"
+
+
local upload_response
+
upload_response=$(curl -s -X POST \
+
"https://bsky.social/xrpc/com.atproto.repo.uploadBlob" \
+
-H "Authorization: Bearer $token" \
+
-H "Content-Type: image/jpeg" \
+
--data-binary "@$image_path")
+
+
if echo "$upload_response" | jq -e '.blob' > /dev/null 2>&1; then
+
local blob_result
+
blob_result=$(echo "$upload_response" | jq -c '.blob')
+
log_success "Successfully uploaded image"
+
log_info "Blob data: $blob_result"
+
echo "$blob_result"
+
return 0
+
else
+
local error_type=$(echo "$upload_response" | jq -r '.error // "Unknown"')
+
local error_msg=$(echo "$upload_response" | jq -r '.message // "Unknown error"')
+
+
if [ "$error_type" = "ExpiredToken" ] || echo "$error_msg" | grep -qi "expired"; then
+
log_error "Token has expired - session needs refresh"
+
# Remove the expired session file so it will be regenerated
+
rm -f "$SESSION_FILE"
+
return 2 # Special return code for expired token
+
else
+
log_error "Failed to upload image: $error_msg"
+
log_error "Full response: $upload_response"
+
return 1
+
fi
+
fi
+
}
+
+
# Get or upload blob with caching
+
get_or_upload_blob() {
+
local image_path="$1"
+
local weather_type="$2"
+
local hour="$3"
+
local token="$4"
+
+
if [ ! -f "$image_path" ]; then
+
log_error "Image file not found: $image_path"
+
return 1
+
fi
+
+
# Calculate image hash
+
local image_hash
+
image_hash=$(calculate_image_hash "$image_path")
+
if [ -z "$image_hash" ]; then
+
log_error "Failed to calculate image hash"
+
return 1
+
fi
+
+
log_info "Image hash: $image_hash"
+
+
# Try to get cached blob
+
local cached_blob
+
if cached_blob=$(get_cached_blob "$weather_type" "$hour" "$image_hash" "$token"); then
+
log_success "Using cached blob reference"
+
echo "$cached_blob"
+
return 0
+
fi
+
+
# No cache hit, upload the blob
+
log_info "No valid cache found, uploading new blob..."
+
local new_blob
+
new_blob=$(upload_blob "$image_path" "$token")
+
+
if [ -n "$new_blob" ]; then
+
# Store the blob reference for future use
+
if store_blob_reference "$weather_type" "$hour" "$image_hash" "$new_blob" "$token"; then
+
log_success "Blob uploaded and cached successfully"
+
else
+
log_warning "Blob uploaded but failed to cache reference"
+
fi
+
echo "$new_blob"
+
return 0
+
else
+
log_error "Failed to upload blob"
+
return 1
+
fi
+
}
+
+
# List all cached blobs
+
list_cached_blobs() {
+
local token="$1"
+
local did
+
+
did=$(jq -r '.did' "$SESSION_FILE")
+
+
log_info "Listing cached blob records..."
+
+
# Get list of available timelines to check each collection
+
local timelines=()
+
if [ -d "$IMAGES_DIR" ]; then
+
for timeline_dir in "$IMAGES_DIR"/*; do
+
if [ -d "$timeline_dir" ]; then
+
timelines+=($(basename "$timeline_dir"))
+
fi
+
done
+
fi
+
+
local total_records=0
+
echo "Cached blob records:"
+
+
for timeline in "${timelines[@]}"; do
+
# List records in each timeline collection
+
local list_response
+
list_response=$(curl -s \
+
"https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=$did&collection=pfp.updates.${timeline}" \
+
-H "Authorization: Bearer $token" 2>/dev/null)
+
+
if echo "$list_response" | jq -e '.records' > /dev/null 2>&1; then
+
local timeline_count=$(echo "$list_response" | jq '.records | length')
+
if [ "$timeline_count" -gt 0 ]; then
+
echo " $timeline timeline:"
+
echo "$list_response" | jq -r '.records[] | " hour \(.value.hour) - Hash: \(.value.imageHash[0:12])... (Created: \(.value.createdAt))"'
+
total_records=$((total_records + timeline_count))
+
fi
+
fi
+
done
+
+
echo "Total cached records: $total_records"
+
}
+
+
# Clean up old cached blobs (optional maintenance function)
+
cleanup_cached_blobs() {
+
local token="$1"
+
local days_to_keep="${2:-30}" # Keep records for 30 days by default
+
local did
+
+
did=$(jq -r '.did' "$SESSION_FILE")
+
+
log_info "Cleaning up cached blobs older than $days_to_keep days..."
+
+
# Get current timestamp minus retention period
+
local cutoff_date
+
cutoff_date=$(date -u -d "$days_to_keep days ago" +%Y-%m-%dT%H:%M:%S.%3NZ)
+
+
# Get list of available timelines to check each collection
+
local timelines=()
+
if [ -d "$IMAGES_DIR" ]; then
+
for timeline_dir in "$IMAGES_DIR"/*; do
+
if [ -d "$timeline_dir" ]; then
+
timelines+=($(basename "$timeline_dir"))
+
fi
+
done
+
fi
+
+
local deleted_count=0
+
+
for timeline in "${timelines[@]}"; do
+
# List all records in this timeline collection
+
local list_response
+
list_response=$(curl -s \
+
"https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=$did&collection=pfp.updates.${timeline}" \
+
-H "Authorization: Bearer $token" 2>/dev/null)
+
+
if echo "$list_response" | jq -e '.records' > /dev/null 2>&1; then
+
# Find records older than cutoff
+
local old_records
+
old_records=$(echo "$list_response" | jq --arg cutoff "$cutoff_date" '.records[] | select(.value.createdAt < $cutoff)')
+
+
if [ -n "$old_records" ]; then
+
echo "$old_records" | jq -r '.uri' | while read -r record_uri; do
+
local rkey=$(echo "$record_uri" | sed 's/.*\///')
+
log_info "Deleting old record: $timeline/$rkey"
+
+
curl -s -X POST \
+
"https://bsky.social/xrpc/com.atproto.repo.deleteRecord" \
+
-H "Authorization: Bearer $token" \
+
-H "Content-Type: application/json" \
+
-d "{\"repo\":\"$did\",\"collection\":\"pfp.updates.${timeline}\",\"rkey\":\"$rkey\"}" > /dev/null
+
+
deleted_count=$((deleted_count + 1))
+
done
+
fi
+
fi
+
done
+
+
if [ "$deleted_count" -eq 0 ]; then
+
log_info "No old records found to clean up"
+
else
+
log_success "Deleted $deleted_count old records"
+
fi
+
}
+
+
# Update profile picture
+
update_profile_picture() {
+
local blob_ref="$1"
+
local token="$2"
+
local did
+
+
# Validate blob reference
+
if [ -z "$blob_ref" ] || [ "$blob_ref" = "null" ]; then
+
log_error "Invalid blob reference provided"
+
return 1
+
fi
+
+
# Validate blob reference format
+
if ! echo "$blob_ref" | jq -e '.ref' > /dev/null 2>&1; then
+
log_error "Blob reference missing required 'ref' field: $blob_ref"
+
return 1
+
fi
+
+
# Get DID from session
+
did=$(jq -r '.did' "$SESSION_FILE")
+
+
log_info "Updating profile picture..."
+
log_info "Using blob: $blob_ref"
+
+
# Get current profile
+
local current_profile
+
current_profile=$(curl -s \
+
"https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=$did&collection=app.bsky.actor.profile&rkey=self" \
+
-H "Authorization: Bearer $token")
+
+
log_info "Current profile response: $current_profile"
+
+
local profile_data
+
if echo "$current_profile" | jq -e '.value' > /dev/null 2>&1; then
+
# Update existing profile - PRESERVE ALL EXISTING FIELDS
+
profile_data=$(echo "$current_profile" | jq --argjson avatar "$blob_ref" '.value | .avatar = $avatar')
+
log_info "Updating existing profile (preserving existing fields)"
+
else
+
log_error "No existing profile found - cannot safely create new profile"
+
log_error "Please manually restore your profile in the Bluesky app first"
+
return 1
+
fi
+
+
log_info "Profile data to send: $profile_data"
+
+
# Validate profile data before sending
+
if ! echo "$profile_data" | jq -e '.avatar' > /dev/null 2>&1; then
+
log_error "Generated profile data is invalid"
+
return 1
+
fi
+
+
# Double-check we're preserving important fields
+
local display_name=$(echo "$profile_data" | jq -r '.displayName // empty')
+
local description=$(echo "$profile_data" | jq -r '.description // empty')
+
+
if [ -n "$display_name" ]; then
+
log_info "Preserving display name: $display_name"
+
fi
+
+
if [ -n "$description" ]; then
+
log_info "Preserving description: $(echo "$description" | head -c 50)..."
+
fi
+
+
# Create the request payload
+
local request_payload
+
request_payload=$(jq -n \
+
--arg repo "$did" \
+
--arg collection "app.bsky.actor.profile" \
+
--arg rkey "self" \
+
--argjson record "$profile_data" \
+
'{repo: $repo, collection: $collection, rkey: $rkey, record: $record}')
+
+
log_info "Request payload: $request_payload"
+
+
# In dry run mode, don't actually update
+
if [ "${DRY_RUN:-false}" = "true" ]; then
+
log_info "DRY RUN MODE - Would send profile update with avatar"
+
log_info "DRY RUN MODE - Profile fields would be preserved"
+
return 0
+
fi
+
+
# Update profile
+
local update_response
+
update_response=$(curl -s -X POST \
+
"https://bsky.social/xrpc/com.atproto.repo.putRecord" \
+
-H "Authorization: Bearer $token" \
+
-H "Content-Type: application/json" \
+
-d "$request_payload")
+
+
log_info "Update response: $update_response"
+
+
if echo "$update_response" | jq -e '.uri' > /dev/null 2>&1; then
+
log_success "Successfully updated profile picture"
+
return 0
+
else
+
log_error "Failed to update profile picture: $(echo "$update_response" | jq -r '.message // "Unknown error"')"
+
log_error "Full error response: $update_response"
+
return 1
+
fi
+
}
+
+
# Update Slack profile picture
+
update_slack_profile_picture() {
+
local image_path="$1"
+
+
if [ "$SLACK_ENABLED" != "true" ] || [ -z "$SLACK_USER_TOKEN" ] || [ "$SLACK_USER_TOKEN" = "null" ]; then
+
log_info "Slack integration disabled or not configured"
+
return 0
+
fi
+
+
if [ ! -f "$image_path" ]; then
+
log_error "Image file not found for Slack: $image_path"
+
return 1
+
fi
+
+
log_info "Updating Slack profile picture..."
+
+
# First, upload the image to Slack
+
local upload_response
+
upload_response=$(curl -s -X POST \
+
"https://slack.com/api/users.setPhoto" \
+
-H "Authorization: Bearer $SLACK_USER_TOKEN" \
+
-F "image=@$image_path")
+
+
if echo "$upload_response" | jq -e '.ok' > /dev/null 2>&1; then
+
local ok_status=$(echo "$upload_response" | jq -r '.ok')
+
if [ "$ok_status" = "true" ]; then
+
log_success "Successfully updated Slack profile picture"
+
return 0
+
else
+
local error_msg=$(echo "$upload_response" | jq -r '.error // "Unknown error"')
+
log_error "Failed to update Slack profile picture: $error_msg"
+
+
# Handle common errors
+
case "$error_msg" in
+
"invalid_auth")
+
log_error "Invalid Slack token - please check your user token"
+
;;
+
"not_authed")
+
log_error "Authentication failed - token may be expired"
+
;;
+
"missing_scope")
+
log_error "Token missing required scope - needs 'users.profile:write'"
+
;;
+
"too_large")
+
log_error "Image file too large for Slack"
+
;;
+
esac
+
return 1
+
fi
+
else
+
log_error "Invalid response from Slack API: $upload_response"
+
return 1
+
fi
+
}
+
+
# Get weather-based timeline
+
get_weather_timeline() {
+
if [ "$WEATHER_ENABLED" != "true" ] || [ -z "$WEATHER_API_KEY" ] || [ "$WEATHER_API_KEY" = "null" ]; then
+
log_info "Weather integration disabled, using default timeline: $DEFAULT_TIMELINE"
+
echo "$DEFAULT_TIMELINE"
+
return 0
+
fi
+
+
log_info "Fetching weather data..."
+
+
local lat lon
+
+
if [ "$WEATHER_LOCATION" = "auto" ]; then
+
# Auto-detect location from IP
+
local ip_data
+
ip_data=$(curl -s "http://ip-api.com/json/" --connect-timeout 10)
+
+
if echo "$ip_data" | jq -e '.lat' > /dev/null 2>&1; then
+
lat=$(echo "$ip_data" | jq -r '.lat')
+
lon=$(echo "$ip_data" | jq -r '.lon')
+
local city=$(echo "$ip_data" | jq -r '.city')
+
local country=$(echo "$ip_data" | jq -r '.country')
+
log_info "Auto-detected location: $city, $country"
+
else
+
log_warning "Could not auto-detect location, using default timeline"
+
echo "$DEFAULT_TIMELINE"
+
return 0
+
fi
+
else
+
# Use provided location (assume it's "lat,lon" or city name)
+
if [[ "$WEATHER_LOCATION" =~ ^-?[0-9]+\.?[0-9]*,-?[0-9]+\.?[0-9]*$ ]]; then
+
# It's coordinates
+
lat=$(echo "$WEATHER_LOCATION" | cut -d',' -f1)
+
lon=$(echo "$WEATHER_LOCATION" | cut -d',' -f2)
+
else
+
# It's a city name, geocode it
+
local geocode_response
+
geocode_response=$(curl -s "http://api.openweathermap.org/geo/1.0/direct?q=$WEATHER_LOCATION&limit=1&appid=$WEATHER_API_KEY")
+
+
if echo "$geocode_response" | jq -e '.[0].lat' > /dev/null 2>&1; then
+
lat=$(echo "$geocode_response" | jq -r '.[0].lat')
+
lon=$(echo "$geocode_response" | jq -r '.[0].lon')
+
log_info "Geocoded location: $WEATHER_LOCATION"
+
else
+
log_warning "Could not geocode location: $WEATHER_LOCATION"
+
echo "$DEFAULT_TIMELINE"
+
return 0
+
fi
+
fi
+
fi
+
+
# Get current weather
+
local weather_response
+
weather_response=$(curl -s "http://api.openweathermap.org/data/2.5/weather?lat=$lat&lon=$lon&appid=$WEATHER_API_KEY" --connect-timeout 10)
+
+
if echo "$weather_response" | jq -e '.weather[0].main' > /dev/null 2>&1; then
+
local weather_main=$(echo "$weather_response" | jq -r '.weather[0].main' | tr '[:upper:]' '[:lower:]')
+
local weather_desc=$(echo "$weather_response" | jq -r '.weather[0].description')
+
log_info "Current weather: $weather_desc"
+
+
# Map weather to timeline
+
local timeline
+
timeline=$(jq -r ".weather.timeline_mapping.\"$weather_main\" // \"$DEFAULT_TIMELINE\"" "$CONFIG_FILE")
+
+
# Check if the mapped timeline exists
+
if [ ! -d "$IMAGES_DIR/$timeline" ]; then
+
log_warning "Timeline '$timeline' not found, falling back to default: $DEFAULT_TIMELINE"
+
echo "$DEFAULT_TIMELINE"
+
else
+
log_info "Weather mapped to timeline: $timeline"
+
echo "$timeline"
+
fi
+
else
+
log_warning "Could not fetch weather data, using default timeline"
+
echo "$DEFAULT_TIMELINE"
+
fi
+
}
+
+
# Get current hour image path
+
get_hour_image_path() {
+
local timeline="$1"
+
local hour=$(date +%H)
+
# Remove leading zero to avoid octal interpretation, then pad with zero
+
local hour_decimal=$((10#$hour)) # Force decimal interpretation
+
local hour_padded=$(printf "%02d" "$hour_decimal")
+
local image_path="$IMAGES_DIR/$timeline/hour_${hour_padded}.jpg"
+
+
if [ -f "$image_path" ]; then
+
echo "$image_path"
+
return 0
+
else
+
log_warning "Image not found: $image_path" >&2
+
+
# Try fallback to default timeline
+
if [ "$timeline" != "$DEFAULT_TIMELINE" ]; then
+
local fallback_path="$IMAGES_DIR/$DEFAULT_TIMELINE/hour_${hour_padded}.jpg"
+
if [ -f "$fallback_path" ]; then
+
log_info "Using fallback image: $fallback_path" >&2
+
echo "$fallback_path"
+
return 0
+
fi
+
fi
+
+
return 1
+
fi
+
}
+
+
# List available timelines
+
list_timelines() {
+
if [ ! -d "$IMAGES_DIR" ]; then
+
log_error "Images directory not found: $IMAGES_DIR"
+
return 1
+
fi
+
+
echo "Available timelines:"
+
for timeline_dir in "$IMAGES_DIR"/*; do
+
if [ -d "$timeline_dir" ]; then
+
local timeline_name=$(basename "$timeline_dir")
+
local image_count=$(find "$timeline_dir" -name "hour_*.jpg" | wc -l)
+
echo " - $timeline_name ($image_count images)"
+
fi
+
done
+
}
+
+
# Test mode - show what would be used
+
test_mode() {
+
local timeline
+
timeline=$(get_weather_timeline)
+
+
local image_path
+
image_path=$(get_hour_image_path "$timeline")
+
+
local hour=$(date +%H)
+
+
echo "=== Test Mode ==="
+
echo "Current time: $(date)"
+
echo "Current hour: $hour"
+
echo "Weather enabled: $WEATHER_ENABLED"
+
echo "Selected timeline: $timeline"
+
echo "Image path: $image_path"
+
+
if [ -f "$image_path" ]; then
+
echo "✓ Image exists"
+
echo "Image size: $(du -h "$image_path" | cut -f1)"
+
+
# Show hash info in test mode
+
local test_hash=$(calculate_image_hash "$image_path")
+
echo "Image hash: $test_hash"
+
else
+
echo "✗ Image not found"
+
+
# Show available alternatives
+
echo ""
+
echo "Available timelines:"
+
list_timelines
+
return 1
+
fi
+
+
# Show weather info if enabled
+
if [ "$WEATHER_ENABLED" = "true" ] && [ -n "$WEATHER_API_KEY" ] && [ "$WEATHER_API_KEY" != "null" ]; then
+
echo ""
+
echo "Weather integration: enabled"
+
echo "Location setting: $WEATHER_LOCATION"
+
else
+
echo ""
+
echo "Weather integration: disabled (using default timeline)"
+
fi
+
+
# Show platform info
+
echo ""
+
echo "Enabled platforms:"
+
if [ "$BLUESKY_ENABLED" = "true" ]; then
+
echo " ✓ Bluesky ($BLUESKY_HANDLE)"
+
else
+
echo " ✗ Bluesky (disabled or not configured)"
+
fi
+
+
if [ "$SLACK_ENABLED" = "true" ]; then
+
echo " ✓ Slack"
+
else
+
echo " ✗ Slack (disabled or not configured)"
+
fi
+
+
# Show caching info
+
echo ""
+
echo "Blob caching: enabled for Bluesky uploads"
+
local hour_decimal=$((10#$hour))
+
local hour_padded=$(printf "%02d" "$hour_decimal")
+
echo "Cache key would be: ${timeline}_hour_${hour_padded}"
+
}
+
+
# Modified update_pfp function to use caching
+
update_pfp() {
+
log_info "Starting profile picture update..."
+
+
# Determine timeline based on weather
+
local timeline
+
timeline=$(get_weather_timeline)
+
log_info "Using timeline: $timeline"
+
+
# Get appropriate image for current hour
+
local image_path
+
image_path=$(get_hour_image_path "$timeline")
+
+
if [ -z "$image_path" ]; then
+
log_error "No suitable image found for current time"
+
return 1
+
fi
+
+
log_info "Selected image: $image_path"
+
+
# Get current hour for caching
+
local current_hour=$(date +%H)
+
local hour_decimal=$((10#$current_hour))
+
local hour_padded=$(printf "%02d" "$hour_decimal")
+
+
# Dry run mode - don't actually upload
+
if [ "${DRY_RUN:-false}" = "true" ]; then
+
log_info "DRY RUN MODE - Would upload: $image_path"
+
log_info "Image size: $(stat -c%s "$image_path" 2>/dev/null | numfmt --to=iec)"
+
local test_hash=$(calculate_image_hash "$image_path")
+
log_info "Image hash: $test_hash"
+
log_info "Would cache as: $timeline hour $hour_padded"
+
if [ "$BLUESKY_ENABLED" = "true" ]; then
+
log_info "DRY RUN MODE - Would update Bluesky profile"
+
fi
+
if [ "$SLACK_ENABLED" = "true" ]; then
+
log_info "DRY RUN MODE - Would update Slack profile"
+
fi
+
log_info "DRY RUN MODE - No changes made"
+
return 0
+
fi
+
+
local bluesky_success=false
+
local slack_success=false
+
+
# Update Bluesky with caching
+
if [ "$BLUESKY_ENABLED" = "true" ]; then
+
log_info "Updating Bluesky profile picture with caching..."
+
+
# Get session token
+
local token
+
token=$(get_session_token)
+
+
if [ -z "$token" ] || [ "$token" = "null" ]; then
+
log_info "No valid session found, authenticating..."
+
if ! authenticate_bluesky; then
+
log_error "Failed to authenticate with Bluesky"
+
else
+
token=$(get_session_token)
+
fi
+
fi
+
+
if [ -n "$token" ] && [ "$token" != "null" ]; then
+
# Get or upload blob with caching
+
local blob_ref
+
blob_ref=$(get_or_upload_blob "$image_path" "$timeline" "$hour_padded" "$token")
+
+
# If operation failed due to expired token, try re-authenticating once
+
if [ -z "$blob_ref" ]; then
+
log_info "Failed to get blob, trying to re-authenticate..."
+
rm -f "$SESSION_FILE" # Remove expired session
+
if authenticate_bluesky; then
+
token=$(get_session_token)
+
if [ -n "$token" ] && [ "$token" != "null" ]; then
+
blob_ref=$(get_or_upload_blob "$image_path" "$timeline" "$hour_padded" "$token")
+
fi
+
fi
+
fi
+
+
if [ -n "$blob_ref" ]; then
+
# Update profile picture
+
if update_profile_picture "$blob_ref" "$token"; then
+
bluesky_success=true
+
fi
+
fi
+
else
+
log_error "Could not obtain valid Bluesky session token"
+
fi
+
fi
+
+
# Update Slack (independent of Bluesky success)
+
if [ "$SLACK_ENABLED" = "true" ]; then
+
if update_slack_profile_picture "$image_path"; then
+
slack_success=true
+
fi
+
fi
+
+
# Report results
+
local updated_services=()
+
local failed_services=()
+
+
if [ "$BLUESKY_ENABLED" = "true" ]; then
+
if [ "$bluesky_success" = "true" ]; then
+
updated_services+=("Bluesky")
+
else
+
failed_services+=("Bluesky")
+
fi
+
fi
+
+
if [ "$SLACK_ENABLED" = "true" ]; then
+
if [ "$slack_success" = "true" ]; then
+
updated_services+=("Slack")
+
else
+
failed_services+=("Slack")
+
fi
+
fi
+
+
# Final status
+
if [ ${#updated_services[@]} -gt 0 ]; then
+
log_success "Successfully updated: ${updated_services[*]}"
+
fi
+
+
if [ ${#failed_services[@]} -gt 0 ]; then
+
log_error "Failed to update: ${failed_services[*]}"
+
fi
+
+
# Return success if at least one service updated
+
if [ ${#updated_services[@]} -gt 0 ]; then
+
return 0
+
else
+
return 1
+
fi
+
}
+
+
# Show help
+
show_help() {
+
cat << EOF
+
Dynamic Profile Picture Updater
+
+
Automatically updates your profile pictures across multiple platforms based on time and weather.
+
Uses ATProto record caching to avoid re-uploading identical images.
+
+
Usage: $0 [options]
+
+
Options:
+
-c, --config FILE Use custom config file (default: $CONFIG_FILE)
+
-t, --test Test mode - show what would be used without updating
+
-d, --dry-run Dry run - authenticate and prepare but don't actually update
+
-l, --list List available timelines
+
-f, --force TIMELINE Force use of specific timeline (ignore weather)
+
--list-cache List all cached blob references
+
--cleanup-cache [DAYS] Clean up cached blobs older than DAYS (default: 30)
+
--clear-cache Delete all cached blob references (USE WITH CAUTION)
+
-h, --help Show this help message
+
+
Configuration:
+
Edit $CONFIG_FILE to set your platform credentials and preferences.
+
+
Supported platforms:
+
- Bluesky: Set handle and app password
+
- Slack: Set user token (xoxp-...) with users.profile:write scope
+
+
Blob Caching:
+
Images are uploaded once and cached in ATProto records at:
+
pfp.updates.{timeline}.{timeline}_hour_{HH}
+
+
Each record contains:
+
- Image SHA256 hash for change detection
+
- Blob reference for reuse
+
- Metadata (timeline, hour, creation time)
+
+
Examples of cache locations:
+
- pfp.updates.sunny.sunny_hour_09
+
- pfp.updates.rainy.rainy_hour_14
+
- pfp.updates.cloudy.cloudy_hour_23
+
+
Examples:
+
$0 # Update profile pictures (uses cache when possible)
+
$0 --test # Test what would be used
+
$0 --list-cache # Show all cached blob references
+
$0 --cleanup-cache 7 # Remove cached blobs older than 7 days
+
$0 --force sunny # Force sunny timeline
+
+
For automated updates, add to crontab:
+
# Update 2 minutes after every hour
+
2 * * * * $0 >/dev/null 2>&1
+
EOF
+
}
+
+
# Parse command line arguments
+
parse_args() {
+
FORCE_TIMELINE=""
+
+
while [[ $# -gt 0 ]]; do
+
case $1 in
+
-c|--config)
+
CONFIG_FILE="$2"
+
shift 2
+
;;
+
-t|--test)
+
load_config
+
test_mode
+
exit $?
+
;;
+
-d|--dry-run)
+
DRY_RUN=true
+
shift
+
;;
+
-l|--list)
+
load_config
+
list_timelines
+
exit 0
+
;;
+
-f|--force)
+
FORCE_TIMELINE="$2"
+
shift 2
+
;;
+
--list-cache)
+
load_config
+
check_dependencies
+
+
# Get session token
+
local token
+
token=$(get_session_token)
+
if [ -z "$token" ] || [ "$token" = "null" ]; then
+
if ! authenticate_bluesky; then
+
log_error "Failed to authenticate with Bluesky"
+
exit 1
+
else
+
token=$(get_session_token)
+
fi
+
fi
+
+
list_cached_blobs "$token"
+
exit $?
+
;;
+
--cleanup-cache)
+
local cleanup_days="30"
+
if [[ "$2" =~ ^[0-9]+$ ]]; then
+
cleanup_days="$2"
+
shift
+
fi
+
+
load_config
+
check_dependencies
+
+
# Get session token
+
local token
+
token=$(get_session_token)
+
if [ -z "$token" ] || [ "$token" = "null" ]; then
+
if ! authenticate_bluesky; then
+
log_error "Failed to authenticate with Bluesky"
+
exit 1
+
else
+
token=$(get_session_token)
+
fi
+
fi
+
+
cleanup_cached_blobs "$token" "$cleanup_days"
+
exit $?
+
;;
+
--clear-cache)
+
echo "WARNING: This will delete ALL cached blob references!"
+
echo "You will need to re-upload all images on next use."
+
read -p "Are you sure? (y/N): " -n 1 -r
+
echo
+
if [[ $REPLY =~ ^[Yy]$ ]]; then
+
load_config
+
check_dependencies
+
+
# Get session token
+
local token
+
token=$(get_session_token)
+
if [ -z "$token" ] || [ "$token" = "null" ]; then
+
if ! authenticate_bluesky; then
+
log_error "Failed to authenticate with Bluesky"
+
exit 1
+
else
+
token=$(get_session_token)
+
fi
+
fi
+
+
cleanup_cached_blobs "$token" "0" # Delete all
+
log_success "Cache cleared"
+
else
+
log_info "Cache clear cancelled"
+
fi
+
exit 0
+
;;
+
-h|--help)
+
show_help
+
exit 0
+
;;
+
*)
+
echo "Unknown option: $1"
+
show_help
+
exit 1
+
;;
+
esac
+
done
+
}
+
+
# Override weather function if timeline is forced
+
if [ -n "${FORCE_TIMELINE:-}" ]; then
+
get_weather_timeline() {
+
echo "$FORCE_TIMELINE"
+
}
+
fi
+
+
# Main execution
+
main() {
+
parse_args "$@"
+
check_dependencies
+
load_config
+
+
if ! update_pfp; then
+
exit 1
+
fi
+
}
+
+
# Run main function
+
main "$@"
+2241
site/pfp-updates/index.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+
<link
+
rel="icon"
+
type="image/png"
+
href="/favicon/favicon-96x96.png"
+
sizes="96x96"
+
/>
+
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
+
<link rel="shortcut icon" href="/favicon/favicon.ico" />
+
<link
+
rel="apple-touch-icon"
+
sizes="180x180"
+
href="/favicon/apple-touch-icon.png"
+
/>
+
<meta name="apple-mobile-web-app-title" content="Serif.blue" />
+
<link rel="manifest" href="/favicon/site.webmanifest" />
+
+
<meta
+
name="description"
+
content="Serif.blue - Fancy projects by Kieran"
+
/>
+
<meta name="color-scheme" content="light" />
+
+
<meta property="og:title" content="Serif.blue - pfp gradient builder" />
+
<meta property="og:type" content="website" />
+
<meta property="og:url" content="https://serif.blue/pfp-updates" />
+
<meta property="og:image" content="/og.png" />
+
+
<link rel="me" href="https://dunkirk.sh" />
+
<link rel="me" href="https://bsky.app/profile/dunkirk.sh" />
+
<link rel="me" href="https://github.com/taciturnaxolotl" />
+
+
<title>Sky Gradient Timeline Builder</title>
+
<style>
+
body {
+
font-family: Arial, sans-serif;
+
max-width: 1400px;
+
margin: 0 auto;
+
padding: 20px;
+
background: #f5f5f5;
+
}
+
+
.container {
+
display: grid;
+
grid-template-columns: 350px 1fr;
+
gap: 20px;
+
margin-bottom: 20px;
+
}
+
+
.sidebar {
+
background: white;
+
padding: 20px;
+
border-radius: 8px;
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+
height: fit-content;
+
overflow-y: auto;
+
overflow-x: auto;
+
max-height: 90vh;
+
word-wrap: break-word;
+
overflow-wrap: break-word;
+
}
+
+
.timeline-area {
+
background: white;
+
padding: 20px;
+
border-radius: 8px;
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+
}
+
+
.upload-section {
+
margin-bottom: 20px;
+
padding: 15px;
+
border: 2px dashed #ddd;
+
border-radius: 8px;
+
text-align: center;
+
}
+
+
.timeline-selector {
+
margin-bottom: 20px;
+
}
+
+
.timeline-tabs {
+
display: flex;
+
flex-wrap: wrap;
+
gap: 5px;
+
margin-bottom: 10px;
+
}
+
+
.timeline-tab {
+
padding: 8px 12px;
+
border: 1px solid #ddd;
+
border-radius: 4px;
+
cursor: pointer;
+
font-size: 12px;
+
background: #f8f9fa;
+
transition: all 0.2s;
+
}
+
+
.timeline-tab.active {
+
background: #007bff;
+
color: white;
+
border-color: #007bff;
+
}
+
+
.timeline-tab:hover {
+
background: #e9ecef;
+
}
+
+
.timeline-tab.active:hover {
+
background: #0056b3;
+
}
+
+
.new-timeline {
+
display: flex;
+
gap: 5px;
+
margin-bottom: 15px;
+
}
+
+
.timeline-grid {
+
display: grid;
+
grid-template-columns: repeat(24, 1fr);
+
gap: 2px;
+
margin-bottom: 20px;
+
border: 1px solid #ddd;
+
border-radius: 4px;
+
padding: 10px;
+
background: #f8f9fa;
+
}
+
+
.hour-slot {
+
aspect-ratio: 1;
+
border: 1px solid #ccc;
+
border-radius: 3px;
+
cursor: pointer;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
font-size: 10px;
+
font-weight: bold;
+
position: relative;
+
transition: all 0.2s;
+
}
+
+
.hour-slot:hover {
+
border-color: #007bff;
+
transform: scale(1.1);
+
z-index: 10;
+
}
+
+
.hour-slot.selected {
+
border: 2px solid #007bff;
+
box-shadow: 0 0 5px rgba(0, 123, 255, 0.5);
+
}
+
+
.color-input {
+
width: 60px;
+
height: 30px;
+
border: none;
+
border-radius: 4px;
+
cursor: pointer;
+
margin: 0 5px;
+
}
+
+
.slider-group {
+
margin: 10px 0;
+
display: flex;
+
align-items: center;
+
gap: 10px;
+
}
+
+
.slider {
+
flex: 1;
+
height: 6px;
+
border-radius: 3px;
+
background: #ddd;
+
outline: none;
+
}
+
+
.value-display {
+
min-width: 40px;
+
font-weight: bold;
+
}
+
+
.preview-canvas {
+
max-width: 200px;
+
border: 1px solid #ddd;
+
border-radius: 8px;
+
margin: 10px 0;
+
}
+
+
.btn {
+
padding: 8px 12px;
+
border: none;
+
border-radius: 4px;
+
cursor: pointer;
+
font-size: 12px;
+
transition: all 0.2s;
+
margin: 2px;
+
}
+
+
.btn-primary {
+
background: #007bff;
+
color: white;
+
}
+
.btn-success {
+
background: #28a745;
+
color: white;
+
}
+
.btn-danger {
+
background: #dc3545;
+
color: white;
+
}
+
.btn-secondary {
+
background: #6c757d;
+
color: white;
+
}
+
.btn-warning {
+
background: #ffc107;
+
color: black;
+
}
+
+
.btn:hover {
+
transform: translateY(-1px);
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+
}
+
+
.status {
+
margin: 10px 0;
+
padding: 10px;
+
border-radius: 4px;
+
display: none;
+
}
+
+
.status.error {
+
background: #ffe6e6;
+
color: #d00;
+
display: block;
+
}
+
.status.success {
+
background: #e6ffe6;
+
color: #060;
+
display: block;
+
}
+
+
.hour-labels {
+
display: grid;
+
grid-template-columns: repeat(24, 1fr);
+
gap: 2px;
+
margin-bottom: 5px;
+
padding: 0 10px;
+
}
+
+
.hour-label {
+
text-align: center;
+
font-size: 10px;
+
color: #666;
+
}
+
+
.config-export {
+
margin-top: 20px;
+
padding: 15px;
+
border: 1px solid #ddd;
+
border-radius: 4px;
+
background: #f8f9fa;
+
}
+
+
.timeline-info {
+
font-size: 12px;
+
color: #666;
+
margin-bottom: 15px;
+
padding: 10px;
+
background: #e3f2fd;
+
border-radius: 4px;
+
}
+
</style>
+
</head>
+
<body>
+
<h1>🌅 Sky Gradient Timeline Builder</h1>
+
+
<div class="container">
+
<div class="sidebar">
+
<div class="upload-section">
+
<h3>Upload Images</h3>
+
<div>
+
<label>Base Image:</label><br />
+
<input type="file" id="baseImage" accept="image/*" />
+
</div>
+
<br />
+
<div>
+
<label>Matte:</label><br />
+
<input type="file" id="matteImage" accept="image/*" />
+
</div>
+
<div class="status" id="uploadStatus"></div>
+
</div>
+
+
<div>
+
<h3>Hour Settings</h3>
+
<div
+
id="hourInfo"
+
style="
+
font-size: 12px;
+
color: #666;
+
margin-bottom: 10px;
+
"
+
>
+
Click an hour slot to configure
+
</div>
+
+
<div
+
style="
+
display: flex;
+
align-items: center;
+
gap: 10px;
+
margin: 15px 0;
+
"
+
>
+
<label>From:</label>
+
<input
+
type="color"
+
id="color1"
+
class="color-input"
+
value="#4682b4"
+
/>
+
<label>To:</label>
+
<input
+
type="color"
+
id="color2"
+
class="color-input"
+
value="#87ceeb"
+
/>
+
</div>
+
+
<div class="slider-group">
+
<label>Background:</label>
+
<input
+
type="range"
+
id="bgIntensity"
+
class="slider"
+
min="0"
+
max="100"
+
value="40"
+
/>
+
<span class="value-display" id="bgValue">40%</span>
+
</div>
+
+
<div class="slider-group">
+
<label>Foreground:</label>
+
<input
+
type="range"
+
id="fgIntensity"
+
class="slider"
+
min="0"
+
max="50"
+
value="8"
+
/>
+
<span class="value-display" id="fgValue">8%</span>
+
</div>
+
+
<button
+
onclick="applyToSelectedHour()"
+
class="btn btn-success"
+
style="width: 100%; margin-top: 10px"
+
>
+
Apply to Selected Hour
+
</button>
+
+
<h4>Preview</h4>
+
<canvas
+
id="previewCanvas"
+
class="preview-canvas"
+
width="150"
+
height="150"
+
></canvas>
+
+
<canvas
+
id="renderCanvas"
+
style="display: none"
+
width="400"
+
height="400"
+
></canvas>
+
</div>
+
+
<div class="config-export">
+
<h4>Export Config</h4>
+
<button
+
onclick="copyAllTimelines()"
+
class="btn btn-warning"
+
style="width: 100%; margin-bottom: 10px"
+
>
+
📋 Copy All Timelines
+
</button>
+
+
<label style="font-size: 12px; color: #666"
+
>Config Output:</label
+
>
+
<textarea
+
id="configOutput"
+
readonly
+
style="
+
width: 100%;
+
height: 120px;
+
font-family: monospace;
+
font-size: 10px;
+
resize: vertical;
+
margin-top: 5px;
+
padding: 8px;
+
border: 1px solid #ddd;
+
border-radius: 4px;
+
word-break: break-all;
+
white-space: pre-wrap;
+
overflow-wrap: break-word;
+
"
+
></textarea>
+
<button
+
onclick="copyToClipboard()"
+
class="btn btn-success"
+
style="width: 100%; margin-top: 5px"
+
>
+
📋 Copy to Clipboard
+
</button>
+
+
<hr style="margin: 15px 0" />
+
+
<h4>Import Config</h4>
+
<label style="font-size: 12px; color: #666"
+
>Paste Config (auto-imports):</label
+
>
+
<textarea
+
id="bulkConfigInput"
+
placeholder="Paste timeline config here..."
+
style="
+
width: 100%;
+
height: 80px;
+
font-family: monospace;
+
font-size: 10px;
+
resize: vertical;
+
margin-top: 5px;
+
padding: 8px;
+
border: 1px solid #ddd;
+
border-radius: 4px;
+
"
+
></textarea>
+
</div>
+
</div>
+
+
<div class="main-area">
+
<div class="timeline-area">
+
<div class="timeline-selector">
+
<h3 style="margin-top: 0">Weather Timelines</h3>
+
<div class="timeline-info">
+
Create different timelines for various weather
+
conditions. Each timeline defines how your profile
+
picture should look throughout the day.
+
</div>
+
+
<div class="new-timeline">
+
<input
+
type="text"
+
id="newTimelineName"
+
placeholder="Timeline name (e.g. sunny, rainy, cloudy)"
+
style="flex: 1; padding: 8px"
+
/>
+
<button
+
onclick="createTimeline()"
+
class="btn btn-success"
+
>
+
Create
+
</button>
+
</div>
+
+
<div class="timeline-tabs" id="timelineTabs">
+
<div
+
class="timeline-tab active"
+
data-timeline="sunny"
+
>
+
Sunny
+
</div>
+
</div>
+
+
<div style="margin: 10px 0">
+
<button
+
onclick="duplicateTimeline()"
+
class="btn btn-secondary"
+
>
+
Duplicate Current
+
</button>
+
<button
+
onclick="deleteTimeline()"
+
class="btn btn-danger"
+
>
+
Delete Current
+
</button>
+
</div>
+
</div>
+
+
<div>
+
<h4>
+
24-Hour Timeline:
+
<span id="currentTimelineName">Sunny</span>
+
</h4>
+
<div class="hour-labels">
+
<div class="hour-label">0</div>
+
<div class="hour-label">1</div>
+
<div class="hour-label">2</div>
+
<div class="hour-label">3</div>
+
<div class="hour-label">4</div>
+
<div class="hour-label">5</div>
+
<div class="hour-label">6</div>
+
<div class="hour-label">7</div>
+
<div class="hour-label">8</div>
+
<div class="hour-label">9</div>
+
<div class="hour-label">10</div>
+
<div class="hour-label">11</div>
+
<div class="hour-label">12</div>
+
<div class="hour-label">13</div>
+
<div class="hour-label">14</div>
+
<div class="hour-label">15</div>
+
<div class="hour-label">16</div>
+
<div class="hour-label">17</div>
+
<div class="hour-label">18</div>
+
<div class="hour-label">19</div>
+
<div class="hour-label">20</div>
+
<div class="hour-label">21</div>
+
<div class="hour-label">22</div>
+
<div class="hour-label">23</div>
+
</div>
+
<div class="timeline-grid" id="timelineGrid">
+
<!-- Hours 0-23 will be generated here -->
+
</div>
+
+
<div style="margin-top: 20px">
+
<h4>Bulk Actions</h4>
+
<div
+
style="
+
display: flex;
+
gap: 10px;
+
flex-wrap: wrap;
+
margin-bottom: 15px;
+
"
+
>
+
<button
+
onclick="loadPreset('dawn', [5,6,7])"
+
class="btn btn-secondary"
+
>
+
Dawn (5-7)
+
</button>
+
<button
+
onclick="loadPreset('morning', [8,9,10,11])"
+
class="btn btn-secondary"
+
>
+
Morning (8-11)
+
</button>
+
<button
+
onclick="loadPreset('afternoon', [12,13,14,15,16])"
+
class="btn btn-secondary"
+
>
+
Afternoon (12-16)
+
</button>
+
<button
+
onclick="loadPreset('sunset', [17,18,19])"
+
class="btn btn-secondary"
+
>
+
Sunset (17-19)
+
</button>
+
<button
+
onclick="loadPreset('night', [20,21,22,23,0,1,2,3,4])"
+
class="btn btn-secondary"
+
>
+
Night (20-4)
+
</button>
+
</div>
+
+
<div
+
style="
+
border: 1px solid #ddd;
+
padding: 10px;
+
border-radius: 4px;
+
background: #f8f9fa;
+
"
+
>
+
<h5 style="margin: 0 0 10px 0">Custom Range</h5>
+
<div
+
style="
+
display: flex;
+
gap: 5px;
+
align-items: center;
+
margin-bottom: 10px;
+
"
+
>
+
<label style="font-size: 12px">From:</label>
+
<input
+
type="number"
+
id="rangeStart"
+
min="0"
+
max="23"
+
value="9"
+
style="width: 50px; padding: 4px"
+
/>
+
<label style="font-size: 12px">To:</label>
+
<input
+
type="number"
+
id="rangeEnd"
+
min="0"
+
max="23"
+
value="11"
+
style="width: 50px; padding: 4px"
+
/>
+
<button
+
onclick="applyCurrentToRange()"
+
class="btn btn-success"
+
style="font-size: 11px"
+
>
+
Apply Current
+
</button>
+
</div>
+
<div style="font-size: 11px; color: #666">
+
Uses current gradient & intensity settings
+
for the specified hour range
+
</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
+
<div id="renderGallery" style="margin-top: 20px">
+
<div
+
style="
+
background: white;
+
padding: 20px;
+
padding-top: 5px;
+
border-radius: 8px;
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+
"
+
>
+
<h3>🖼️ Render Gallery</h3>
+
+
<div
+
style="
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
margin-bottom: 15px;
+
flex-wrap: wrap;
+
gap: 10px;
+
"
+
>
+
<div
+
id="galleryInfo"
+
style="font-size: 14px; color: #666"
+
>
+
Ready to render timelines
+
</div>
+
<div style="display: flex; gap: 10px">
+
<button
+
onclick="renderAllTimelines()"
+
class="btn btn-success"
+
>
+
🎨 Render All
+
</button>
+
<button
+
onclick="downloadAllRendered()"
+
class="btn btn-primary"
+
id="downloadAllBtn"
+
disabled
+
>
+
💾 Download ZIP
+
</button>
+
<button
+
onclick="clearGallery()"
+
class="btn btn-danger"
+
>
+
🗑️ Clear Gallery
+
</button>
+
</div>
+
</div>
+
+
<div
+
id="bulkProgress"
+
style="margin-bottom: 15px; display: none"
+
>
+
<div
+
style="
+
background: #e9ecef;
+
border-radius: 4px;
+
overflow: hidden;
+
"
+
>
+
<div
+
id="progressBar"
+
style="
+
background: #28a745;
+
height: 20px;
+
width: 0%;
+
transition: width 0.3s;
+
"
+
></div>
+
</div>
+
<div
+
id="progressText"
+
style="
+
font-size: 12px;
+
text-align: center;
+
margin-top: 5px;
+
"
+
>
+
0/0
+
</div>
+
</div>
+
+
<div
+
id="galleryContent"
+
style="
+
display: grid;
+
grid-template-columns: repeat(
+
auto-fill,
+
minmax(120px, 1fr)
+
);
+
gap: 15px;
+
min-height: 100px;
+
border: 2px dashed #ddd;
+
border-radius: 8px;
+
padding: 20px;
+
align-items: center;
+
justify-content: center;
+
"
+
>
+
<div
+
id="galleryPlaceholder"
+
style="
+
grid-column: 1 / -1;
+
text-align: center;
+
color: #999;
+
font-style: italic;
+
"
+
>
+
Click "Render All" to generate images and see
+
them here
+
</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
+
<script>
+
let baseImg = null;
+
let matteImg = null;
+
let selectedHour = 0;
+
let currentTimeline = "sunny";
+
+
// Preset configurations
+
const presets = {
+
dawn: {
+
color1: "#ff6b35",
+
color2: "#f7931e",
+
backgroundIntensity: 45,
+
foregroundIntensity: 8,
+
},
+
morning: {
+
color1: "#87ceeb",
+
color2: "#4682b4",
+
backgroundIntensity: 35,
+
foregroundIntensity: 5,
+
},
+
afternoon: {
+
color1: "#4682b4",
+
color2: "#daa520",
+
backgroundIntensity: 30,
+
foregroundIntensity: 5,
+
},
+
sunset: {
+
color1: "#ff4500",
+
color2: "#8b0000",
+
backgroundIntensity: 50,
+
foregroundIntensity: 10,
+
},
+
night: {
+
color1: "#191970",
+
color2: "#000000",
+
backgroundIntensity: 55,
+
foregroundIntensity: 12,
+
},
+
};
+
+
// Timeline data structure - initialize with proper presets
+
let timelines = {
+
sunny: {},
+
};
+
+
// Initialize the default sunny timeline with presets
+
for (let hour = 0; hour < 24; hour++) {
+
if (hour >= 5 && hour <= 7) {
+
timelines.sunny[hour] = { ...presets.dawn };
+
} else if (hour >= 8 && hour <= 11) {
+
timelines.sunny[hour] = { ...presets.morning };
+
} else if (hour >= 12 && hour <= 16) {
+
timelines.sunny[hour] = { ...presets.afternoon };
+
} else if (hour >= 17 && hour <= 19) {
+
timelines.sunny[hour] = { ...presets.sunset };
+
} else {
+
timelines.sunny[hour] = { ...presets.night };
+
}
+
}
+
+
// Default hour configuration
+
const defaultHourConfig = {
+
color1: "#4682b4",
+
color2: "#87ceeb",
+
backgroundIntensity: 40,
+
foregroundIntensity: 8,
+
};
+
+
function initializeTimeline() {
+
// Create hour slots
+
const grid = document.getElementById("timelineGrid");
+
grid.innerHTML = "";
+
+
for (let hour = 0; hour < 24; hour++) {
+
const slot = document.createElement("div");
+
slot.className = "hour-slot";
+
slot.dataset.hour = hour;
+
slot.textContent = hour;
+
slot.onclick = () => selectHour(hour);
+
+
// Initialize with appropriate preset if not exists
+
if (!timelines[currentTimeline][hour]) {
+
timelines[currentTimeline][hour] =
+
getDefaultConfigForHour(hour);
+
}
+
+
grid.appendChild(slot);
+
}
+
+
updateTimelineDisplay();
+
selectHour(0);
+
}
+
+
function getDefaultConfigForHour(hour) {
+
// Apply appropriate preset based on hour
+
if (hour >= 5 && hour <= 7) {
+
return { ...presets.dawn };
+
} else if (hour >= 8 && hour <= 11) {
+
return { ...presets.morning };
+
} else if (hour >= 12 && hour <= 16) {
+
return { ...presets.afternoon };
+
} else if (hour >= 17 && hour <= 19) {
+
return { ...presets.sunset };
+
} else {
+
return { ...presets.night };
+
}
+
}
+
+
function selectHour(hour) {
+
selectedHour = hour;
+
+
// Update UI selection
+
document.querySelectorAll(".hour-slot").forEach((slot) => {
+
slot.classList.remove("selected");
+
});
+
document
+
.querySelector(`[data-hour="${hour}"]`)
+
.classList.add("selected");
+
+
// Load hour configuration
+
const config = timelines[currentTimeline][hour] || {
+
...defaultHourConfig,
+
};
+
document.getElementById("color1").value = config.color1;
+
document.getElementById("color2").value = config.color2;
+
document.getElementById("bgIntensity").value =
+
config.backgroundIntensity;
+
document.getElementById("fgIntensity").value =
+
config.foregroundIntensity;
+
+
// Update displays
+
document.getElementById("bgValue").textContent =
+
config.backgroundIntensity + "%";
+
document.getElementById("fgValue").textContent =
+
config.foregroundIntensity + "%";
+
document.getElementById("hourInfo").textContent =
+
`Configuring hour ${hour} (${hour === 0 ? "12" : hour > 12 ? hour - 12 : hour}${hour < 12 ? "AM" : "PM"})`;
+
+
updatePreview();
+
}
+
+
function applyToSelectedHour() {
+
const config = {
+
color1: document.getElementById("color1").value,
+
color2: document.getElementById("color2").value,
+
backgroundIntensity: parseInt(
+
document.getElementById("bgIntensity").value,
+
),
+
foregroundIntensity: parseInt(
+
document.getElementById("fgIntensity").value,
+
),
+
};
+
+
timelines[currentTimeline][selectedHour] = config;
+
updateTimelineDisplay();
+
showStatus("Hour " + selectedHour + " updated!", "success");
+
}
+
+
function updateTimelineDisplay() {
+
document.querySelectorAll(".hour-slot").forEach((slot) => {
+
const hour = parseInt(slot.dataset.hour);
+
const config = timelines[currentTimeline][hour];
+
+
if (config) {
+
const gradient = `linear-gradient(135deg, ${config.color1}, ${config.color2})`;
+
slot.style.background = gradient;
+
slot.style.color = "white";
+
slot.style.textShadow = "1px 1px 2px rgba(0,0,0,0.8)";
+
} else {
+
slot.style.background = "#f8f9fa";
+
slot.style.color = "#666";
+
slot.style.textShadow = "none";
+
}
+
});
+
}
+
+
function createTimeline() {
+
const name = document
+
.getElementById("newTimelineName")
+
.value.trim();
+
if (!name) {
+
showStatus("Please enter a timeline name!", "error");
+
return;
+
}
+
+
if (timelines[name]) {
+
showStatus("Timeline already exists!", "error");
+
return;
+
}
+
+
// Create new timeline with proper presets for each hour
+
timelines[name] = {};
+
for (let hour = 0; hour < 24; hour++) {
+
timelines[name][hour] = getDefaultConfigForHour(hour);
+
}
+
+
// Add tab
+
const tab = document.createElement("div");
+
tab.className = "timeline-tab";
+
tab.dataset.timeline = name;
+
tab.textContent = name;
+
tab.onclick = () => switchTimeline(name);
+
document.getElementById("timelineTabs").appendChild(tab);
+
+
// Switch to new timeline
+
switchTimeline(name);
+
document.getElementById("newTimelineName").value = "";
+
showStatus(
+
`Timeline "${name}" created with default presets!`,
+
"success",
+
);
+
}
+
+
function switchTimeline(timelineName) {
+
currentTimeline = timelineName;
+
+
// Update tab selection
+
document.querySelectorAll(".timeline-tab").forEach((tab) => {
+
tab.classList.remove("active");
+
});
+
document
+
.querySelector(`[data-timeline="${timelineName}"]`)
+
.classList.add("active");
+
+
document.getElementById("currentTimelineName").textContent =
+
timelineName;
+
updateTimelineDisplay();
+
selectHour(selectedHour);
+
}
+
+
function duplicateTimeline() {
+
const newName = prompt(
+
`Enter name for copy of "${currentTimeline}":`,
+
);
+
if (!newName || timelines[newName]) {
+
showStatus(
+
"Invalid name or timeline already exists!",
+
"error",
+
);
+
return;
+
}
+
+
// Deep copy current timeline
+
timelines[newName] = JSON.parse(
+
JSON.stringify(timelines[currentTimeline]),
+
);
+
+
// Add tab
+
const tab = document.createElement("div");
+
tab.className = "timeline-tab";
+
tab.dataset.timeline = newName;
+
tab.textContent = newName;
+
tab.onclick = () => switchTimeline(newName);
+
document.getElementById("timelineTabs").appendChild(tab);
+
+
switchTimeline(newName);
+
showStatus(`Timeline "${newName}" created as copy!`, "success");
+
}
+
+
function deleteTimeline() {
+
if (Object.keys(timelines).length <= 1) {
+
showStatus("Cannot delete the last timeline!", "error");
+
return;
+
}
+
+
if (!confirm(`Delete timeline "${currentTimeline}"?`)) return;
+
+
// Remove timeline
+
delete timelines[currentTimeline];
+
+
// Remove tab
+
document
+
.querySelector(`[data-timeline="${currentTimeline}"]`)
+
.remove();
+
+
// Switch to first available timeline
+
const firstTimeline = Object.keys(timelines)[0];
+
switchTimeline(firstTimeline);
+
+
showStatus(`Timeline "${currentTimeline}" deleted!`, "success");
+
}
+
+
function loadPreset(presetName, hours) {
+
// Get current UI settings
+
const config = {
+
color1: document.getElementById("color1").value,
+
color2: document.getElementById("color2").value,
+
backgroundIntensity: parseInt(
+
document.getElementById("bgIntensity").value,
+
),
+
foregroundIntensity: parseInt(
+
document.getElementById("fgIntensity").value,
+
),
+
};
+
+
// Apply current settings to specified hours
+
hours.forEach((hour) => {
+
timelines[currentTimeline][hour] = { ...config };
+
});
+
+
updateTimelineDisplay();
+
showStatus(
+
`Applied current settings to ${presetName} hours: ${hours.join(", ")}`,
+
"success",
+
);
+
}
+
+
function applyCurrentToRange() {
+
const start = parseInt(
+
document.getElementById("rangeStart").value,
+
);
+
const end = parseInt(document.getElementById("rangeEnd").value);
+
+
if (start < 0 || start > 23 || end < 0 || end > 23) {
+
showStatus("Hours must be between 0 and 23!", "error");
+
return;
+
}
+
+
const config = {
+
color1: document.getElementById("color1").value,
+
color2: document.getElementById("color2").value,
+
backgroundIntensity: parseInt(
+
document.getElementById("bgIntensity").value,
+
),
+
foregroundIntensity: parseInt(
+
document.getElementById("fgIntensity").value,
+
),
+
};
+
+
// Generate hour range (handle wrap-around)
+
let hours = [];
+
if (start <= end) {
+
for (let i = start; i <= end; i++) {
+
hours.push(i);
+
}
+
} else {
+
// Wrap around (e.g., 22 to 2 = 22,23,0,1,2)
+
for (let i = start; i <= 23; i++) {
+
hours.push(i);
+
}
+
for (let i = 0; i <= end; i++) {
+
hours.push(i);
+
}
+
}
+
+
// Apply config to all hours in range
+
hours.forEach((hour) => {
+
timelines[currentTimeline][hour] = { ...config };
+
});
+
+
updateTimelineDisplay();
+
showStatus(
+
`Applied current settings to hours: ${hours.join(", ")}`,
+
"success",
+
);
+
}
+
+
function copyAllTimelines() {
+
const config = {
+
timelines: timelines,
+
metadata: {
+
created: new Date().toISOString(),
+
tool: "Sky Gradient Timeline Builder",
+
},
+
};
+
+
const configText = JSON.stringify(config, null, 2);
+
document.getElementById("configOutput").value = configText;
+
showStatus("All timelines ready to copy!", "success");
+
}
+
+
function renderCurrentHour() {
+
if (!baseImg || !matteImg) {
+
showStatus("Please upload both images first!", "error");
+
return;
+
}
+
+
const color1 = document.getElementById("color1").value;
+
const color2 = document.getElementById("color2").value;
+
const bgIntensity = parseInt(
+
document.getElementById("bgIntensity").value,
+
);
+
const fgIntensity = parseInt(
+
document.getElementById("fgIntensity").value,
+
);
+
+
// Use the full-size render canvas
+
const canvas = document.getElementById("renderCanvas");
+
const ctx = canvas.getContext("2d");
+
+
// Set canvas size to match base image
+
canvas.width = baseImg.width;
+
canvas.height = baseImg.height;
+
+
// Create gradient
+
const gradient = createGradient(
+
canvas.width,
+
canvas.height,
+
color1,
+
color2,
+
);
+
+
// Clear canvas
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+
// Create background layer
+
const backgroundLayer = blendImages(
+
baseImg,
+
gradient,
+
bgIntensity,
+
);
+
ctx.drawImage(backgroundLayer, 0, 0);
+
+
// Create and apply foreground layer
+
let foregroundLayer;
+
if (fgIntensity === 0) {
+
foregroundLayer = baseImg;
+
} else {
+
foregroundLayer = blendImages(
+
baseImg,
+
gradient,
+
fgIntensity,
+
);
+
}
+
+
// Apply masking
+
const maskedForeground = document.createElement("canvas");
+
maskedForeground.width = canvas.width;
+
maskedForeground.height = canvas.height;
+
const maskCtx = maskedForeground.getContext("2d");
+
+
maskCtx.drawImage(foregroundLayer, 0, 0);
+
+
// Create proper alpha mask
+
const matteDataCanvas = document.createElement("canvas");
+
matteDataCanvas.width = matteImg.width;
+
matteDataCanvas.height = matteImg.height;
+
const matteDataCtx = matteDataCanvas.getContext("2d");
+
matteDataCtx.drawImage(matteImg, 0, 0);
+
+
const imageData = matteDataCtx.getImageData(
+
0,
+
0,
+
matteImg.width,
+
matteImg.height,
+
);
+
const data = imageData.data;
+
+
for (let i = 0; i < data.length; i += 4) {
+
const brightness =
+
(data[i] + data[i + 1] + data[i + 2]) / 3;
+
if (brightness > 128) {
+
data[i + 3] = 255;
+
} else {
+
data[i + 3] = 0;
+
}
+
data[i] = 255;
+
data[i + 1] = 255;
+
data[i + 2] = 255;
+
}
+
+
matteDataCtx.putImageData(imageData, 0, 0);
+
+
maskCtx.globalCompositeOperation = "destination-in";
+
maskCtx.drawImage(
+
matteDataCanvas,
+
0,
+
0,
+
canvas.width,
+
canvas.height,
+
);
+
+
ctx.globalCompositeOperation = "source-over";
+
ctx.drawImage(maskedForeground, 0, 0);
+
+
// Enable download button
+
document.getElementById("downloadBtn").disabled = false;
+
showStatus(
+
`Hour ${selectedHour} rendered at full resolution!`,
+
"success",
+
);
+
}
+
+
function downloadRendered() {
+
const canvas = document.getElementById("renderCanvas");
+
if (canvas.width === 400 && canvas.height === 400) {
+
showStatus("Please render first!", "error");
+
return;
+
}
+
+
// Create download
+
const link = document.createElement("a");
+
const timelineName = currentTimeline;
+
const hour = selectedHour.toString().padStart(2, "0");
+
link.download = `${timelineName}_hour_${hour}.jpg`;
+
+
// Convert to JPEG for smaller file size
+
link.href = canvas.toDataURL("image/jpeg", 0.95);
+
link.click();
+
+
showStatus(`Downloaded: ${link.download}`, "success");
+
}
+
+
// Auto-import on paste
+
document
+
.getElementById("bulkConfigInput")
+
.addEventListener("paste", function (e) {
+
// Small delay to let the paste complete
+
setTimeout(() => {
+
const configText = this.value.trim();
+
if (configText && configText.startsWith("{")) {
+
importConfigToTimelines();
+
}
+
}, 100);
+
});
+
+
// Bulk rendering
+
let renderedImages = {};
+
+
function renderAllTimelines() {
+
if (!baseImg || !matteImg) {
+
showStatus("Please upload both images first!", "error");
+
return;
+
}
+
+
if (Object.keys(timelines).length === 0) {
+
showStatus("No timelines to render!", "error");
+
return;
+
}
+
+
// Clear previous renders
+
renderedImages = {};
+
+
// Show progress bar
+
document.getElementById("bulkProgress").style.display = "block";
+
document.getElementById("downloadAllBtn").disabled = true;
+
+
// Clear gallery
+
document.getElementById("galleryContent").innerHTML = "";
+
+
// Count total hours to render
+
const timelineNames = Object.keys(timelines);
+
let totalHours = 0;
+
let currentHour = 0;
+
+
timelineNames.forEach((timelineName) => {
+
const hours = Object.keys(timelines[timelineName]);
+
totalHours += hours.length;
+
});
+
+
updateProgress(0, totalHours);
+
updateGalleryInfo(0, totalHours, timelineNames.length);
+
showStatus(
+
`Rendering all timelines: ${timelineNames.length} timelines, ${totalHours} hours total`,
+
"success",
+
);
+
+
// Render all timelines sequentially
+
let timelineIndex = 0;
+
+
function renderNextTimeline() {
+
if (timelineIndex >= timelineNames.length) {
+
// All done!
+
document.getElementById("downloadAllBtn").disabled =
+
false;
+
showStatus(
+
`Render complete! ${totalHours} images rendered.`,
+
"success",
+
);
+
return;
+
}
+
+
const timelineName = timelineNames[timelineIndex];
+
const timelineConfig = timelines[timelineName];
+
const hours = Object.keys(timelineConfig).sort(
+
(a, b) => parseInt(a) - parseInt(b),
+
);
+
+
renderedImages[timelineName] = {};
+
+
let hourIndex = 0;
+
+
function renderNextHour() {
+
if (hourIndex >= hours.length) {
+
// Timeline done, move to next
+
timelineIndex++;
+
setTimeout(renderNextTimeline, 10);
+
return;
+
}
+
+
const hour = hours[hourIndex];
+
const hourConfig = timelineConfig[hour];
+
+
// Render this hour
+
const imageData = renderHourToDataURL(hourConfig);
+
if (imageData) {
+
renderedImages[timelineName][hour] = imageData;
+
currentHour++;
+
updateProgress(currentHour, totalHours);
+
updateGalleryInfo(
+
currentHour,
+
totalHours,
+
timelineNames.length,
+
);
+
+
// Add to gallery
+
addToGallery(timelineName, hour, imageData);
+
}
+
+
hourIndex++;
+
// Small delay to keep UI responsive
+
setTimeout(renderNextHour, 100);
+
}
+
+
renderNextHour();
+
}
+
+
renderNextTimeline();
+
}
+
+
function addToGallery(timelineName, hour, imageDataURL) {
+
const gallery = document.getElementById("galleryContent");
+
+
// Remove placeholder if it exists
+
const placeholder =
+
document.getElementById("galleryPlaceholder");
+
if (placeholder) {
+
placeholder.remove();
+
// Reset gallery styles
+
gallery.style.minHeight = "auto";
+
gallery.style.border = "none";
+
gallery.style.alignItems = "stretch";
+
gallery.style.justifyContent = "stretch";
+
}
+
+
const item = document.createElement("div");
+
item.style.cssText = `
+
border: 1px solid #ddd;
+
border-radius: 8px;
+
overflow: hidden;
+
background: white;
+
transition: transform 0.2s, box-shadow 0.2s;
+
cursor: pointer;
+
`;
+
+
const hourPadded = hour.toString().padStart(2, "0");
+
+
item.innerHTML = `
+
<img src="${imageDataURL}" style="width: 100%; height: 80px; object-fit: cover;">
+
<div style="padding: 8px; text-align: center;">
+
<div style="font-size: 11px; font-weight: bold; color: #333;">${timelineName}</div>
+
<div style="font-size: 10px; color: #666;">Hour ${hourPadded}</div>
+
</div>
+
`;
+
+
// Add hover effect
+
item.addEventListener("mouseenter", () => {
+
item.style.transform = "scale(1.05)";
+
item.style.boxShadow = "0 4px 15px rgba(0,0,0,0.2)";
+
});
+
+
item.addEventListener("mouseleave", () => {
+
item.style.transform = "scale(1)";
+
item.style.boxShadow = "none";
+
});
+
+
// Click to download individual image
+
item.addEventListener("click", () => {
+
const link = document.createElement("a");
+
link.href = imageDataURL;
+
link.download = `${timelineName}_hour_${hourPadded}.jpg`;
+
link.click();
+
showStatus(
+
`Downloaded ${timelineName} hour ${hourPadded}`,
+
"success",
+
);
+
});
+
+
gallery.appendChild(item);
+
}
+
+
function updateGalleryInfo(current, total, timelineCount) {
+
const info = document.getElementById("galleryInfo");
+
info.textContent = `${current}/${total} images rendered across ${timelineCount} timeline(s)`;
+
}
+
+
function clearGallery() {
+
const gallery = document.getElementById("galleryContent");
+
gallery.innerHTML = "";
+
+
// Reset gallery to placeholder state
+
gallery.style.minHeight = "100px";
+
gallery.style.border = "2px dashed #ddd";
+
gallery.style.alignItems = "center";
+
gallery.style.justifyContent = "center";
+
gallery.style.padding = "20px";
+
+
// Add placeholder back
+
const placeholder = document.createElement("div");
+
placeholder.id = "galleryPlaceholder";
+
placeholder.style.cssText =
+
"grid-column: 1 / -1; text-align: center; color: #999; font-style: italic;";
+
placeholder.textContent =
+
'Click "Render All" to generate images and see them here';
+
gallery.appendChild(placeholder);
+
+
// Reset state
+
renderedImages = {};
+
document.getElementById("downloadAllBtn").disabled = true;
+
document.getElementById("galleryInfo").textContent =
+
"Ready to render timelines";
+
showStatus("Gallery cleared", "success");
+
}
+
+
function renderAllFromConfig() {
+
if (!baseImg || !matteImg) {
+
showStatus("Please upload both images first!", "error");
+
return;
+
}
+
+
const configText = document
+
.getElementById("bulkConfigInput")
+
.value.trim();
+
if (!configText) {
+
showStatus("Please paste a config to render!", "error");
+
return;
+
}
+
+
let config;
+
try {
+
config = JSON.parse(configText);
+
} catch (e) {
+
showStatus("Invalid JSON config!", "error");
+
return;
+
}
+
+
// Validate config structure
+
if (!config.timelines) {
+
showStatus(
+
'Config must have "timelines" property!',
+
"error",
+
);
+
return;
+
}
+
+
// Clear previous renders
+
renderedImages = {};
+
+
// Show progress bar
+
document.getElementById("bulkProgress").style.display = "block";
+
document.getElementById("downloadAllBtn").disabled = true;
+
+
// Count total hours to render
+
const timelines = Object.keys(config.timelines);
+
let totalHours = 0;
+
let currentHour = 0;
+
+
timelines.forEach((timeline) => {
+
const hours = Object.keys(config.timelines[timeline]);
+
totalHours += hours.length;
+
});
+
+
updateProgress(0, totalHours);
+
showStatus(
+
`Starting bulk render: ${timelines.length} timelines, ${totalHours} hours total`,
+
"success",
+
);
+
+
// Render all timelines sequentially with small delays for UI responsiveness
+
let timelineIndex = 0;
+
+
function renderNextTimeline() {
+
if (timelineIndex >= timelines.length) {
+
// All done!
+
document.getElementById("downloadAllBtn").disabled =
+
false;
+
showStatus(
+
`Bulk render complete! ${totalHours} images rendered.`,
+
"success",
+
);
+
return;
+
}
+
+
const timelineName = timelines[timelineIndex];
+
const timelineConfig = config.timelines[timelineName];
+
const hours = Object.keys(timelineConfig);
+
+
renderedImages[timelineName] = {};
+
+
let hourIndex = 0;
+
+
function renderNextHour() {
+
if (hourIndex >= hours.length) {
+
// Timeline done, move to next
+
timelineIndex++;
+
setTimeout(renderNextTimeline, 10);
+
return;
+
}
+
+
const hour = hours[hourIndex];
+
const hourConfig = timelineConfig[hour];
+
+
// Render this hour
+
const imageData = renderHourToDataURL(hourConfig);
+
if (imageData) {
+
renderedImages[timelineName][hour] = imageData;
+
currentHour++;
+
updateProgress(currentHour, totalHours);
+
}
+
+
hourIndex++;
+
// Small delay to keep UI responsive
+
setTimeout(renderNextHour, 50);
+
}
+
+
renderNextHour();
+
}
+
+
renderNextTimeline();
+
}
+
+
function renderHourToDataURL(config) {
+
try {
+
// Create a temporary canvas for this render
+
const canvas = document.createElement("canvas");
+
const ctx = canvas.getContext("2d");
+
+
// Set canvas size to match base image
+
canvas.width = baseImg.width;
+
canvas.height = baseImg.height;
+
+
// Create gradient
+
const gradient = createGradient(
+
canvas.width,
+
canvas.height,
+
config.color1,
+
config.color2,
+
);
+
+
// Clear canvas
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+
// Create background layer
+
const backgroundLayer = blendImages(
+
baseImg,
+
gradient,
+
config.backgroundIntensity,
+
);
+
ctx.drawImage(backgroundLayer, 0, 0);
+
+
// Create and apply foreground layer
+
let foregroundLayer;
+
if (config.foregroundIntensity === 0) {
+
foregroundLayer = baseImg;
+
} else {
+
foregroundLayer = blendImages(
+
baseImg,
+
gradient,
+
config.foregroundIntensity,
+
);
+
}
+
+
// Apply masking
+
const maskedForeground = document.createElement("canvas");
+
maskedForeground.width = canvas.width;
+
maskedForeground.height = canvas.height;
+
const maskCtx = maskedForeground.getContext("2d");
+
+
maskCtx.drawImage(foregroundLayer, 0, 0);
+
+
// Create proper alpha mask
+
const matteDataCanvas = document.createElement("canvas");
+
matteDataCanvas.width = matteImg.width;
+
matteDataCanvas.height = matteImg.height;
+
const matteDataCtx = matteDataCanvas.getContext("2d");
+
matteDataCtx.drawImage(matteImg, 0, 0);
+
+
const imageData = matteDataCtx.getImageData(
+
0,
+
0,
+
matteImg.width,
+
matteImg.height,
+
);
+
const data = imageData.data;
+
+
for (let i = 0; i < data.length; i += 4) {
+
const brightness =
+
(data[i] + data[i + 1] + data[i + 2]) / 3;
+
if (brightness > 128) {
+
data[i + 3] = 255;
+
} else {
+
data[i + 3] = 0;
+
}
+
data[i] = 255;
+
data[i + 1] = 255;
+
data[i + 2] = 255;
+
}
+
+
matteDataCtx.putImageData(imageData, 0, 0);
+
+
maskCtx.globalCompositeOperation = "destination-in";
+
maskCtx.drawImage(
+
matteDataCanvas,
+
0,
+
0,
+
canvas.width,
+
canvas.height,
+
);
+
+
ctx.globalCompositeOperation = "source-over";
+
ctx.drawImage(maskedForeground, 0, 0);
+
+
// Return as JPEG data URL
+
return canvas.toDataURL("image/jpeg", 0.95);
+
} catch (e) {
+
console.error("Failed to render hour:", e);
+
return null;
+
}
+
}
+
+
function updateProgress(current, total) {
+
const percentage = total > 0 ? (current / total) * 100 : 0;
+
document.getElementById("progressBar").style.width =
+
percentage + "%";
+
document.getElementById("progressText").textContent =
+
`${current}/${total}`;
+
}
+
+
async function downloadAllRendered() {
+
if (Object.keys(renderedImages).length === 0) {
+
showStatus("No rendered images to download!", "error");
+
return;
+
}
+
+
showStatus("Preparing download...", "success");
+
+
// Simple approach: create individual downloads if ZIP fails
+
try {
+
// Try to load JSZip if not available
+
if (typeof JSZip === "undefined") {
+
showStatus("Loading ZIP library...", "success");
+
await loadJSZip();
+
}
+
+
await createZipDownload();
+
} catch (e) {
+
console.error("ZIP download failed:", e);
+
showStatus(
+
"ZIP failed, downloading individual files...",
+
"warning",
+
);
+
downloadIndividualFiles();
+
}
+
}
+
+
function loadJSZip() {
+
return new Promise((resolve, reject) => {
+
const script = document.createElement("script");
+
script.src =
+
"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js";
+
script.onload = resolve;
+
script.onerror = reject;
+
document.head.appendChild(script);
+
});
+
}
+
+
async function createZipDownload() {
+
const zip = new JSZip();
+
+
// First, fetch and add the shell script to the root
+
try {
+
showStatus("Including shell script...", "success");
+
try {
+
// Try local copy first, then relative path, then original source as fallback
+
const scriptUrls = ["/pfp-updates/bsky-pfp-updates.sh"];
+
+
let scriptContent = null;
+
for (const url of scriptUrls) {
+
try {
+
const scriptResponse = await fetch(url);
+
if (scriptResponse.ok) {
+
scriptContent = await scriptResponse.text();
+
break;
+
}
+
} catch (err) {
+
console.log(
+
`Failed to fetch from ${url}:`,
+
err,
+
);
+
// Continue to next URL
+
}
+
}
+
+
if (scriptContent) {
+
zip.file("bsky-pfp-updates.sh", scriptContent);
+
} else {
+
throw new Error(
+
"Could not load script from any source",
+
);
+
}
+
} catch (error) {
+
showStatus(
+
"Warning: Could not include shell script, continuing without it...",
+
"warning",
+
);
+
console.error("Script loading error:", error);
+
}
+
} catch (e) {
+
console.error("Failed to fetch shell script:", e);
+
showStatus(
+
"Warning: Could not fetch shell script, continuing without it...",
+
"warning",
+
);
+
}
+
+
// Add the timeline config to the root
+
const config = {
+
timelines: timelines,
+
metadata: {
+
created: new Date().toISOString(),
+
tool: "Sky Gradient Timeline Builder",
+
version: "1.0",
+
},
+
};
+
const configText = JSON.stringify(config, null, 2);
+
zip.file("timeline_config.json", configText);
+
+
// Create rendered_timelines folder and add all images
+
const renderedFolder = zip.folder("rendered_timelines");
+
+
for (const [timelineName, timelineImages] of Object.entries(
+
renderedImages,
+
)) {
+
const timelineFolder = renderedFolder.folder(timelineName);
+
+
for (const [hour, imageDataURL] of Object.entries(
+
timelineImages,
+
)) {
+
// Convert data URL to binary data
+
const base64Data = imageDataURL.split(",")[1];
+
const binaryData = atob(base64Data);
+
const bytes = new Uint8Array(binaryData.length);
+
for (let i = 0; i < binaryData.length; i++) {
+
bytes[i] = binaryData.charCodeAt(i);
+
}
+
+
const hourPadded = hour.padStart(2, "0");
+
timelineFolder.file(`hour_${hourPadded}.jpg`, bytes);
+
}
+
}
+
+
// Generate and download ZIP
+
showStatus("Creating ZIP file...", "success");
+
const content = await zip.generateAsync({
+
type: "blob",
+
compression: "DEFLATE",
+
compressionOptions: { level: 6 },
+
});
+
+
// Create download link
+
const link = document.createElement("a");
+
const url = URL.createObjectURL(content);
+
link.href = url;
+
link.download = "bluesky-pfp-updates.zip";
+
+
// Trigger download
+
document.body.appendChild(link);
+
link.click();
+
document.body.removeChild(link);
+
+
// Cleanup
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
+
+
showStatus("ZIP file downloaded with shell script!", "success");
+
}
+
+
function importConfigToTimelines() {
+
const configText = document
+
.getElementById("bulkConfigInput")
+
.value.trim();
+
if (!configText) {
+
showStatus("Please paste a config to import!", "error");
+
return;
+
}
+
+
let config;
+
try {
+
config = JSON.parse(configText);
+
} catch (e) {
+
showStatus("Invalid JSON config!", "error");
+
return;
+
}
+
+
// Validate config structure
+
if (!config.timelines) {
+
showStatus(
+
'Config must have "timelines" property!',
+
"error",
+
);
+
return;
+
}
+
+
// Clear existing timelines (except keep one if empty)
+
const wasEmpty = Object.keys(timelines).length === 0;
+
timelines = {};
+
+
// Clear existing tabs
+
document.getElementById("timelineTabs").innerHTML = "";
+
+
// Import all timelines from config
+
const importedTimelines = Object.keys(config.timelines);
+
let importedCount = 0;
+
+
for (const [timelineName, timelineConfig] of Object.entries(
+
config.timelines,
+
)) {
+
// Validate timeline structure
+
if (typeof timelineConfig !== "object") {
+
showStatus(
+
`Invalid timeline structure for "${timelineName}"`,
+
"warning",
+
);
+
continue;
+
}
+
+
// Convert timeline config to our format
+
timelines[timelineName] = {};
+
+
for (const [hour, hourConfig] of Object.entries(
+
timelineConfig,
+
)) {
+
const hourNum = parseInt(hour);
+
if (
+
hourNum >= 0 &&
+
hourNum <= 23 &&
+
hourConfig.color1 &&
+
hourConfig.color2
+
) {
+
timelines[timelineName][hourNum] = {
+
color1: hourConfig.color1,
+
color2: hourConfig.color2,
+
backgroundIntensity:
+
hourConfig.backgroundIntensity || 40,
+
foregroundIntensity:
+
hourConfig.foregroundIntensity || 8,
+
};
+
}
+
}
+
+
// Create tab for this timeline
+
const tab = document.createElement("div");
+
tab.className = "timeline-tab";
+
tab.dataset.timeline = timelineName;
+
tab.textContent = timelineName;
+
tab.onclick = () => switchTimeline(timelineName);
+
document.getElementById("timelineTabs").appendChild(tab);
+
+
importedCount++;
+
}
+
+
if (importedCount === 0) {
+
showStatus("No valid timelines found in config!", "error");
+
// Restore default if nothing imported
+
timelines.sunny = {};
+
for (let hour = 0; hour < 24; hour++) {
+
timelines.sunny[hour] = getDefaultConfigForHour(hour);
+
}
+
const tab = document.createElement("div");
+
tab.className = "timeline-tab active";
+
tab.dataset.timeline = "sunny";
+
tab.textContent = "sunny";
+
tab.onclick = () => switchTimeline("sunny");
+
document.getElementById("timelineTabs").appendChild(tab);
+
currentTimeline = "sunny";
+
} else {
+
// Switch to first imported timeline
+
const firstTimeline = importedTimelines[0];
+
currentTimeline = firstTimeline;
+
document
+
.querySelector(`[data-timeline="${firstTimeline}"]`)
+
.classList.add("active");
+
document.getElementById("currentTimelineName").textContent =
+
firstTimeline;
+
}
+
+
// Update UI
+
initializeTimeline();
+
+
showStatus(
+
`Successfully imported ${importedCount} timeline(s): ${importedTimelines.join(", ")}`,
+
"success",
+
);
+
+
// Clear the config input
+
document.getElementById("bulkConfigInput").value = "";
+
}
+
+
function downloadIndividualFiles() {
+
let downloadCount = 0;
+
const totalFiles = Object.values(renderedImages).reduce(
+
(sum, timeline) => sum + Object.keys(timeline).length,
+
0,
+
);
+
+
for (const [timelineName, timelineImages] of Object.entries(
+
renderedImages,
+
)) {
+
for (const [hour, imageDataURL] of Object.entries(
+
timelineImages,
+
)) {
+
const hourPadded = hour.padStart(2, "0");
+
const filename = `${timelineName}_hour_${hourPadded}.jpg`;
+
+
// Create download link
+
const link = document.createElement("a");
+
link.href = imageDataURL;
+
link.download = filename;
+
+
// Trigger download with small delay
+
setTimeout(() => {
+
document.body.appendChild(link);
+
link.click();
+
document.body.removeChild(link);
+
downloadCount++;
+
+
if (downloadCount === totalFiles) {
+
showStatus(
+
`Downloaded ${totalFiles} individual files!`,
+
"success",
+
);
+
}
+
}, downloadCount * 100); // 100ms delay between downloads
+
}
+
}
+
+
showStatus(
+
`Starting ${totalFiles} individual downloads...`,
+
"success",
+
);
+
}
+
+
function copyToClipboard() {
+
const textarea = document.getElementById("configOutput");
+
if (!textarea.value.trim()) {
+
showStatus(
+
"Nothing to copy! Generate a config first.",
+
"error",
+
);
+
return;
+
}
+
+
textarea.select();
+
document.execCommand("copy");
+
+
// Try the modern API as fallback
+
if (navigator.clipboard) {
+
navigator.clipboard
+
.writeText(textarea.value)
+
.then(() => {
+
showStatus(
+
"Config copied to clipboard!",
+
"success",
+
);
+
})
+
.catch(() => {
+
showStatus(
+
"Please manually copy the text",
+
"error",
+
);
+
});
+
} else {
+
showStatus(
+
"Config selected - press Ctrl+C to copy",
+
"success",
+
);
+
}
+
}
+
+
function showStatus(message, type) {
+
const status = document.getElementById("uploadStatus");
+
status.textContent = message;
+
status.className = `status ${type}`;
+
setTimeout(() => (status.style.display = "none"), 3000);
+
}
+
+
// Image upload handlers
+
document
+
.getElementById("baseImage")
+
.addEventListener("change", function (e) {
+
const file = e.target.files[0];
+
if (file) {
+
const reader = new FileReader();
+
reader.onload = function (e) {
+
const img = new Image();
+
img.onload = function () {
+
baseImg = img;
+
showStatus("Base image loaded!", "success");
+
updatePreview();
+
};
+
img.src = e.target.result;
+
};
+
reader.readAsDataURL(file);
+
}
+
});
+
+
document
+
.getElementById("matteImage")
+
.addEventListener("change", function (e) {
+
const file = e.target.files[0];
+
if (file) {
+
const reader = new FileReader();
+
reader.onload = function (e) {
+
const img = new Image();
+
img.onload = function () {
+
matteImg = img;
+
showStatus("Matte loaded!", "success");
+
updatePreview();
+
};
+
img.src = e.target.result;
+
};
+
reader.readAsDataURL(file);
+
}
+
});
+
+
// Slider updates
+
document
+
.getElementById("bgIntensity")
+
.addEventListener("input", function () {
+
document.getElementById("bgValue").textContent =
+
this.value + "%";
+
updatePreview();
+
});
+
+
document
+
.getElementById("fgIntensity")
+
.addEventListener("input", function () {
+
document.getElementById("fgValue").textContent =
+
this.value + "%";
+
updatePreview();
+
});
+
+
document
+
.getElementById("color1")
+
.addEventListener("change", updatePreview);
+
document
+
.getElementById("color2")
+
.addEventListener("change", updatePreview);
+
+
function updatePreview() {
+
if (!baseImg || !matteImg) return;
+
+
const canvas = document.getElementById("previewCanvas");
+
const ctx = canvas.getContext("2d");
+
+
const color1 = document.getElementById("color1").value;
+
const color2 = document.getElementById("color2").value;
+
const bgIntensity = parseInt(
+
document.getElementById("bgIntensity").value,
+
);
+
const fgIntensity = parseInt(
+
document.getElementById("fgIntensity").value,
+
);
+
+
// Create gradient
+
const gradient = createGradient(
+
canvas.width,
+
canvas.height,
+
color1,
+
color2,
+
);
+
+
// Clear canvas
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+
// Create background layer
+
const backgroundLayer = blendImages(
+
baseImg,
+
gradient,
+
bgIntensity,
+
);
+
ctx.drawImage(
+
backgroundLayer,
+
0,
+
0,
+
canvas.width,
+
canvas.height,
+
);
+
+
// Create and apply foreground layer
+
let foregroundLayer;
+
if (fgIntensity === 0) {
+
foregroundLayer = baseImg;
+
} else {
+
foregroundLayer = blendImages(
+
baseImg,
+
gradient,
+
fgIntensity,
+
);
+
}
+
+
// Apply masking
+
const maskedForeground = document.createElement("canvas");
+
maskedForeground.width = canvas.width;
+
maskedForeground.height = canvas.height;
+
const maskCtx = maskedForeground.getContext("2d");
+
+
maskCtx.drawImage(
+
foregroundLayer,
+
0,
+
0,
+
canvas.width,
+
canvas.height,
+
);
+
+
// Create proper alpha mask
+
const matteDataCanvas = document.createElement("canvas");
+
matteDataCanvas.width = matteImg.width;
+
matteDataCanvas.height = matteImg.height;
+
const matteDataCtx = matteDataCanvas.getContext("2d");
+
matteDataCtx.drawImage(matteImg, 0, 0);
+
+
const imageData = matteDataCtx.getImageData(
+
0,
+
0,
+
matteImg.width,
+
matteImg.height,
+
);
+
const data = imageData.data;
+
+
for (let i = 0; i < data.length; i += 4) {
+
const brightness =
+
(data[i] + data[i + 1] + data[i + 2]) / 3;
+
if (brightness > 128) {
+
data[i + 3] = 255;
+
} else {
+
data[i + 3] = 0;
+
}
+
data[i] = 255;
+
data[i + 1] = 255;
+
data[i + 2] = 255;
+
}
+
+
matteDataCtx.putImageData(imageData, 0, 0);
+
+
maskCtx.globalCompositeOperation = "destination-in";
+
maskCtx.drawImage(
+
matteDataCanvas,
+
0,
+
0,
+
canvas.width,
+
canvas.height,
+
);
+
+
ctx.globalCompositeOperation = "source-over";
+
ctx.drawImage(maskedForeground, 0, 0);
+
}
+
+
function createGradient(width, height, color1, color2) {
+
const gradCanvas = document.createElement("canvas");
+
gradCanvas.width = width;
+
gradCanvas.height = height;
+
const gradCtx = gradCanvas.getContext("2d");
+
+
const gradient = gradCtx.createLinearGradient(0, 0, 0, height);
+
gradient.addColorStop(0, color1);
+
gradient.addColorStop(1, color2);
+
+
gradCtx.fillStyle = gradient;
+
gradCtx.fillRect(0, 0, width, height);
+
+
return gradCanvas;
+
}
+
+
function blendImages(base, overlay, intensity) {
+
const blendCanvas = document.createElement("canvas");
+
blendCanvas.width = base.width;
+
blendCanvas.height = base.height;
+
const blendCtx = blendCanvas.getContext("2d");
+
+
blendCtx.drawImage(base, 0, 0);
+
blendCtx.globalCompositeOperation = "overlay";
+
blendCtx.globalAlpha = intensity / 100;
+
blendCtx.drawImage(overlay, 0, 0, base.width, base.height);
+
+
return blendCanvas;
+
}
+
+
// Initialize
+
initializeTimeline();
+
</script>
+
</body>
+
</html>