Better AlternativeTo

Shows website/GitHub/app-store/social links straight on the cards, pins a compact filter bar with search + likes range, and puts dark mode in the header.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Better AlternativeTo
// @namespace    https://github.com/quantavil/userscript/
// @version      1.1.0
// @author       quantavil
// @description  Shows website/GitHub/app-store/social links straight on the cards, pins a compact filter bar with search + likes range, and puts dark mode in the header.
// @license      MIT
// @match        *://*.alternativeto.net/*
// ==/UserScript==

(function() {
	"use strict";
	var EXTERNAL_LINKS = /"externalLinks":(\[[^\]]*\])/;
	function extractLinks(rsc) {
		const m = rsc.match(EXTERNAL_LINKS);
		if (!m) return [];
		try {
			return JSON.parse(m[1]).filter((l) => Boolean(l && l.url && l.type));
		} catch {
			return [];
		}
	}
	function slugFromHref(href) {
		const m = /\/software\/([^/?#]+)/.exec(href ?? "");
		return m ? m[1] : null;
	}
	function parseLikes(text) {
		const m = /(\d[\d,]*)/.exec(text ?? "");
		return m ? Number(m[1].replace(/,/g, "")) : 0;
	}
	var EMPTY_FILTER = {
		query: "",
		minLikes: 0,
		maxLikes: NaN,
		needsSource: false,
		needsOfficial: false
	};
	function isFilterActive(f) {
		return f.query !== "" || f.minLikes > 0 || !Number.isNaN(f.maxLikes) || f.needsSource || f.needsOfficial;
	}
	function matchesFilter(card, f) {
		if (f.query) {
			const hay = `${card.title} ${card.description}`.toLowerCase();
			for (const term of f.query.toLowerCase().split(/\s+/)) if (term && !hay.includes(term)) return false;
		}
		if (card.likes < f.minLikes) return false;
		if (!Number.isNaN(f.maxLikes) && card.likes > f.maxLikes) return false;
		if (f.needsSource || f.needsOfficial) {
			if (!card.links) return true;
			if (f.needsSource && !card.links.some((l) => l.type === "Source")) return false;
			if (f.needsOfficial && !card.links.some((l) => l.type === "Official")) return false;
		}
		return true;
	}
	var FACET_LABELS = {
		category: "Category",
		platform: "Platform",
		license: "License",
		feature: "Feature",
		property: "Property",
		origin: "Origin",
		tag: "Tag",
		sort: "Sort",
		q: "Search"
	};
	function activeFacets(search) {
		const out = [];
		for (const [key, value] of new URLSearchParams(search)) {
			if (key === "p" || key === "page" || !value) continue;
			out.push({
				key,
				value,
				label: FACET_LABELS[key] ?? key
			});
		}
		return out;
	}
	function urlWithoutFacet(href, key, value) {
		const url = new URL(href);
		const kept = [...url.searchParams.entries()].filter(([k, v]) => !(k === key && v === value));
		url.search = new URLSearchParams(kept).toString();
		url.searchParams.delete("p");
		return url.toString();
	}
	var CACHE_PREFIX = "bat:links:";
	var CACHE_TTL_MS = 6048e5;
	var MAX_CONCURRENT = 4;
	function readCache(slug) {
		try {
			const raw = localStorage.getItem(CACHE_PREFIX + slug);
			if (!raw) return null;
			const entry = JSON.parse(raw);
			if (Date.now() - entry.t > CACHE_TTL_MS) {
				localStorage.removeItem(CACHE_PREFIX + slug);
				return null;
			}
			return entry.l;
		} catch {
			return null;
		}
	}
	function writeCache(slug, links) {
		try {
			localStorage.setItem(CACHE_PREFIX + slug, JSON.stringify({
				t: Date.now(),
				l: links
			}));
		} catch {}
	}
	var inflight = new Map();
	var queue = [];
	var running = 0;
	function pump() {
		while (running < MAX_CONCURRENT && queue.length) {
			running++;
			queue.shift()();
		}
	}
	async function fetchLinks(slug) {
		const res = await fetch(`/software/${slug}/about/`, {
			headers: { RSC: "1" },
			credentials: "same-origin"
		});
		if (!res.ok) throw new Error(`${res.status} for ${slug}`);
		return extractLinks(await res.text());
	}
	function getLinks(slug) {
		const cached = readCache(slug);
		if (cached) return Promise.resolve(cached);
		const existing = inflight.get(slug);
		if (existing) return existing;
		const promise = new Promise((resolve) => {
			queue.push(() => {
				fetchLinks(slug).then((links) => {
					writeCache(slug, links);
					resolve(links);
				}).catch(() => resolve([])).finally(() => {
					running--;
					inflight.delete(slug);
					pump();
				});
			});
			pump();
		});
		inflight.set(slug, promise);
		return promise;
	}
	var TYPE_ORDER = [
		"Official",
		"Source",
		"Appstore",
		"Social"
	];
	function shortLabel(link) {
		if (link.type === "Official") return "Website";
		return link.name.replace(/\s*(App\s*)?Store\b/i, "").replace(/\s*Repository\b/i, "").replace(/\s*Platform\b/i, "").trim() || link.name;
	}
	function renderLinkRow(links) {
		const row = document.createElement("div");
		row.className = "bat-links";
		if (!links.length) {
			row.classList.add("bat-links-empty");
			row.textContent = "No external links listed";
			return row;
		}
		const sorted = [...links].sort((a, b) => TYPE_ORDER.indexOf(a.type) - TYPE_ORDER.indexOf(b.type));
		for (const link of sorted) {
			const a = document.createElement("a");
			a.className = `bat-chip bat-chip-${link.type.toLowerCase()}`;
			a.href = link.url;
			a.target = "_blank";
			a.rel = "nofollow noopener noreferrer";
			a.title = `${link.name} — ${link.url}`;
			a.textContent = shortLabel(link);
			row.append(a);
		}
		return row;
	}
	function el(tag, props = {}, children = []) {
		const node = Object.assign(document.createElement(tag), props);
		node.append(...children);
		return node;
	}
	function panelExists() {
		return Boolean(document.querySelector("[data-testid=\"app-filter-bar\"]"));
	}
	function expandSitePanel() {
		const items = document.querySelectorAll("[data-testid=\"popular-filters\"] ul > li");
		(items[items.length - 1]?.querySelector("span"))?.click();
	}
	var STORE = "bat:filter";
	function loadFilter() {
		try {
			const saved = JSON.parse(sessionStorage.getItem(STORE) ?? "");
			return {
				...EMPTY_FILTER,
				...saved,
				maxLikes: saved.maxLikes ?? NaN
			};
		} catch {
			return { ...EMPTY_FILTER };
		}
	}
	function saveFilter(filter) {
		try {
			sessionStorage.setItem(STORE, JSON.stringify(filter));
		} catch {}
	}
	var currentBar = null;
	function closePanel() {
		document.documentElement.classList.remove("bat-panel-open");
	}
	document.addEventListener("click", (event) => {
		if (!document.documentElement.classList.contains("bat-panel-open")) return;
		const target = event.target;
		if (currentBar?.contains(target)) return;
		if (document.querySelector("[data-testid=\"app-filter-bar\"]")?.contains(target)) return;
		closePanel();
	});
	document.addEventListener("keydown", (event) => {
		if (event.key === "Escape") closePanel();
	});
	function measure() {
		const header = document.querySelector("header");
		const top = header && getComputedStyle(header).position === "sticky" ? header.offsetHeight : 0;
		document.documentElement.style.setProperty("--bat-top", `${top}px`);
	}
	window.addEventListener("resize", measure, { passive: true });
	function mountFilterBar(nav, hooks) {
		const filter = loadFilter();
		const search = el("input", {
			type: "search",
			className: "bat-search",
			placeholder: "Filter these apps by name or description…"
		});
		const minLikes = el("input", {
			type: "number",
			min: "0",
			placeholder: "min"
		});
		const maxLikes = el("input", {
			type: "number",
			min: "0",
			placeholder: "max"
		});
		search.setAttribute("aria-label", "Filter apps on this page");
		minLikes.setAttribute("aria-label", "Minimum likes");
		maxLikes.setAttribute("aria-label", "Maximum likes");
		search.value = filter.query;
		minLikes.value = filter.minLikes ? String(filter.minLikes) : "";
		maxLikes.value = Number.isNaN(filter.maxLikes) ? "" : String(filter.maxLikes);
		const srcToggle = el("button", {
			type: "button",
			className: "bat-toggle",
			textContent: "Has source"
		});
		const siteToggle = el("button", {
			type: "button",
			className: "bat-toggle",
			textContent: "Has website"
		});
		srcToggle.setAttribute("aria-pressed", String(filter.needsSource));
		siteToggle.setAttribute("aria-pressed", String(filter.needsOfficial));
		const filtersBtn = el("button", {
			type: "button",
			className: "bat-filters-btn",
			textContent: "Filters"
		});
		const reset = el("button", {
			type: "button",
			className: "bat-reset",
			textContent: "Reset",
			hidden: true
		});
		const count = el("span", { className: "bat-count" });
		const chips = el("div", { className: "bat-chips" });
		const bar = el("div", { className: "bat-bar" }, [
			search,
			el("span", { className: "bat-likes" }, [
				minLikes,
				el("span", { textContent: "–" }),
				maxLikes
			]),
			siteToggle,
			srcToggle,
			filtersBtn,
			reset,
			count,
			chips
		]);
		function sync() {
			reset.hidden = !isFilterActive(filter);
			saveFilter(filter);
			hooks.apply(filter);
		}
		let debounce = 0;
		search.addEventListener("input", () => {
			clearTimeout(debounce);
			debounce = window.setTimeout(() => {
				filter.query = search.value.trim();
				sync();
			}, 140);
		});
		const readLikes = () => {
			filter.minLikes = Number(minLikes.value) || 0;
			filter.maxLikes = maxLikes.value === "" ? NaN : Number(maxLikes.value);
			sync();
		};
		minLikes.addEventListener("input", readLikes);
		maxLikes.addEventListener("input", readLikes);
		function wireToggle(button, key) {
			button.addEventListener("click", () => {
				filter[key] = !filter[key];
				button.setAttribute("aria-pressed", String(filter[key]));
				if (filter[key]) hooks.requestAllLinks();
				sync();
			});
		}
		wireToggle(srcToggle, "needsSource");
		wireToggle(siteToggle, "needsOfficial");
		reset.addEventListener("click", () => {
			Object.assign(filter, EMPTY_FILTER);
			search.value = "";
			minLikes.value = "";
			maxLikes.value = "";
			for (const [button, key] of [[srcToggle, "needsSource"], [siteToggle, "needsOfficial"]]) {
				filter[key] = false;
				button.setAttribute("aria-pressed", "false");
			}
			sync();
		});
		function revealPanel() {
			document.documentElement.classList.add("bat-panel-open");
			const panel = document.querySelector("[data-testid=\"app-filter-bar\"]");
			if (!panel) return;
			panel.scrollIntoView({ block: "start" });
			const top = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--bat-top")) || 0;
			window.scrollBy(0, -(top + bar.offsetHeight + 10));
		}
		filtersBtn.addEventListener("click", (event) => {
			event.stopPropagation();
			const root = document.documentElement;
			if (root.classList.contains("bat-panel-open") && panelExists()) {
				root.classList.remove("bat-panel-open");
				return;
			}
			if (panelExists()) {
				revealPanel();
				return;
			}
			expandSitePanel();
			let tries = 0;
			const timer = window.setInterval(() => {
				if (panelExists() || ++tries > 20) {
					clearInterval(timer);
					if (panelExists()) revealPanel();
				}
			}, 50);
		});
		for (const facet of activeFacets(location.search)) chips.append(el("a", {
			className: "bat-facet",
			href: urlWithoutFacet(location.href, facet.key, facet.value),
			title: `Remove ${facet.label}: ${facet.value}`
		}, [el("b", { textContent: facet.label }), document.createTextNode(facet.value)]));
		nav.parentElement?.insertBefore(bar, nav);
		currentBar = bar;
		measure();
		sync();
		return {
			element: bar,
			setCount: (text) => {
				count.textContent = text;
			}
		};
	}
	var CSS = `
:root { --bat-top: 58px; }

/* ---------- links on cards ---------- */
.bat-links {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  align-items: center;
  margin-top: 6px;
  grid-column: 1 / -1;
}
.bat-links-empty {
  font-size: 0.78em;
  color: var(--meta, #888);
  font-style: italic;
}
.bat-links-pending {
  height: 20px;
  border-radius: 5px;
  width: 190px;
  background: var(--gray150, #eee);
  opacity: 0.6;
}
.bat-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  height: 22px;
  padding: 0 9px;
  border-radius: 6px;
  font-size: 0.76em;
  font-weight: 500;
  line-height: 1;
  text-decoration: none !important;
  white-space: nowrap;
  border: 1px solid transparent;
  transition: filter 0.12s ease, transform 0.12s ease;
}
.bat-chip:hover { filter: brightness(1.12); transform: translateY(-1px); }
.bat-chip::before { font-size: 1.05em; line-height: 1; }
.bat-chip-official {
  background: var(--positiveGreenerLight, #e3f6ee);
  color: var(--positiveGreenerDark, #04724d) !important;
  border-color: color-mix(in srgb, var(--positiveGreener, #0a8) 35%, transparent);
}
.bat-chip-official::before { content: "\\1F310"; }
.bat-chip-source {
  background: var(--gray200, #22252b);
  color: var(--mainFg, #eee) !important;
  border-color: var(--gray300, #2c2f35);
}
.bat-chip-source::before { content: "\\276F"; font-weight: 700; }
.bat-chip-appstore {
  background: var(--brandLight3, #e7f2fb);
  color: var(--linkColor, #0b6fb8) !important;
}
.bat-chip-appstore::before { content: "\\2B07"; }
.bat-chip-social {
  background: transparent;
  color: var(--meta, #888) !important;
  border-color: var(--gray300, #ccc);
}
.bat-chip-social::before { content: "\\1F4AC"; }

/* ----------- pinned compact filter bar -----------
   Everything below is keyed off the site's own data-testid attributes rather
   than classes added from JS: React owns className on these nodes and wipes
   anything this script adds on its next re-render. */
html.bat-on nav:has(> [data-testid="app-filter-bar-wrapper"]) {
  position: static !important;
  z-index: auto !important;
}
html.bat-on [data-testid="app-filter-bar-wrapper"] { display: none !important; }

.bat-bar {
  position: sticky;
  top: var(--bat-top);
  z-index: 12;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding: 7px 14px;
  background: var(--mainBg, #fff);
  border: 1px solid var(--gray300, #d8dde3);
  border-radius: 12px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07);
  margin: 0 0 10px;
  font-size: 14px;
  color: var(--mainFg, #222);
}
.bat-bar input,
.bat-bar button {
  font: inherit;
  color: inherit;
  background: var(--gray50, #f6f8fa);
  border: 1px solid var(--gray300, #d8dde3);
  border-radius: 8px;
  height: 30px;
  padding: 0 9px;
  box-sizing: border-box;
}
.bat-bar input:focus-visible,
.bat-bar button:focus-visible { outline: 2px solid var(--linkColor, #0b6fb8); outline-offset: 1px; }
.bat-search { flex: 1 1 190px; min-width: 130px; }
.bat-likes { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.bat-likes::before { content: "\\2665"; color: #e0245e; }
.bat-likes input { width: 62px; text-align: center; padding: 0 4px; }
.bat-likes span { color: var(--meta, #888); }

.bat-toggle { cursor: pointer; white-space: nowrap; }
/* Fixed colours on purpose: --linkColor is a pale blue in dark mode, so white
   text on it was unreadable. This pair works against either theme. */
.bat-toggle[aria-pressed="true"] {
  background: #1665a8;
  border-color: #1665a8;
  color: #fff !important;
}
.bat-filters-btn { cursor: pointer; font-weight: 600; white-space: nowrap; }
.bat-filters-btn::after { content: " \\25BE"; }
html.bat-panel-open .bat-filters-btn::after { content: " \\25B4"; }
.bat-count { margin-left: auto; color: var(--meta, #888); white-space: nowrap; font-size: 0.9em; }
.bat-reset { cursor: pointer; }

.bat-chips { display: flex; flex-wrap: wrap; gap: 6px; width: 100%; }
.bat-chips:empty { display: none; }
.bat-facet {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 24px;
  padding: 0 6px 0 9px;
  border-radius: 7px;
  font-size: 0.8em;
  background: var(--brandLight3, #e7f2fb);
  color: var(--linkColor, #0b6fb8);
  text-decoration: none !important;
}
.bat-facet b { font-weight: 600; }
.bat-facet::after { content: "\\2715"; opacity: 0.6; font-size: 1.05em; }
.bat-facet:hover::after { opacity: 1; }

/* The site's own facet panel, hidden at rest and shown untouched when opened —
   no box, no cap, no scroller: its native multi-column layout is the one that
   reads well, and constraining it only ever made it worse. */
html.bat-on:not(.bat-panel-open) [data-testid="app-filter-bar"] { display: none !important; }

/* An attribute, not a class — React rewrites className on the cards. */
[data-bat-hide] { display: none !important; }
.bat-empty-note {
  padding: 26px 14px;
  text-align: center;
  color: var(--meta, #888);
}

/* ---------- dark mode button in the header ---------- */
.bat-theme {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  margin: 0 4px;
  cursor: pointer;
  border: 1px solid rgba(255, 255, 255, 0.35);
  border-radius: 8px;
  background: transparent;
  color: #fff;
  font-size: 15px;
  line-height: 1;
  padding: 0;
}
.bat-theme:hover { background: rgba(255, 255, 255, 0.16); }

@media (max-width: 640px) {
  .bat-bar { gap: 6px; padding: 6px 10px; border-radius: 10px; }
  /* 16px or iOS zooms the whole page in when the field is focused. */
  .bat-bar input { font-size: 16px; }
  .bat-bar input, .bat-bar button { height: 34px; }
  .bat-search { flex-basis: 100%; min-width: 0; }
  .bat-likes { flex: 1 1 130px; }
  .bat-likes input { width: 100%; min-width: 0; }
  .bat-toggle, .bat-filters-btn { flex: 1 1 auto; }
  .bat-count { margin-left: 0; width: 100%; text-align: right; }
}
`;
	var bar = null;
	var cards = [];
	var filter = { ...EMPTY_FILTER };
	var lastUrl = location.href;
	function injectStyles() {
		if (document.getElementById("bat-style")) return;
		const style = document.createElement("style");
		style.id = "bat-style";
		style.textContent = CSS;
		(document.head ?? document.documentElement).append(style);
	}
	function currentTheme() {
		return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
	}
	function mountThemeButton() {
		const menuButton = document.querySelector("button[aria-label=\"Open page menu\"]");
		if (!menuButton || document.querySelector(".bat-theme")) return;
		const button = document.createElement("button");
		button.type = "button";
		button.className = "bat-theme";
		const paint = () => {
			const dark = currentTheme() === "dark";
			button.textContent = dark ? "☀" : "☾";
			button.title = dark ? "Switch to light mode" : "Switch to dark mode";
			button.setAttribute("aria-label", button.title);
		};
		paint();
		button.addEventListener("click", () => {
			const next = currentTheme() === "dark" ? "light" : "dark";
			document.documentElement.dataset.theme = next;
			try {
				localStorage.setItem("theme", next);
			} catch {}
			paint();
		});
		new MutationObserver(paint).observe(document.documentElement, {
			attributes: true,
			attributeFilter: ["data-theme"]
		});
		(menuButton.parentElement ?? menuButton).before(button);
	}
	var observer = new IntersectionObserver((entries) => {
		for (const entry of entries) {
			if (!entry.isIntersecting) continue;
			observer.unobserve(entry.target);
			const card = cards.find((c) => c.el === entry.target);
			if (card) loadLinks(card);
		}
	}, { rootMargin: "300px 0px" });
	async function loadLinks(card) {
		const host = card.el.querySelector("[data-testid=\"main-app-info\"]") ?? card.el;
		if (card.data.links) {
			if (!card.el.querySelector(".bat-links")) host.append(renderLinkRow(card.data.links));
			return;
		}
		let placeholder = card.el.querySelector(".bat-links");
		if (!placeholder) {
			placeholder = document.createElement("div");
			placeholder.className = "bat-links bat-links-pending";
			host.append(placeholder);
		}
		const links = await getLinks(card.slug);
		card.data.links = links;
		if (placeholder.isConnected) placeholder.replaceWith(renderLinkRow(links));
		else host.append(renderLinkRow(links));
		applyFilter();
	}
	function cardSlug(el) {
		return slugFromHref(el.querySelector("a[href*=\"/software/\"]")?.getAttribute("href"));
	}
	function isStale(el) {
		const slug = cardSlug(el);
		return Boolean(slug) && el.dataset.bat !== slug;
	}
	function readCard(el) {
		const slug = cardSlug(el);
		if (!slug) return null;
		const info = el.querySelector("[data-testid=\"main-app-info\"]");
		const platforms = el.querySelector("[data-testid=\"platform-row\"]");
		const likesText = el.querySelector("#like-button-container")?.textContent ?? /\d[\d,]*\s+likes?/.exec(info?.textContent ?? "")?.[0];
		return {
			el,
			slug,
			data: {
				title: el.querySelector("h2")?.textContent?.trim() ?? "",
				description: `${info?.textContent ?? ""} ${platforms?.textContent ?? ""}`,
				likes: parseLikes(likesText)
			}
		};
	}
	function scanCards() {
		const previous = cards;
		cards = [];
		for (const el of document.querySelectorAll("[data-testid^=\"item-\"]")) {
			const card = readCard(el);
			if (!card) continue;
			if (el.dataset.bat === card.slug) {
				cards.push(previous.find((c) => c.el === el) ?? card);
				continue;
			}
			el.querySelector(".bat-links")?.remove();
			el.dataset.bat = card.slug;
			cards.push(card);
			observer.observe(el);
		}
	}
	function requestAllLinks() {
		for (const card of cards) loadLinks(card);
	}
	function siteCount() {
		const wrapper = document.querySelector("[data-testid=\"app-filter-bar-wrapper\"]");
		if (!wrapper) return "";
		for (const span of wrapper.querySelectorAll("span")) {
			if (span.children.length) continue;
			const text = span.textContent?.trim() ?? "";
			if (/^\d[\d,]*\s+apps?$/.test(text)) return text.replace(/\s+/g, " ");
		}
		return "";
	}
	function applyFilter() {
		let visible = 0;
		for (const card of cards) {
			const show = matchesFilter(card.data, filter);
			card.el.toggleAttribute("data-bat-hide", !show);
			if (show) visible++;
		}
		const filtering = isFilterActive(filter);
		bar?.setCount(filtering ? `${visible} of ${cards.length} on this page` : siteCount());
		let note = document.querySelector(".bat-empty-note");
		if (filtering && visible === 0 && cards.length) {
			if (!note) {
				note = document.createElement("div");
				note.className = "bat-empty-note";
				note.textContent = "No app on this page matches — try the Filters panel to search the whole site.";
				bar?.element.after(note);
			}
		} else note?.remove();
	}
	function boot() {
		mountThemeButton();
		const nav = document.querySelector("[data-testid=\"app-filter-bar-wrapper\"]")?.closest("nav");
		if (nav && (!bar || !bar.element.isConnected)) {
			filter = { ...EMPTY_FILTER };
			bar = mountFilterBar(nav, {
				apply: (next) => {
					filter = next;
					applyFilter();
				},
				requestAllLinks
			});
			document.documentElement.classList.add("bat-on");
		}
		scanCards();
		if (filter.needsSource || filter.needsOfficial) requestAllLinks();
		applyFilter();
	}
	function teardown() {
		bar?.element.remove();
		bar = null;
		filter = { ...EMPTY_FILTER };
		document.documentElement.classList.remove("bat-panel-open");
		document.querySelector(".bat-empty-note")?.remove();
	}
	function needsWork() {
		if (location.href !== lastUrl) return true;
		if (!bar || !bar.element.isConnected) return Boolean(document.querySelector("[data-testid=\"app-filter-bar-wrapper\"]"));
		if (!document.querySelector(".bat-theme")) return Boolean(document.querySelector("button[aria-label=\"Open page menu\"]"));
		return [...document.querySelectorAll("[data-testid^=\"item-\"]")].some(isStale);
	}
	var scheduled = 0;
	function schedule() {
		clearTimeout(scheduled);
		scheduled = window.setTimeout(() => {
			if (!needsWork()) return;
			if (location.href !== lastUrl) {
				lastUrl = location.href;
				teardown();
			}
			boot();
		}, 120);
	}
	injectStyles();
	schedule();
	new MutationObserver(schedule).observe(document.documentElement, {
		childList: true,
		subtree: true
	});
})();