Add CNAME for custom domain, README with project overview and instructions, main HTML, CSS, and JavaScript files for the META.js Customizer web app. Includes meta_template.js for configuration generation and script.js for interactive UI logic.
372 lines
12 KiB
JavaScript
372 lines
12 KiB
JavaScript
document.addEventListener("DOMContentLoaded", () => {
|
|
const searchInput = document.getElementById("search-input");
|
|
const searchResults = document.getElementById("search-results");
|
|
const selectedServices = document.getElementById("selected-services");
|
|
const selectedCountEl = document.getElementById("selected-count");
|
|
const copyButton = document.getElementById("copyButton");
|
|
const downloadButton = document.getElementById("downloadButton");
|
|
const themeToggle = document.getElementById("theme-toggle");
|
|
|
|
const owner = "xixu-me";
|
|
const repo = "RFM";
|
|
const branch = "universal";
|
|
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
|
|
|
|
let allServices = [];
|
|
let selectedServicesList = [];
|
|
|
|
// Theme management
|
|
function getSystemTheme() {
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
? "dark"
|
|
: "light";
|
|
}
|
|
|
|
function initTheme() {
|
|
const savedTheme = localStorage.getItem("theme");
|
|
const systemTheme = getSystemTheme();
|
|
const initialTheme = savedTheme || systemTheme;
|
|
|
|
document.documentElement.setAttribute("data-theme", initialTheme);
|
|
updateThemeIcon(initialTheme);
|
|
|
|
// Listen for system theme changes
|
|
window
|
|
.matchMedia("(prefers-color-scheme: dark)")
|
|
.addEventListener("change", (e) => {
|
|
// Only auto-switch if user hasn't manually set a preference
|
|
if (!localStorage.getItem("theme")) {
|
|
const newSystemTheme = e.matches ? "dark" : "light";
|
|
document.documentElement.setAttribute("data-theme", newSystemTheme);
|
|
updateThemeIcon(newSystemTheme);
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateThemeIcon(theme) {
|
|
const icon = themeToggle.querySelector("i");
|
|
const isSystemDefault = !localStorage.getItem("theme");
|
|
|
|
if (isSystemDefault) {
|
|
// Show auto icon when following system
|
|
icon.className = "fas fa-circle-half-stroke";
|
|
themeToggle.title = "Theme: Auto (following system)";
|
|
} else if (theme === "dark") {
|
|
icon.className = "fas fa-sun";
|
|
themeToggle.title = "Switch to light mode";
|
|
} else {
|
|
icon.className = "fas fa-moon";
|
|
themeToggle.title = "Switch to dark mode";
|
|
}
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const currentTheme = document.documentElement.getAttribute("data-theme");
|
|
const savedTheme = localStorage.getItem("theme");
|
|
|
|
if (!savedTheme) {
|
|
// First click: set to opposite of current system theme
|
|
const newTheme = currentTheme === "dark" ? "light" : "dark";
|
|
document.documentElement.setAttribute("data-theme", newTheme);
|
|
localStorage.setItem("theme", newTheme);
|
|
updateThemeIcon(newTheme);
|
|
} else {
|
|
// Subsequent clicks: cycle through light -> dark -> auto
|
|
if (savedTheme === "light") {
|
|
document.documentElement.setAttribute("data-theme", "dark");
|
|
localStorage.setItem("theme", "dark");
|
|
updateThemeIcon("dark");
|
|
} else if (savedTheme === "dark") {
|
|
// Reset to auto (system preference)
|
|
localStorage.removeItem("theme");
|
|
const systemTheme = getSystemTheme();
|
|
document.documentElement.setAttribute("data-theme", systemTheme);
|
|
updateThemeIcon(systemTheme);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchServices() {
|
|
try {
|
|
const response = await fetch(apiUrl);
|
|
if (!response.ok) {
|
|
throw new Error(`GitHub API error: ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
const mrsFiles = data.tree.filter(
|
|
(file) => file.path.endsWith(".mrs") && file.type === "blob"
|
|
);
|
|
|
|
allServices = mrsFiles.map((file) => {
|
|
const name = file.path.replace(".mrs", "");
|
|
// A simple heuristic to format the name
|
|
const formattedName = name
|
|
.replace(/-/g, " ")
|
|
.replace("Google", "Google ")
|
|
.replace(" ", "");
|
|
|
|
const serviceDefinition = {
|
|
name: formattedName,
|
|
};
|
|
|
|
// Improved heuristics for tld, sld, alias, domain based on favicon availability and service patterns
|
|
if (name.includes("google-")) {
|
|
// Google services: use google TLD for favicon consistency
|
|
serviceDefinition.tld = "google";
|
|
serviceDefinition.alias = formattedName;
|
|
} else if (name === "xai") {
|
|
// xAI: specific case with SLD needed for proper favicon
|
|
serviceDefinition.name = "xAI";
|
|
serviceDefinition.tld = "ai";
|
|
serviceDefinition.sld = "x";
|
|
} else if (name === "rednote") {
|
|
// RedNote: use domain for services with non-standard favicon requirements
|
|
serviceDefinition.name = "rednote";
|
|
serviceDefinition.domain = "xiaohongshu.com";
|
|
serviceDefinition.alias = "Xiaohongshu";
|
|
} else if (name === "claude-ai") {
|
|
// Claude: use domain for Anthropic's service
|
|
serviceDefinition.domain = "claude.ai";
|
|
} else if (name === "openai") {
|
|
// OpenAI: simple case where service name matches SLD
|
|
serviceDefinition.tld = "com";
|
|
} else if (name === "github") {
|
|
// GitHub: simple case where service name matches SLD
|
|
serviceDefinition.tld = "com";
|
|
} else if (name.includes("-")) {
|
|
// Services with hyphens: check if last part is a known TLD
|
|
const parts = name.split("-");
|
|
const lastPart = parts[parts.length - 1];
|
|
|
|
if (["com", "org", "net", "ai", "io", "co"].includes(lastPart)) {
|
|
// If ends with known TLD, use it and add SLD if needed
|
|
serviceDefinition.tld = lastPart;
|
|
if (parts.length > 1) {
|
|
const remainingParts = parts.slice(0, -1);
|
|
if (remainingParts.length === 1) {
|
|
// Single part before TLD becomes SLD
|
|
serviceDefinition.sld = remainingParts[0];
|
|
} else {
|
|
// Multiple parts: join as SLD or use domain for complex cases
|
|
const potentialSld = remainingParts.join("-");
|
|
if (potentialSld.length <= 15) {
|
|
serviceDefinition.sld = potentialSld;
|
|
} else {
|
|
// For very long or complex names, prefer domain
|
|
serviceDefinition.domain = `${name.replace("-", ".")}.com`;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// No clear TLD pattern: check if it's a compound service name
|
|
if (
|
|
parts.length === 2 &&
|
|
parts[0].length <= 10 &&
|
|
parts[1].length <= 10
|
|
) {
|
|
// Two reasonable parts: treat as SLD-TLD or use SLD with common TLD
|
|
serviceDefinition.tld = "com";
|
|
serviceDefinition.sld = parts[0];
|
|
} else {
|
|
// Complex name: use domain to ensure favicon availability
|
|
serviceDefinition.domain = `${name.replace(/-/g, "")}.com`;
|
|
}
|
|
}
|
|
} else {
|
|
// Single word services: default to com TLD
|
|
serviceDefinition.tld = "com";
|
|
}
|
|
|
|
return serviceDefinition;
|
|
});
|
|
|
|
updateSelectedCount();
|
|
} catch (error) {
|
|
searchResults.innerHTML = `<p style="padding: 1rem;">Error loading services: ${error.message}</p>`;
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
function showSearchResults(searchTerm) {
|
|
if (!searchTerm.trim()) {
|
|
searchResults.classList.remove("show");
|
|
return;
|
|
}
|
|
|
|
const filteredServices = allServices.filter(
|
|
(service) =>
|
|
service.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
|
!selectedServicesList.find((selected) => selected.name === service.name)
|
|
);
|
|
|
|
if (filteredServices.length === 0) {
|
|
searchResults.innerHTML =
|
|
'<div class="search-result-item">No matching services found</div>';
|
|
} else {
|
|
searchResults.innerHTML = filteredServices
|
|
.map(
|
|
(service) => `
|
|
<div class="search-result-item" data-service='${JSON.stringify(
|
|
service
|
|
)}'>
|
|
${service.name}
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
searchResults.classList.add("show");
|
|
}
|
|
|
|
function addService(service) {
|
|
if (
|
|
!selectedServicesList.find((selected) => selected.name === service.name)
|
|
) {
|
|
selectedServicesList.push(service);
|
|
renderSelectedServices();
|
|
updateSelectedCount();
|
|
searchInput.value = "";
|
|
searchResults.classList.remove("show");
|
|
}
|
|
}
|
|
|
|
function removeService(serviceName) {
|
|
selectedServicesList = selectedServicesList.filter(
|
|
(service) => service.name !== serviceName
|
|
);
|
|
renderSelectedServices();
|
|
updateSelectedCount();
|
|
}
|
|
|
|
function renderSelectedServices() {
|
|
if (selectedServicesList.length === 0) {
|
|
selectedServices.innerHTML = `
|
|
<div class="no-services">
|
|
<i class="fas fa-inbox"></i>
|
|
<p>No services selected</p>
|
|
<small>Search for services above to get started</small>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
selectedServices.innerHTML = selectedServicesList
|
|
.map(
|
|
(service) => `
|
|
<div class="selected-service-item">
|
|
<span class="service-name">${service.name}</span>
|
|
<button class="delete-button" data-service="${service.name}">
|
|
<i class="fas fa-times"></i> Remove
|
|
</button>
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function updateSelectedCount() {
|
|
const selectedCount = selectedServicesList.length;
|
|
const totalCount = allServices.length;
|
|
selectedCountEl.textContent = `${selectedCount} of ${totalCount} selected`;
|
|
}
|
|
|
|
function generateServicesString(services) {
|
|
return services
|
|
.map((s) => {
|
|
let definition = `{ name: "${s.name}"`;
|
|
if (s.tld) definition += `, tld: "${s.tld}"`;
|
|
if (s.sld) definition += `, sld: "${s.sld}"`;
|
|
if (s.domain) definition += `, domain: "${s.domain}"`;
|
|
if (s.alias) definition += `, alias: "${s.alias}"`;
|
|
definition += ` }`;
|
|
return definition;
|
|
})
|
|
.join(",\n ");
|
|
}
|
|
|
|
function generateOutput() {
|
|
const servicesString = generateServicesString(selectedServicesList);
|
|
return metaTemplate.replace("/* SERVICES_PLACEHOLDER */", servicesString);
|
|
}
|
|
|
|
// Event listeners
|
|
searchInput.addEventListener("input", (e) => {
|
|
showSearchResults(e.target.value);
|
|
});
|
|
|
|
searchInput.addEventListener("blur", () => {
|
|
// Delay hiding results to allow clicks on results
|
|
setTimeout(() => {
|
|
searchResults.classList.remove("show");
|
|
}, 200);
|
|
});
|
|
|
|
searchResults.addEventListener("click", (e) => {
|
|
if (e.target.classList.contains("search-result-item")) {
|
|
const serviceData = e.target.getAttribute("data-service");
|
|
if (serviceData) {
|
|
const service = JSON.parse(serviceData);
|
|
addService(service);
|
|
}
|
|
}
|
|
});
|
|
|
|
selectedServices.addEventListener("click", (e) => {
|
|
if (e.target.classList.contains("delete-button")) {
|
|
const serviceName = e.target.getAttribute("data-service");
|
|
removeService(serviceName);
|
|
}
|
|
});
|
|
|
|
copyButton.addEventListener("click", () => {
|
|
const generatedCode = generateOutput();
|
|
const originalContent = copyButton.innerHTML;
|
|
|
|
navigator.clipboard
|
|
.writeText(generatedCode)
|
|
.then(() => {
|
|
copyButton.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
|
copyButton.classList.add("btn-success");
|
|
setTimeout(() => {
|
|
copyButton.innerHTML = originalContent;
|
|
copyButton.classList.remove("btn-success");
|
|
}, 2000);
|
|
})
|
|
.catch((err) => {
|
|
alert("Failed to copy text.");
|
|
console.error("Copy error", err);
|
|
});
|
|
});
|
|
|
|
downloadButton.addEventListener("click", () => {
|
|
const generatedCode = generateOutput();
|
|
const originalContent = downloadButton.innerHTML;
|
|
|
|
downloadButton.innerHTML =
|
|
'<i class="fas fa-spinner fa-spin"></i> Downloading...';
|
|
|
|
const blob = new Blob([generatedCode], {
|
|
type: "text/javascript",
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "META.js";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
setTimeout(() => {
|
|
downloadButton.innerHTML = originalContent;
|
|
}, 1000);
|
|
});
|
|
|
|
themeToggle.addEventListener("click", toggleTheme);
|
|
|
|
// Initialize
|
|
initTheme();
|
|
fetchServices();
|
|
});
|