// ==UserScript== // @name TRMNL Private Plugin Categorizer // @namespace https://github.com/ExcuseMi/trmnl-userscripts // @version 1.1.5 // @description Add category filters and search to the private plugin page (with persistence, counters, and proper initial styling) // @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/private-plugin-organiser.user.js // @updateURL https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/main/private-plugin-organiser.user.js // @grant none // ==/UserScript== (function() { 'use strict'; const LOG_PREFIX = '[TRMNL Organiser]'; const log = (...args) => console.log(LOG_PREFIX, ...args); const warn = (...args) => console.warn(LOG_PREFIX, ...args); const FILTER_BAR_ID = 'trmnl-filter-bar'; const TARGET_PATH = '/plugin_settings'; const TARGET_PARAM = 'keyname=private_plugin'; function isTargetPage() { return location.pathname === TARGET_PATH && location.search.includes(TARGET_PARAM); } function onNavigate() { if (!isTargetPage()) { log('Not on target page, skipping. URL:', location.href); return; } log('On target page, starting observer. URL:', location.href); waitForPluginList(); } log('Script loaded. readyState:', document.readyState); // turbo:load — fires after every Turbo page visit (full or cached) // turbo:frame-load — fires when a finishes loading its content // Both can be the moment the plugin list first appears in the DOM. document.addEventListener('turbo:load', () => { log('turbo:load fired.'); onNavigate(); }); document.addEventListener('turbo:frame-load', () => { log('turbo:frame-load fired.'); if (!isTargetPage()) return; trySetup(); // filter bar guard inside trySetup handles duplicates }); // Handle regular (non-Turbo) page loads if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { log('DOMContentLoaded fired.'); onNavigate(); }); } else { onNavigate(); } // Wait for the plugin list to appear in the DOM. // NOTE: turbo:before-visit is intentionally NOT used to remove the filter bar, // because that would strip it from Turbo's page cache snapshot and force a // re-render on every back-navigation, causing the perceived delay. function waitForPluginList() { if (document.getElementById(FILTER_BAR_ID)) { log('Filter bar already present, skipping.'); return; } // Happy path: content already in DOM (direct nav or Turbo cache restore). if (trySetup()) return; // Fallback: content is inside a that hasn't finished loading. // Observe the controller element if present, otherwise the whole document. // turbo:frame-load will also fire and call trySetup(), so the observer // is mainly a safety net. const observeTarget = document.querySelector('[data-controller="plugin-settings"]') || document.documentElement; log('Content not ready, observing:', observeTarget.tagName, observeTarget.id || observeTarget.className.slice(0, 60)); const observer = new MutationObserver(() => { if (trySetup()) { observer.disconnect(); } }); observer.observe(observeTarget, { childList: true, subtree: true }); } function trySetup() { const stickyHeader = document.querySelector('.flex-grow.sticky.top-14'); if (!stickyHeader) { log('Sticky header not found yet (.flex-grow.sticky.top-14).'); return false; } const listContainer = document.querySelector('[data-controller="plugin-settings"] .flex.flex-col'); if (!listContainer) { log('List container not found yet ([data-controller="plugin-settings"] .flex.flex-col).'); return false; } const pluginItems = Array.from(listContainer.querySelectorAll('[data-action*="plugin-settings#editSetting"]')) .map(el => el.closest('.flex.items-center.text-sm.cursor-pointer')) .filter(row => row); if (pluginItems.length === 0) { log('No plugin items found yet. Waiting...'); return false; } // Already set up (guard against MutationObserver firing multiple times) if (document.getElementById(FILTER_BAR_ID)) { log('Filter bar already exists, skipping duplicate setup.'); return true; } log(`Found ${pluginItems.length} plugin items. Setting up filter bar.`); setup(stickyHeader, pluginItems); return true; } function setup(stickyHeader, pluginItems) { const plugins = pluginItems.map(row => { const titleEl = row.querySelector('h3'); const title = titleEl ? titleEl.textContent.trim() : ''; const badgeSpan = row.querySelector('.inline-block.bg-gray-100.text-gray-600.text-xs.font-medium'); const badge = badgeSpan ? badgeSpan.textContent.trim() : null; let category; if (badge === 'Recipe Master') category = 'Recipe Master'; else if (badge === 'Fork') category = 'Fork'; else if (badge === 'Read Only') category = 'Install'; else category = 'Private'; log(` Plugin: "${title}" | badge: "${badge}" → category: "${category}"`); return { row, title, category }; }); // Storage helpers const STORAGE_KEY_CATEGORY = 'trmnlPluginFilterCategory'; const STORAGE_KEY_SEARCH = 'trmnlPluginSearchTerm'; function loadStoredFilters() { let category = localStorage.getItem(STORAGE_KEY_CATEGORY) || 'all'; const validCategories = ['all', 'Recipe Master', 'Fork', 'Install', 'Private']; if (!validCategories.includes(category)) category = 'all'; let searchTerm = localStorage.getItem(STORAGE_KEY_SEARCH) || ''; log(`Loaded filters from storage: category="${category}", search="${searchTerm}"`); return { category, searchTerm }; } function saveFilters(category, searchTerm) { localStorage.setItem(STORAGE_KEY_CATEGORY, category); localStorage.setItem(STORAGE_KEY_SEARCH, searchTerm); } let { category: activeCategory, searchTerm } = loadStoredFilters(); function getButtonClass(category, isActive) { const base = 'category-btn px-4 py-2 rounded-full text-sm font-medium transition'; return isActive ? base + ' bg-primary-500 text-white' : base + ' bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600'; } const baseLabels = { 'all': 'All', 'Recipe Master': 'Recipe Master', 'Fork': 'Fork', 'Install': 'Install', 'Private': 'Private' }; const filterBar = document.createElement('div'); filterBar.id = FILTER_BAR_ID; filterBar.className = 'mt-4 mb-2 w-full'; filterBar.innerHTML = `
`; const headerContent = stickyHeader.querySelector('.w-full'); if (headerContent) { log('Inserting filter bar after .w-full inside sticky header.'); headerContent.after(filterBar); } else { warn('No .w-full found inside sticky header, appending to sticky header directly.'); stickyHeader.appendChild(filterBar); } const searchInput = document.getElementById('plugin-search'); const categoryButtons = document.querySelectorAll('.category-btn'); log(`Found ${categoryButtons.length} category buttons.`); function updateCounts() { const term = searchTerm.toLowerCase(); const counts = { 'all': 0, 'Recipe Master': 0, 'Fork': 0, 'Install': 0, 'Private': 0 }; plugins.forEach(p => { if (!p.title.toLowerCase().includes(term)) return; counts.all++; counts[p.category]++; }); log('Updated counts:', counts); categoryButtons.forEach(btn => { const cat = btn.dataset.category; btn.textContent = `${baseLabels[cat]} (${counts[cat] || 0})`; }); } function applyFilters() { let visible = 0, hidden = 0; plugins.forEach(p => { const matchesCategory = activeCategory === 'all' || p.category === activeCategory; const matchesSearch = p.title.toLowerCase().includes(searchTerm.toLowerCase()); const show = matchesCategory && matchesSearch; p.row.style.display = show ? '' : 'none'; show ? visible++ : hidden++; }); log(`applyFilters: ${visible} visible, ${hidden} hidden (category="${activeCategory}", search="${searchTerm}")`); return visible; } function switchToAllIfNoResults() { if (activeCategory !== 'all') { const totalInAllWithCurrentSearch = plugins.filter(p => p.title.toLowerCase().includes(searchTerm.toLowerCase()) ).length; if (totalInAllWithCurrentSearch > 0) { log(`No results in "${activeCategory}" but ${totalInAllWithCurrentSearch} results in "All" — switching to All category`); activeCategory = 'all'; saveFilters(activeCategory, searchTerm); // Update button active states categoryButtons.forEach(b => { b.className = getButtonClass(b.dataset.category, b.dataset.category === activeCategory); }); applyFilters(); return true; } } return false; } function checkAndHandleNoResults(visibleCount) { if (visibleCount === 0) { switchToAllIfNoResults(); } } // Initial state updateCounts(); const initialVisible = applyFilters(); checkAndHandleNoResults(initialVisible); // Event handlers searchInput.addEventListener('input', e => { searchTerm = e.target.value; log(`Search changed: "${searchTerm}"`); saveFilters(activeCategory, searchTerm); updateCounts(); const visible = applyFilters(); checkAndHandleNoResults(visible); }); categoryButtons.forEach(btn => { btn.addEventListener('click', () => { const category = btn.dataset.category; log(`Category button clicked: "${category}"`); activeCategory = category; saveFilters(activeCategory, searchTerm); categoryButtons.forEach(b => { b.className = getButtonClass(b.dataset.category, b.dataset.category === activeCategory); }); const visible = applyFilters(); checkAndHandleNoResults(visible); }); }); log('Setup complete.'); } })();