Mac Themes Garden: post-launch updates

Post-launch stuff
It has been a fun two weeks since I launched Mac Themes Garden!
I had published my little blog post, posted about it on Mastodon and Bluesky at like 9pm without thinking much of it.
Only to wake up to being on Hacker News?! The (in)famous Orange Site! And somehow the thread was full of people just reminiscing and sharing some of their favorite themes they looked for on the site?! Bonkers stuff. But I'm not complaining.
Made for a funny set of stats, let me tell you:


Then Boing Boing posted about it, so did Michael Tsai, and then today (as I write this) 9to5Mac also posted about it. I almost expect a second, much smaller wave, of visits if other websites pick it up from 9to5Mac.
Wild stuff, but it has been great to see the response to the website, pretty rewarding and motivating!
Some updates!
Right as I launched the site, John Siracusa made a very good suggestion:
So implementing these suggestions (sorting and "likes") has been what I've been doing for the past week and a half.
Sorting
Sorting was relatively straightforward, I just had to get a bit creative because I didn't want to use Astro's server rendering just to sort items. So I used the nested pagination mechanism to generate the ~320 pages necessary, not too bad:
---import type { GetStaticPaths } from "astro";import { getCollection } from "astro:content";import IndexPage from "../../components/IndexPage.astro";import { PAGINATION, slugFromSortAndOrder, sortThemes } from "../../helpers";import { possibleSortSlugs } from "../../helpers";export const getStaticPaths = (async ({ paginate }) => { const themes = await getCollection("themes");
return possibleSortSlugs.flatMap((slug) => { const { order, sort } = slugFromSortAndOrder(slug); return paginate(sortThemes(themes, sort, order), { pageSize: PAGINATION.size, params: { sort: slug }, }); });}) satisfies GetStaticPaths;
const { page } = Astro.props;const { order, sort } = slugFromSortAndOrder(Astro.params.sort);---
<IndexPage page={page} order={order} sort={sort} />User likes
Likes... were a Whole Thing as I expected. I knew I wanted to do two things:
- Accept "user" likes. Users are "identified" using a UUID v5 generated with their IP address as a namespace, making for a convenient hash. It's not perfect, but I'm not going to bother setting up an actual user login system just for a little "like" button.
- Use the likes on posts from the Mastodon and Bluesky profiles.
This was a perfect excuse to get familiar with Astro DB.
My database schema is relatively simple:
- A
Liketable with the following columns:id(unique/primary key) which is a UUID v4themeId, which references a specific theme in theThemetableuserId
- A
Themetable with:- a
idcolumn which corresponds to the actual IDs I give each theme
- a
- A
UserRequesttable with auserIdand adatecolumn, this is used to do basic rate-limiting on the "like" action.
The aforementioned action's code is a bit verbose, but the logic is relatively simple:
- Receive a user request
- Do basic validation to ensure the
themeIdbeing set matches one of the themes - Check against the rate-limiter to see if the user is allowed to perform the action
- Look if the theme being liked already has a corresponding row in
Themeand if not, insert it. - Insert/delete a row from the
Liketable depending on whether the user is liking/unliking the theme. - Return the total likes count for the theme and the
likedstate for the current user. - Have the UI update as the action is performed with a simple Preact component
Mastodon/Bluesky likes
That's for user-generated likes, next is pulling the likes from Mastodon and Bluesky.
I went with solutions that one might call "lazy" or "stupid", to which I would answer "if it is stupid, and it works, then it's not stupid".
Since I host my own Mastodon instance, I can bypass the API entirely to get the basis of what I need data-wise. So I wrote a little script that calls PostgreSQL and generates a JSON file that looks like this:
[ { "text": "Orange'N'Blue - John Bloor\nhttps://macthemes.garden/themes/6471b22c97c7-OrangeNBlue", "reblogs_count": 2, "favourites_count": 6 }, { "text": "fvwMac green 1.09 - Alexander E. Ribbe\nhttps://macthemes.garden/themes/739da6aef9f0-fvwMac-green-109", "reblogs_count": 0, "favourites_count": 2 }, // and so on...]The script is basically one cursed SQL query, behold:
psql --dbname=mastodon_production -c "SELECT json_agg(json_build_object('text', s.text, 'reblogs_count', ss.reblogs_count, 'favourites_count', ss.favourites_count)) FROM statuses s JOIN status_stats ss ON s.id=ss.status_id WHERE s.account_id=113370184750103599 AND s.in_reply_to_id IS NULL AND s.text LIKE '%https://macthemes.garden/themes/%' AND (ss.reblogs_count>0 OR ss.favourites_count>0);" -t -A -o /home/mastodon/live/public/macthemes-posts-stats.jsonThe file is then made available publicly on my instance's server and is consumed by a script that runs periodically in a GitHub action that will grab the URLs, extract the IDs, add the reblogs and favorites and make one big JSON map that will be used at build-time to add onto the like count.
import fs from "fs-extra";
// File generated dailyconst stats = await fetch( "https://social.erambert.me/macthemes-posts-stats.json",);const statsObject: { text: string; reblogs_count: number; favourites_count: number;}[] = await stats.json();
const urlRegex = new RegExp("https://macthemes.garden/themes/([a-z0-9]+)", "i");const likesByThemeIds = statsObject .map((obj) => { const [, themeId] = obj.text.match(urlRegex) || [];
if (!themeId) { return undefined; } return { ...obj, themeId }; }) .filter(Boolean) .reduce((prev, curr) => { if (!curr?.themeId) { return {}; } return { ...prev, [curr?.themeId]: (prev[curr.themeId] || 0) + (curr.favourites_count || 0) + (curr.reblogs_count || 0), }; }, {});
await fs.writeFile( new URL(import.meta.resolve("../src/themes/likes-mastodon.json")).pathname, JSON.stringify( { likes: likesByThemeIds, }, null, 2, ), "utf-8",);For Bluesky, I'm doing something that feels like it shouldn't work, but it does and is much less annoying to write than the "correct" way so I'll take it. I'm abusing the AT Proto repo export mechanism to get an entire archive of the bot's account, I parse it as a collection of records and fetch the corresponding posts from the Bluesky API to get the necessary metrics. It's a bit silly, but it feels more efficient than having to query multiple pages of posts to maybe find the right ones.
import { iterateAtpRepo } from "@atcute/car";import { Client, CredentialManager } from "@atcute/client";import fs from "fs-extra";import { chunk } from "lodash-es";// import lexiconsimport type {} from "@atcute/atproto";import type {} from "@atcute/bluesky";
const actor = "did:plc:a5j6hkim467cvi4rzouh6aei";const manager = new CredentialManager({ service: "https://bsky.social" });const rpc = new Client({ handler: manager });
await manager.login({ identifier: process.env.BLUESKY_USERNAME || "", password: process.env.BLUESKY_PASSWORD || "",});
const { data, ok } = await rpc.get("com.atproto.sync.getRepo", { as: "bytes", params: { did: actor, },});
if (!ok) { process.exit(1);}
const urlRegex = new RegExp("https://macthemes.garden/themes/([a-z0-9]+)", "i");const records: { rkey: string; themeId: string }[] = [];// convenient iterator for reading through an AT Protocol CAR repositoryfor (const { collection, rkey, record } of iterateAtpRepo(data)) { if (collection === "app.bsky.feed.post") { if ( (record as any).facets?.[0].features?.[0].$type === "app.bsky.richtext.facet#link" ) { if ( (record as any).facets?.[0].features?.[0].uri.startsWith( "https://macthemes.garden/themes/", ) ) { const [, themeId] = String((record as any).facets?.[0].features?.[0].uri).match( urlRegex, ) || []; if (themeId) { records.push({ themeId, rkey }); } } } }}
let likesByThemeIds: Record<string, number> = {};
for (const recordsChunk of chunk(records, 25)) { const { data, ok } = await rpc.get("app.bsky.feed.getPosts", { params: { uris: recordsChunk.map((r) => makeUri(r.rkey)), }, }); if (ok) { data.posts.forEach((post) => { const themeIdForPost = recordsChunk.find((r) => { return makeUri(r.rkey) === post.uri; })?.themeId;
if (themeIdForPost) { likesByThemeIds[themeIdForPost] = (likesByThemeIds[themeIdForPost] || 0) + (post.likeCount || 0) + (post.repostCount || 0); } }); }}
await fs.writeFile( new URL(import.meta.resolve("../src/themes/likes-bsky.json")).pathname, JSON.stringify( { likes: likesByThemeIds, }, null, 2, ), "utf-8",);
function makeUri(rkey: string): any { return `at://${actor}/app.bsky.feed.post/${rkey}`;}And just like the Mastodon script, it is running periodically in a GitHub action, so the data is refreshed at least once a day.
Putting all this together
A few tweaks of the theme grid layout and some cute icons drawn by my friend Sage later, I got myself a cute "likes" view on the theme grid!

And that's it for the updates! I got other ideas, but they will be a bit more involved so they might take some time to execute, so stay tuned! And keep sharing the good themes around ❤️
Cheers,
- Damien