// ==UserScript== // @name TRMNL Master Recipe Badges // @namespace https://github.com/ExcuseMi/trmnl-userscripts // @version 1.5.7 // @description Add install and forks badges to Recipe Master plugins on list page and edit page // @author ExcuseMi // @match https://trmnl.com/* // @icon https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/refs/heads/main/images/trmnl.svg // @downloadURL https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/main/master-recipe-badge.user.js // @updateURL https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/main/master-recipe-badge.user.js // @grant none // ==/UserScript== (function() { 'use strict'; const LOG_PREFIX = '[TRMNL Recipe Badges]'; const log = (...args) => console.log(LOG_PREFIX, ...args); function isDarkMode() { return document.documentElement.classList.contains('dark'); } function badgeColorParams() { return isDarkMode() ? '' : '&glyph=white&color=000000&labelColor=77767B'; } function updateAllBadgeColors() { document.querySelectorAll('img[data-badge-base]').forEach(img => { img.src = `${img.dataset.badgeBase}${badgeColorParams()}`; }); } new MutationObserver(updateAllBadgeColors) .observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); // Original list page functionality const LIST_PATH = '/plugin_settings'; const LIST_PARAM = 'keyname=private_plugin'; const BADGE_ATTR = 'data-trmnl-recipe-badge'; // Edit page functionality const EDIT_PATH_PATTERN = /\/plugin_settings\/(\d+)\/edit/; function isListPage() { return location.pathname === LIST_PATH && location.search.includes(LIST_PARAM); } function isEditPage() { return EDIT_PATH_PATTERN.test(location.pathname); } function getPluginIdFromEditUrl() { const match = location.pathname.match(EDIT_PATH_PATTERN); return match ? match[1] : null; } // Check if delete button exists on edit page function hasDeleteButton() { return document.querySelector('button[form="delete_plugin_form"]') !== null; } // Add badges to edit page in the action buttons container function addEditPageBadges(pluginId) { // Find the action buttons container const actionsContainer = document.querySelector('.flex.justify-start.sm\\:justify-end.items-center.shrink-0.gap-3.flex-wrap'); if (!actionsContainer) { log('Edit page: Actions container not found'); return false; } // Check if badges already exist if (actionsContainer.querySelector('[data-trmnl-edit-badge]')) { return true; } // Create badges container const badgesContainer = document.createElement('div'); badgesContainer.setAttribute('data-trmnl-edit-badge', 'true'); badgesContainer.className = 'flex items-center gap-2 mr-2 ml-2'; // Create installs badge const installsLink = document.createElement('a'); installsLink.href = `https://trmnl.com/recipes/${pluginId}`; installsLink.target = '_blank'; installsLink.rel = 'noopener noreferrer'; installsLink.title = 'View recipe installs'; const installsImg = document.createElement('img'); const installsBase = `https://trmnl-badges.gohk.xyz/badge/installs?recipe=${pluginId}&pretty`; installsImg.dataset.badgeBase = installsBase; installsImg.src = `${installsBase}&${badgeColorParams()}`; installsImg.alt = 'Installs'; installsImg.className = 'h-9 w-auto inline-block'; installsLink.appendChild(installsImg); badgesContainer.appendChild(installsLink); // Create forks badge const forksLink = document.createElement('a'); forksLink.href = `https://trmnl.com/recipes/${pluginId}`; forksLink.target = '_blank'; forksLink.rel = 'noopener noreferrer'; forksLink.title = 'View recipe forks'; const forksImg = document.createElement('img'); const forksBase = `https://trmnl-badges.gohk.xyz/badge/forks?recipe=${pluginId}&pretty`; forksImg.dataset.badgeBase = forksBase; forksImg.src = `${forksBase}&${badgeColorParams()}`; forksImg.alt = 'Forks'; forksImg.className = 'h-9 w-auto inline-block'; forksLink.appendChild(forksImg); badgesContainer.appendChild(forksLink); // Insert at the beginning of the actions container actionsContainer.insertBefore(badgesContainer, actionsContainer.firstChild); log(`Edit page: Installs and forks badges added for plugin ${pluginId}`); return true; } // Handle edit page — returns true when done (badges added or definitively skipped) function handleEditPage() { const pluginId = getPluginIdFromEditUrl(); if (!pluginId) { log('Edit page: Could not extract plugin ID from URL'); return true; // nothing to do } if (hasDeleteButton()) { log('Edit page: Delete button found, skipping badges'); return true; // definitively not a Recipe Master } // Actions container not yet in DOM — wait for it const actionsContainer = document.querySelector('.flex.justify-start.sm\\:justify-end.items-center.shrink-0.gap-3.flex-wrap'); if (!actionsContainer) return false; return addEditPageBadges(pluginId); } function waitForEditPage() { if (handleEditPage()) return; const observeTarget = document.querySelector('main') || document.body; log('Edit page: Actions container not ready, observing:', observeTarget.tagName); const observer = new MutationObserver(() => { if (handleEditPage()) { observer.disconnect(); } }); observer.observe(observeTarget, { childList: true, subtree: true }); setTimeout(() => observer.disconnect(), 15_000); } // Original list page functions function waitForPluginList() { trySetup(); const observeTarget = document.querySelector('[data-controller="plugin-settings"]') || document.documentElement; log('List page: Starting observer on:', observeTarget.tagName); // Keep observing — rows or their badge spans may load after initial query. // BADGE_ATTR guards prevent double-processing. const observer = new MutationObserver(() => trySetup()); observer.observe(observeTarget, { childList: true, subtree: true }); // Safety disconnect after 60s setTimeout(() => observer.disconnect(), 60_000); } function trySetup() { const pluginRows = document.querySelectorAll('[data-action*="plugin-settings#editSetting"]'); if (pluginRows.length === 0) { log('List page: No plugin rows found yet.'); return false; } let added = 0; pluginRows.forEach(row => { if (row.getAttribute(BADGE_ATTR) === 'done') return; // Look for any inline badge element whose text is exactly 'Recipe Master'. // Avoids relying on a brittle list of Tailwind classes that can change. const badgeSpan = Array.from(row.querySelectorAll('span,div')) .find(el => el.children.length === 0 && el.textContent.trim() === 'Recipe Master'); // If the badge span hasn't rendered yet, leave the row unmarked so the // observer can retry on the next mutation. if (!badgeSpan) return; const pluginId = row.getAttribute('data-plugin-settings-id'); if (!pluginId) { log('List page: Recipe Master row has no plugin ID, skipping.'); row.setAttribute(BADGE_ATTR, 'no-id'); return; } const actionsDiv = row.closest('.flex.items-center.text-sm.cursor-pointer') ?.querySelector('.flex.items-center.flex-shrink-0'); // actionsDiv may still be loading — leave row unmarked so observer retries. if (!actionsDiv) { log(`List page: Plugin ${pluginId}: actions div not yet in DOM, will retry.`); return; } const badgeContainer = document.createElement('div'); badgeContainer.className = 'flex items-center gap-1 px-1'; const installsLink = document.createElement('a'); installsLink.href = `https://trmnl.com/recipes/${pluginId}`; installsLink.target = '_blank'; installsLink.rel = 'noopener noreferrer'; const installsImg = document.createElement('img'); const installsBase = `https://trmnl-badges.gohk.xyz/badge/installs?recipe=${pluginId}&pretty`; installsImg.dataset.badgeBase = installsBase; installsImg.src = `${installsBase}&${badgeColorParams()}`; installsImg.alt = 'Installs'; installsImg.className = 'h-6 w-auto inline-block'; installsLink.appendChild(installsImg); badgeContainer.appendChild(installsLink); const forksLink = document.createElement('a'); forksLink.href = `https://trmnl.com/recipes/${pluginId}`; forksLink.target = '_blank'; forksLink.rel = 'noopener noreferrer'; const forksImg = document.createElement('img'); const forksBase = `https://trmnl-badges.gohk.xyz/badge/forks?recipe=${pluginId}&pretty`; forksImg.dataset.badgeBase = forksBase; forksImg.src = `${forksBase}&${badgeColorParams()}`; forksImg.alt = 'Forks'; forksImg.className = 'h-6 w-auto inline-block'; forksLink.appendChild(forksImg); badgeContainer.appendChild(forksLink); actionsDiv.prepend(badgeContainer); row.setAttribute(BADGE_ATTR, 'done'); added++; log(`List page: Badges added for plugin ${pluginId}.`); }); log(`List page: trySetup: ${added} badge(s) added, ${pluginRows.length} row(s) total.`); return added > 0; } // Main navigation handler function onNavigate() { if (isListPage()) { log('On list page. URL:', location.href); waitForPluginList(); } else if (isEditPage()) { log('On edit page. URL:', location.href); waitForEditPage(); } } log('Script loaded. readyState:', document.readyState); document.addEventListener('turbo:load', () => { log('turbo:load fired.'); onNavigate(); }); document.addEventListener('turbo:frame-load', () => { log('turbo:frame-load fired.'); onNavigate(); }); window.navigation?.addEventListener('navigate', () => { log('window.navigation navigate fired.'); // Defer so location reflects the new URL setTimeout(onNavigate, 0); }); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { log('DOMContentLoaded fired.'); onNavigate(); }); } else { onNavigate(); } })();