Refactor app to modular ES6 components

Migrates the main application logic from a monolithic script to a modular, component-based architecture using ES6 modules. Adds ThemeManager, ServicesManager, ServiceConfigGenerator, and OutputManager components for improved maintainability and scalability. Updates index.html to use the new entry point and enhances accessibility and UI structure. Removes legacy main.js.
This commit is contained in:
xixu-me committed 2025-07-14 12:39:21 +08:00
1 parent 68e8dd407b
commit 7f84415aaa
11 files changed
+1970 -463

No files matched your search

+53 -28
View File
@@ -2,7 +2,7 @@
## 🎯 Overview ## 🎯 Overview
META.js Customizer is a modern, intuitive web app that allows users to easily create custom META.js configurations for [xixu-me/META](https://github.com/xixu-me/META). It provides a beautiful visual interface to select and configure service rulesets from the [xixu-me/RFM](https://github.com/xixu-me/RFM) repository. META.js Customizer is a modern, intuitive web app that allows users to easily create custom META.js of [xixu-me/META](https://github.com/xixu-me/META). It provides a beautiful visual interface to select and configure service rulesets from the [xixu-me/RFM](https://github.com/xixu-me/RFM) repository.
### ✨ Key Features ### ✨ Key Features
@@ -89,47 +89,72 @@ The app automatically detects system theme changes and applies them when in auto
## 🏗️ Architecture ## 🏗️ Architecture
### Key Components The META.js Customizer follows a modern, modular architecture designed for maintainability, scalability, and performance:
#### 🎨 **ThemeManager** ### 🧩 Component-Based Design
- Handles theme detection, switching, and persistence #### **🎨 ThemeManager**
- Supports system theme detection with auto-switching
- Smooth theme transitions with CSS custom properties
#### 🔍 **ServicesManager** - Handles system theme detection and manual switching
- Supports auto, light, and dark modes with smooth transitions
- Persists user preferences in localStorage
- Responsive to system theme changes
- Retrieves available services from RFM repository via GitHub API #### **🔍 ServicesManager**
- Real-time filtering and searching of services
- Intelligent service configuration generation
- Selected services management with persistence
#### ⚙️ **ServiceConfigGenerator** - Fetches and caches services from RFM repository via GitHub API
- Implements debounced search for optimal performance
- Manages service selection state with event-driven updates
- Provides real-time filtering and search capabilities
- Smart favicon detection and URL generation #### **⚙️ ServiceConfigGenerator**
- Service-specific configuration logic
- Handles complex domain patterns and special cases
#### 📤 **OutputManager** - Intelligent favicon detection from xixu-me/favicons repository
- Smart configuration generation with domain parsing
- Handles special cases and complex domain patterns
- Caches favicon availability for performance optimization
- Real-time META.js generation #### **📤 OutputManager**
- Clipboard integration with error handling
- File download functionality with progress indication
#### 🎯 **MetaJSCustomizer** - Real-time META.js code generation and preview
- Clipboard integration with comprehensive error handling
- File download functionality with progress feedback
- Template management and dynamic content substitution
- Main app controller #### **🎯 MetaJSCustomizer (Main Controller)**
- Error handling and user feedback
- Component orchestration and lifecycle management
### Technology Stack - Application lifecycle management and component orchestration
- Global error handling with user-friendly feedback
- Performance monitoring and optimization
- Loading state management and initialization coordination
### 🚀 Modern JavaScript Features
- **ES6+ Modules**: Clean import/export system for better organization
- **Async/Await**: Modern asynchronous programming patterns
- **Error Boundaries**: Comprehensive error handling with user feedback
- **Event-Driven Architecture**: Loose coupling between components
- **Performance Optimization**: Debouncing, caching, and efficient DOM operations
### 🎨 Enhanced UI/UX
- **Glassmorphism Design**: Modern glass effects with backdrop blur
- **Smooth Animations**: CSS-based transitions and micro-interactions
- **Responsive Layout**: Mobile-first design that adapts to all screen sizes
- **Accessibility First**: ARIA labels, semantic HTML, and keyboard navigation
- **Loading States**: Clear feedback during async operations
### 💻 Technology Stack
- **Frontend**: Modern HTML5, CSS3, JavaScript (ES2020+) - **Frontend**: Modern HTML5, CSS3, JavaScript (ES2020+)
- **Styling**: CSS Custom Properties, CSS Grid/Flexbox, Glassmorphism - **Architecture**: Component-based modular design with ES6 modules
- **Styling**: CSS Custom Properties, CSS Grid/Flexbox, Glassmorphism effects
- **Methodology**: BEM (Block Element Modifier) for CSS organization - **Methodology**: BEM (Block Element Modifier) for CSS organization
- **Icons**: Font Awesome 6.5.1 - **Icons**: Font Awesome 6.5.1 for consistent iconography
- **API**: GitHub REST API for dynamic service fetching - **API Integration**: GitHub REST API for dynamic service fetching
- **Build**: No build process required - runs directly in browser - **Performance**: Debounced search, caching strategies, optimized DOM operations
- **Accessibility**: WCAG 2.1 AA compliant with comprehensive ARIA support
- **Build**: Zero-build architecture - runs directly in modern browsers
## 🎨 Design Features ## 🎨 Design Features
+149 -48
View File
@@ -47,111 +47,206 @@
<!-- Preconnect for Performance --> <!-- Preconnect for Performance -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" /> <link rel="preconnect" href="https://cdnjs.cloudflare.com" />
<link rel="preconnect" href="https://github.com" /> <link rel="preconnect" href="https://api.github.com" />
<link rel="preconnect" href="https://raw.githubusercontent.com" />
<!-- Stylesheets --> <!-- Stylesheets -->
<link rel="stylesheet" href="src/styles/main.css" /> <link rel="stylesheet" href="src/styles/main.css" />
<link <link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
rel="stylesheet" rel="stylesheet"
/> />
</head> </head>
<body> <body>
<!-- Theme Toggle -->
<div class="theme-toggle"> <div class="theme-toggle">
<button id="theme-toggle" aria-label="Toggle dark mode"> <button id="theme-toggle" aria-label="Toggle theme" title="Theme: Auto">
<i class="fas fa-moon"></i> <i class="fas fa-circle-half-stroke"></i>
</button> </button>
</div> </div>
<!-- Main Container -->
<div class="container"> <div class="container">
<!-- Header Section -->
<header class="header"> <header class="header">
<h1><i class="fas fa-cogs"></i> META.js Customizer</h1> <h1>
<i class="fas fa-cogs" aria-hidden="true"></i>
META.js Customizer
</h1>
<p class="subtitle"> <p class="subtitle">
Create your custom META.js of Create your custom META.js of
<a href="https://github.com/xixu-me/META" target="_blank"> <a
<i class="fab fa-github"></i> xixu-me/META</a href="https://github.com/xixu-me/META"
target="_blank"
rel="noopener"
> >
with service rulesets from <i class="fab fa-github" aria-hidden="true"></i> xixu-me/META
<a href="https://github.com/xixu-me/RFM" target="_blank"> </a>
<i class="fab fa-github"></i> xixu-me/RFM</a using service rulesets from
<a
href="https://github.com/xixu-me/RFM"
target="_blank"
rel="noopener"
> >
<i class="fab fa-github" aria-hidden="true"></i> xixu-me/RFM
</a>
</p> </p>
</header> </header>
<div id="services-container" class="card"> <!-- Services Configuration Section -->
<main>
<section
id="services-container"
class="card"
aria-labelledby="services-heading"
>
<div class="card-header"> <div class="card-header">
<h2><i class="fas fa-list"></i> Services</h2> <h2 id="services-heading">
<i class="fas fa-list" aria-hidden="true"></i>
Services Configuration
</h2>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="routing-priority-reminder"> <!-- Routing Priority Information -->
<i class="fas fa-exclamation-triangle warning-icon"></i> <div
class="routing-priority-reminder"
role="note"
aria-label="Important routing information"
>
<i
class="fas fa-exclamation-triangle warning-icon"
aria-hidden="true"
></i>
<div class="content"> <div class="content">
<strong>Routing Priority:</strong> <strong>Routing Priority:</strong>
<span> <span>
Routing rules are matched in order from top to bottom, with the Routing rules are matched in order from top to bottom, with
rule at the top of the list taking precedence over the rules the rule at the top of the list taking precedence over the
below it. rules below it.
</span> </span>
</div> </div>
</div> </div>
<!-- Services Controls -->
<div class="services-controls"> <div class="services-controls">
<div class="search-container"> <div class="search-container">
<div class="search-input-wrapper"> <div class="search-input-wrapper">
<i class="fas fa-search search-icon"></i> <i class="fas fa-search search-icon" aria-hidden="true"></i>
<input <input
type="text" type="text"
id="search-input" id="search-input"
placeholder="Search and add services..." placeholder="Search and add services..."
aria-label="Search for services"
autocomplete="off"
spellcheck="false"
/> />
</div> </div>
<div class="services-count"> <div class="services-count" role="status" aria-live="polite">
<i class="fas fa-check-circle"></i> <i class="fas fa-check-circle" aria-hidden="true"></i>
<span id="selected-count">0 selected</span> <span id="selected-count">0 of 0 selected</span>
</div>
</div>
<div id="search-results" class="search-results"></div>
</div>
<div id="selected-services">
<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>
</div>
</div> </div>
</div> </div>
<div id="output-container" class="card"> <!-- Search Results Dropdown -->
<div
id="search-results"
class="search-results"
role="listbox"
aria-label="Available services"
aria-hidden="true"
></div>
</div>
<!-- Selected Services List -->
<div
id="selected-services"
role="list"
aria-label="Selected services"
aria-describedby="services-description"
>
<div class="no-services">
<i class="fas fa-inbox" aria-hidden="true"></i>
<h3>No services selected</h3>
<p id="services-description">
Search for services above to get started
</p>
</div>
</div>
</div>
</section>
<!-- Generated Output Section -->
<section
id="output-container"
class="card"
aria-labelledby="output-heading"
>
<div class="card-header"> <div class="card-header">
<h2><i class="fas fa-code"></i> Generated META.js</h2> <h2 id="output-heading">
<i class="fas fa-code" aria-hidden="true"></i>
Generated META.js
</h2>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="output-actions"> <div class="output-actions">
<button id="copyButton" class="btn btn-primary"> <button
<i class="fas fa-copy"></i> Copy id="copyButton"
class="btn btn-primary"
disabled
aria-describedby="copy-description"
>
<i class="fas fa-copy" aria-hidden="true"></i>
Copy to Clipboard
</button> </button>
<button id="downloadButton" class="btn btn-secondary"> <button
<i class="fas fa-download"></i> Download id="downloadButton"
class="btn btn-secondary"
disabled
aria-describedby="download-description"
>
<i class="fas fa-download" aria-hidden="true"></i>
Download META.js
</button> </button>
</div> </div>
<div class="sr-only">
<p id="copy-description">
Copy the generated META.js to your clipboard
</p>
<p id="download-description">
Download the generated META.js as a file
</p>
</div> </div>
</div> </div>
</section>
</main>
<div class="info-banner card"> <!-- Information Section -->
<aside class="info-banner card" aria-labelledby="info-heading">
<div class="card-header"> <div class="card-header">
<h2><i class="fas fa-info-circle"></i> Icon Configuration Guide</h2> <h2 id="info-heading">
<i class="fas fa-info-circle" aria-hidden="true"></i>
Icon Configuration Guide
</h2>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="info-content"> <div class="info-content">
<p> <p>
Service icons are sourced from Service icons are sourced from
<a href="https://github.com/xixu-me/favicons" target="_blank"> <a
<i class="fab fa-github"></i> xixu-me/favicons</a href="https://github.com/xixu-me/favicons"
target="_blank"
rel="noopener"
>
<i class="fab fa-github" aria-hidden="true"></i>
xixu-me/favicons </a
>. Auto-generated configurations may require manual adjustments >. Auto-generated configurations may require manual adjustments
based on availability. based on availability.
</p> </p>
<h3><i class="fas fa-cog"></i> Configuration Rules</h3> <h3>
<i class="fas fa-cog" aria-hidden="true"></i>
Configuration Rules
</h3>
<ul> <ul>
<li> <li>
<strong>Icon unavailable:</strong> Uses specific <strong>Icon unavailable:</strong> Uses specific
@@ -167,7 +262,7 @@
> >
</li> </li>
<li> <li>
<strong>Name does not match SLD:</strong> Adds <code>sld</code> <strong>Name mismatch:</strong> Adds <code>sld</code>
<span class="example" <span class="example"
>(e.g., "xai" → <code>sld: "x", tld: "ai"</code>)</span >(e.g., "xai" → <code>sld: "x", tld: "ai"</code>)</span
> >
@@ -175,21 +270,27 @@
</ul> </ul>
</div> </div>
</div> </div>
</div> </aside>
<!-- Footer -->
<footer class="footer"> <footer class="footer">
<p> <p>
<i class="fas fa-code-branch"></i> <i class="fas fa-code-branch" aria-hidden="true"></i>
This web app is open source at This web app is open source at
<a <a
href="https://github.com/xixu-me/META.js-Customizer" href="https://github.com/xixu-me/META.js-Customizer"
target="_blank" target="_blank"
><i class="fab fa-github"></i> xixu-me/META.js-Customizer</a rel="noopener"
> >
<i class="fab fa-github" aria-hidden="true"></i>
xixu-me/META.js-Customizer
</a>
</p> </p>
</footer> </footer>
</div> </div>
<!-- Scripts -->
<script src="src/templates/meta-template.js"></script> <script src="src/templates/meta-template.js"></script>
<script src="src/scripts/main.js"></script> <script type="module" src="src/scripts/app.js"></script>
</body> </body>
</html> </html>
+283
View File
@@ -0,0 +1,283 @@
/**
* META.js Customizer - Main Application Controller
*
* Modern, accessible web app for generating custom META.js
* using service rulesets from the xixu-me/RFM repository.
*
* Features:
* - Component-based architecture with separation of concerns
* - Modern ES6+ JavaScript with module imports
* - Comprehensive error handling and user feedback
* - Accessibility-first design with ARIA support
* - Performance optimizations and caching
*
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/
import { OutputManager } from "./components/OutputManager.js";
import { ServiceConfigGenerator } from "./components/ServiceConfigGenerator.js";
import { ServicesManager } from "./components/ServicesManager.js";
import { ThemeManager } from "./components/ThemeManager.js";
export class MetaJSCustomizer {
constructor() {
this.components = {};
this.isInitialized = false;
this.init();
}
/**
* Initialize the application
*/
async init() {
try {
this.showLoadingState();
// Initialize components in order
await this.initializeComponents();
// Set up global error handling
this.setupErrorHandling();
// Set up performance monitoring
this.setupPerformanceMonitoring();
this.hideLoadingState();
this.isInitialized = true;
console.log("META.js Customizer initialized successfully");
} catch (error) {
console.error("Failed to initialize META.js Customizer:", error);
this.showInitializationError(error);
}
}
/**
* Initialize all application components
*/
async initializeComponents() {
// Initialize theme manager first (no dependencies)
this.components.themeManager = new ThemeManager();
// Initialize service config generator (no dependencies)
this.components.serviceConfigGenerator = new ServiceConfigGenerator();
// Initialize services manager (depends on API)
this.components.servicesManager = new ServicesManager();
// Initialize output manager (depends on service config generator)
this.components.outputManager = new OutputManager(
this.components.serviceConfigGenerator
);
// Wait for services to load
await this.waitForServicesLoad();
}
/**
* Wait for services to load with timeout
* @returns {Promise<void>}
*/
async waitForServicesLoad() {
const timeout = 10000; // 10 seconds timeout
const startTime = Date.now();
return new Promise((resolve, reject) => {
const checkServices = () => {
if (this.components.servicesManager.isLoaded()) {
resolve();
} else if (Date.now() - startTime > timeout) {
reject(new Error("Services loading timeout"));
} else {
setTimeout(checkServices, 100);
}
};
checkServices();
});
}
/**
* Set up global error handling
*/
setupErrorHandling() {
// Handle unhandled promise rejections
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled promise rejection:", event.reason);
this.showError("An unexpected error occurred. Please refresh the page.");
event.preventDefault();
});
// Handle JavaScript errors
window.addEventListener("error", (event) => {
console.error("JavaScript error:", event.error);
this.showError("An unexpected error occurred. Please refresh the page.");
});
}
/**
* Set up performance monitoring
*/
setupPerformanceMonitoring() {
// Monitor long tasks that block the main thread
if ("PerformanceObserver" in window) {
try {
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.duration > 50) {
// Tasks longer than 50ms
console.warn(`Long task detected: ${entry.duration}ms`);
}
});
});
observer.observe({ entryTypes: ["longtask"] });
} catch (error) {
console.warn("Performance monitoring not available:", error);
}
}
}
/**
* Show application loading state
*/
showLoadingState() {
const container = document.querySelector(".container");
if (container) {
container.classList.add("loading");
// Add loading indicator if not exists
if (!document.querySelector(".app-loading")) {
const loading = document.createElement("div");
loading.className = "app-loading";
loading.innerHTML = `
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
</div>
<p>Loading META.js Customizer...</p>
`;
container.appendChild(loading);
}
}
}
/**
* Hide application loading state
*/
hideLoadingState() {
const container = document.querySelector(".container");
const loading = document.querySelector(".app-loading");
if (container) {
container.classList.remove("loading");
}
if (loading) {
loading.remove();
}
}
/**
* Show initialization error
* @param {Error} error - Initialization error
*/
showInitializationError(error) {
const container = document.querySelector(".container");
if (container) {
container.innerHTML = `
<div class="initialization-error">
<div class="error-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<h2>Failed to Initialize</h2>
<p>META.js Customizer failed to load properly.</p>
<details>
<summary>Error Details</summary>
<pre>${error.message}</pre>
</details>
<button onclick="window.location.reload()" class="btn btn-primary">
<i class="fas fa-refresh"></i> Reload Page
</button>
</div>
`;
}
}
/**
* Show error message to user
* @param {string} message - Error message
*/
showError(message) {
// Create or update error toast
let errorToast = document.querySelector(".error-toast");
if (!errorToast) {
errorToast = document.createElement("div");
errorToast.className = "error-toast";
document.body.appendChild(errorToast);
}
errorToast.innerHTML = `
<div class="toast-content">
<i class="fas fa-exclamation-triangle"></i>
<span>${message}</span>
<button class="toast-close" onclick="this.parentNode.parentNode.remove()">
<i class="fas fa-times"></i>
</button>
</div>
`;
errorToast.classList.add("show");
// Auto-hide after 5 seconds
setTimeout(() => {
if (errorToast && errorToast.parentNode) {
errorToast.remove();
}
}, 5000);
}
/**
* Get component instance
* @param {string} componentName - Name of component
* @returns {Object|null} Component instance or null
*/
getComponent(componentName) {
return this.components[componentName] || null;
}
/**
* Check if application is ready
* @returns {boolean} True if initialized
*/
isReady() {
return this.isInitialized;
}
/**
* Destroy application and cleanup
*/
destroy() {
// Cleanup components if they have destroy methods
Object.values(this.components).forEach((component) => {
if (typeof component.destroy === "function") {
component.destroy();
}
});
this.components = {};
this.isInitialized = false;
}
}
// Initialize application when DOM is ready
document.addEventListener("DOMContentLoaded", () => {
// Make app instance globally available for debugging
window.metaJSCustomizer = new MetaJSCustomizer();
});
// Export for module usage
export default MetaJSCustomizer;
+333
View File
@@ -0,0 +1,333 @@
/**
* Output Manager Component
*
* Handles META.js generation and output operations including:
* - Real-time META.js generation from selected services
* - Clipboard integration with error handling
* - File download functionality with progress indication
* - Template management and substitution
*
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/
export class OutputManager {
constructor(serviceConfigGenerator) {
this.serviceConfigGenerator = serviceConfigGenerator;
this.templateCache = null;
this.elements = {
copyButton: document.getElementById("copyButton"),
downloadButton: document.getElementById("downloadButton"),
outputContainer: document.getElementById("output-container"),
};
this.init();
}
/**
* Initialize output manager
*/
init() {
this.setupEventListeners();
this.loadTemplate();
this.setupServiceChangeListener();
}
/**
* Load META.js template
*/
async loadTemplate() {
try {
// Template is already loaded globally as metaTemplate
if (typeof metaTemplate !== "undefined") {
this.templateCache = metaTemplate;
} else {
throw new Error("META.js template not found");
}
} catch (error) {
console.error("Failed to load template:", error);
this.showError("Failed to load META.js template");
}
}
/**
* Set up event listeners
*/
setupEventListeners() {
this.elements.copyButton.addEventListener("click", () =>
this.copyToClipboard()
);
this.elements.downloadButton.addEventListener("click", () =>
this.downloadFile()
);
}
/**
* Listen for service selection changes
*/
setupServiceChangeListener() {
document.addEventListener("servicesChanged", (event) => {
this.handleServicesChanged(event.detail.services);
});
}
/**
* Handle services selection change
* @param {Array<string>} services - Selected services
*/
async handleServicesChanged(services) {
if (services.length === 0) {
this.updateOutput("");
this.setButtonsState(false);
} else {
this.setButtonsState(true, "Generating...");
try {
const output = await this.generateOutput(services);
this.updateOutput(output);
this.setButtonsState(true);
} catch (error) {
console.error("Failed to generate output:", error);
this.showError("Failed to generate META.js");
this.setButtonsState(false);
}
}
}
/**
* Generate META.js output from services
* @param {Array<string>} services - Selected services
* @returns {Promise<string>} Generated META.js code
*/
async generateOutput(services) {
if (!this.templateCache) {
throw new Error("Template not loaded");
}
if (services.length === 0) {
return this.templateCache.replace("/* SERVICES_PLACEHOLDER */", "");
}
const configs = await this.serviceConfigGenerator.generateServiceConfigs(
services
);
const servicesString = this.serviceConfigGenerator.configsToString(configs);
return this.templateCache.replace(
"/* SERVICES_PLACEHOLDER */",
servicesString
);
}
/**
* Update output display
* @param {string} output - Generated output
*/
updateOutput(output) {
// Find or create output display element
let outputDisplay =
this.elements.outputContainer.querySelector(".output-display");
if (!outputDisplay) {
outputDisplay = document.createElement("div");
outputDisplay.className = "output-display";
this.elements.outputContainer
.querySelector(".card-content")
.appendChild(outputDisplay);
}
if (output.trim()) {
outputDisplay.innerHTML = `
<pre><code class="language-javascript">${this.escapeHtml(
output
)}</code></pre>
`;
} else {
outputDisplay.innerHTML = `
<div class="no-output">
<i class="fas fa-code"></i>
<h3>No configuration generated</h3>
<p>Select services to generate your META.js</p>
</div>
`;
}
}
/**
* Escape HTML for safe display
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
/**
* Set buttons enabled/disabled state
* @param {boolean} enabled - Whether buttons should be enabled
* @param {string} [loadingText] - Optional loading text
*/
setButtonsState(enabled, loadingText = null) {
const buttons = [this.elements.copyButton, this.elements.downloadButton];
buttons.forEach((button) => {
button.disabled = !enabled;
if (loadingText && !enabled) {
const originalContent =
button.dataset.originalContent || button.innerHTML;
button.dataset.originalContent = originalContent;
button.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${loadingText}`;
} else if (button.dataset.originalContent) {
button.innerHTML = button.dataset.originalContent;
delete button.dataset.originalContent;
}
});
}
/**
* Copy generated output to clipboard
*/
async copyToClipboard() {
try {
const outputDisplay = this.elements.outputContainer.querySelector(
".output-display pre code"
);
if (!outputDisplay) {
throw new Error("No output to copy");
}
const textToCopy = outputDisplay.textContent;
await navigator.clipboard.writeText(textToCopy);
this.showCopySuccess();
} catch (error) {
console.error("Copy failed:", error);
this.showCopyError();
}
}
/**
* Show copy success feedback
*/
showCopySuccess() {
const originalContent = this.elements.copyButton.innerHTML;
this.elements.copyButton.innerHTML = '<i class="fas fa-check"></i> Copied!';
this.elements.copyButton.classList.add("btn-success");
setTimeout(() => {
this.elements.copyButton.innerHTML = originalContent;
this.elements.copyButton.classList.remove("btn-success");
}, 2000);
}
/**
* Show copy error feedback
*/
showCopyError() {
const originalContent = this.elements.copyButton.innerHTML;
this.elements.copyButton.innerHTML =
'<i class="fas fa-exclamation-triangle"></i> Copy Failed';
this.elements.copyButton.classList.add("btn-danger");
setTimeout(() => {
this.elements.copyButton.innerHTML = originalContent;
this.elements.copyButton.classList.remove("btn-danger");
}, 2000);
}
/**
* Download generated output as file
*/
async downloadFile() {
try {
const outputDisplay = this.elements.outputContainer.querySelector(
".output-display pre code"
);
if (!outputDisplay) {
throw new Error("No output to download");
}
const content = outputDisplay.textContent;
const blob = new Blob([content], { type: "text/javascript" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "META.js";
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
this.showDownloadSuccess();
} catch (error) {
console.error("Download failed:", error);
this.showDownloadError();
}
}
/**
* Show download success feedback
*/
showDownloadSuccess() {
const originalContent = this.elements.downloadButton.innerHTML;
this.elements.downloadButton.innerHTML =
'<i class="fas fa-check"></i> Downloaded!';
this.elements.downloadButton.classList.add("btn-success");
setTimeout(() => {
this.elements.downloadButton.innerHTML = originalContent;
this.elements.downloadButton.classList.remove("btn-success");
}, 1500);
}
/**
* Show download error feedback
*/
showDownloadError() {
const originalContent = this.elements.downloadButton.innerHTML;
this.elements.downloadButton.innerHTML =
'<i class="fas fa-exclamation-triangle"></i> Download Failed';
this.elements.downloadButton.classList.add("btn-danger");
setTimeout(() => {
this.elements.downloadButton.innerHTML = originalContent;
this.elements.downloadButton.classList.remove("btn-danger");
}, 2000);
}
/**
* Show error message
* @param {string} message - Error message
*/
showError(message) {
const outputDisplay =
this.elements.outputContainer.querySelector(".output-display") ||
document.createElement("div");
outputDisplay.className = "output-display";
outputDisplay.innerHTML = `
<div class="output-error">
<i class="fas fa-exclamation-triangle"></i>
<h3>Error</h3>
<p>${message}</p>
</div>
`;
if (!outputDisplay.parentNode) {
this.elements.outputContainer
.querySelector(".card-content")
.appendChild(outputDisplay);
}
}
}
@@ -0,0 +1,236 @@
/**
* Service Configuration Generator
*
* Handles intelligent service configuration generation with:
* - Smart favicon detection and URL generation
* - Service-specific configuration logic
* - Complex domain patterns and special cases handling
* - Integration with xixu-me/favicons repository
*
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/
export class ServiceConfigGenerator {
constructor() {
this.faviconBaseUrl =
"https://raw.githubusercontent.com/xixu-me/favicons/main/";
this.faviconCache = new Map();
// Special domain mappings for services that don't follow standard patterns
this.specialDomains = new Map([
["rednote", "xiaohongshu.com"],
["xai", "x.ai"],
["discord", "discord.com"],
["telegram", "t.me"],
["whatsapp", "web.whatsapp.com"],
["instagram", "instagram.com"],
["facebook", "facebook.com"],
["twitter", "twitter.com"],
["x", "x.com"],
["linkedin", "linkedin.com"],
["tiktok", "tiktok.com"],
["youtube", "youtube.com"],
["reddit", "reddit.com"],
["github", "github.com"],
["gitlab", "gitlab.com"],
["bitbucket", "bitbucket.org"],
["stackoverflow", "stackoverflow.com"],
["medium", "medium.com"],
["dev", "dev.to"],
["hashnode", "hashnode.com"],
["notion", "notion.so"],
["figma", "figma.com"],
["spotify", "spotify.com"],
["apple-music", "music.apple.com"],
["netflix", "netflix.com"],
["amazon", "amazon.com"],
["ebay", "ebay.com"],
["aliexpress", "aliexpress.com"],
["taobao", "taobao.com"],
["jd", "jd.com"],
]);
}
/**
* Check if favicon exists for a service
* @param {string} serviceName - Service name to check
* @returns {Promise<boolean>} True if favicon exists
*/
async checkFaviconExists(serviceName) {
if (this.faviconCache.has(serviceName)) {
return this.faviconCache.get(serviceName);
}
try {
const response = await fetch(`${this.faviconBaseUrl}${serviceName}.svg`, {
method: "HEAD",
});
const exists = response.ok;
this.faviconCache.set(serviceName, exists);
return exists;
} catch (error) {
console.warn(`Failed to check favicon for ${serviceName}:`, error);
this.faviconCache.set(serviceName, false);
return false;
}
}
/**
* Parse domain into components (SLD and TLD)
* @param {string} domain - Domain to parse
* @returns {Object} Object with sld and tld properties
*/
parseDomain(domain) {
const parts = domain.split(".");
if (parts.length < 2) {
throw new Error(`Invalid domain: ${domain}`);
}
// Handle special TLDs like .co.uk, .com.au, etc.
const specialTlds = [
"co.uk",
"com.au",
"co.jp",
"co.kr",
"com.br",
"co.in",
];
const lastTwoParts = parts.slice(-2).join(".");
if (specialTlds.includes(lastTwoParts)) {
return {
sld: parts[parts.length - 3] || parts[parts.length - 2],
tld: lastTwoParts,
};
}
return {
sld: parts[parts.length - 2],
tld: parts[parts.length - 1],
};
}
/**
* Generate configuration for a single service
* @param {string} serviceName - Service name
* @returns {Promise<Object>} Service configuration object
*/
async generateServiceConfig(serviceName) {
const config = { name: serviceName };
// Check if service has special domain mapping
if (this.specialDomains.has(serviceName)) {
const domain = this.specialDomains.get(serviceName);
// Check if favicon exists
const faviconExists = await this.checkFaviconExists(serviceName);
if (!faviconExists) {
// Use full domain if no favicon
config.domain = domain;
} else {
// Parse domain and use components
try {
const { sld, tld } = this.parseDomain(domain);
// If service name matches SLD, only use TLD
if (serviceName.toLowerCase() === sld.toLowerCase()) {
config.tld = tld;
} else {
// Service name doesn't match SLD, include both
config.sld = sld;
config.tld = tld;
}
} catch (error) {
console.warn(
`Failed to parse domain ${domain} for ${serviceName}:`,
error
);
config.domain = domain;
}
}
} else {
// Standard service - assume serviceName.com pattern
const faviconExists = await this.checkFaviconExists(serviceName);
if (!faviconExists) {
// Fallback to full domain
config.domain = `${serviceName}.com`;
} else {
// Use TLD only
config.tld = "com";
}
}
return config;
}
/**
* Generate configurations for multiple services
* @param {Array<string>} serviceNames - Array of service names
* @returns {Promise<Array<Object>>} Array of service configuration objects
*/
async generateServiceConfigs(serviceNames) {
const configs = await Promise.all(
serviceNames.map((name) => this.generateServiceConfig(name))
);
return configs;
}
/**
* Convert service configuration to string representation
* @param {Object} config - Service configuration object
* @returns {string} String representation of configuration
*/
configToString(config) {
const parts = [`name: "${config.name}"`];
if (config.tld) {
parts.push(`tld: "${config.tld}"`);
}
if (config.sld) {
parts.push(`sld: "${config.sld}"`);
}
if (config.domain) {
parts.push(`domain: "${config.domain}"`);
}
if (config.alias) {
parts.push(`alias: "${config.alias}"`);
}
return `{ ${parts.join(", ")} }`;
}
/**
* Convert multiple service configurations to formatted string
* @param {Array<Object>} configs - Array of service configuration objects
* @returns {string} Formatted string representation
*/
configsToString(configs) {
return configs
.map((config) => ` ${this.configToString(config)}`)
.join(",\n");
}
/**
* Clear favicon cache (useful for testing or forced refresh)
*/
clearCache() {
this.faviconCache.clear();
}
/**
* Get cached favicon status for debugging
* @returns {Map} Map of cached favicon statuses
*/
getCacheStatus() {
return new Map(this.faviconCache);
}
}
+314
View File
@@ -0,0 +1,314 @@
/**
* Services Manager Component
*
* Handles service data management including:
* - Retrieval from RFM repository via GitHub API
* - Real-time filtering and searching
* - Selected services management with persistence
* - Debounced search optimization
*
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/
export class ServicesManager {
constructor() {
this.owner = "xixu-me";
this.repo = "RFM";
this.branch = "universal";
this.apiUrl = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${this.branch}?recursive=1`;
this.allServices = [];
this.selectedServices = new Set();
this.searchTimeout = null;
this.elements = {
searchInput: document.getElementById("search-input"),
searchResults: document.getElementById("search-results"),
selectedServices: document.getElementById("selected-services"),
selectedCount: document.getElementById("selected-count"),
};
this.init();
}
/**
* Initialize services manager
*/
async init() {
this.setupEventListeners();
await this.fetchServices();
this.updateUI();
}
/**
* Fetch available services from GitHub API
* @returns {Promise<void>}
*/
async fetchServices() {
try {
const response = await fetch(this.apiUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch services: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
const mrsFiles = data.tree.filter(
(file) => file.path.endsWith(".mrs") && file.type === "blob"
);
this.allServices = mrsFiles.map((file) => ({
name: file.path.replace(".mrs", ""),
path: file.path,
url: file.url,
}));
this.allServices.sort((a, b) => a.name.localeCompare(b.name));
} catch (error) {
console.error("Error fetching services:", error);
this.showError(`Error loading services: ${error.message}`);
}
}
/**
* Show error message in search results
* @param {string} message - Error message to display
*/
showError(message) {
this.elements.searchResults.innerHTML = `
<div class="search-error">
<i class="fas fa-exclamation-triangle"></i>
<p>${message}</p>
</div>
`;
}
/**
* Set up event listeners
*/
setupEventListeners() {
// Debounced search input
this.elements.searchInput.addEventListener("input", (e) => {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.handleSearch(e.target.value);
}, 300);
});
// Hide search results on blur with delay for clicks
this.elements.searchInput.addEventListener("blur", () => {
setTimeout(() => {
this.hideSearchResults();
}, 200);
});
// Handle search result clicks
this.elements.searchResults.addEventListener("click", (e) => {
this.handleSearchResultClick(e);
});
// Handle selected service removal
this.elements.selectedServices.addEventListener("click", (e) => {
this.handleSelectedServiceClick(e);
});
}
/**
* Handle search input with debouncing
* @param {string} searchTerm - Search term
*/
handleSearch(searchTerm) {
if (!searchTerm.trim()) {
this.hideSearchResults();
return;
}
const filteredServices = this.allServices.filter(
(service) =>
service.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
!this.selectedServices.has(service.name)
);
this.showSearchResults(filteredServices);
}
/**
* Show search results dropdown
* @param {Array} services - Filtered services array
*/
showSearchResults(services) {
if (services.length === 0) {
this.elements.searchResults.innerHTML = `
<div class="search-result-item search-no-results">
<i class="fas fa-search"></i>
No matching services found
</div>
`;
} else {
this.elements.searchResults.innerHTML = services
.slice(0, 10) // Limit to 10 results for performance
.map(
(service) => `
<div class="search-result-item" data-service-name="${service.name}">
<i class="fas fa-plus-circle"></i>
<span>${service.name}</span>
</div>
`
)
.join("");
}
this.elements.searchResults.classList.add("show");
}
/**
* Hide search results dropdown
*/
hideSearchResults() {
this.elements.searchResults.classList.remove("show");
}
/**
* Handle clicks on search results
* @param {Event} event - Click event
*/
handleSearchResultClick(event) {
const resultItem = event.target.closest(".search-result-item");
if (!resultItem || resultItem.classList.contains("search-no-results"))
return;
const serviceName = resultItem.dataset.serviceName;
if (serviceName) {
this.addService(serviceName);
}
}
/**
* Handle clicks on selected services (removal)
* @param {Event} event - Click event
*/
handleSelectedServiceClick(event) {
if (event.target.classList.contains("service-remove-btn")) {
const serviceName = event.target.dataset.serviceName;
if (serviceName) {
this.removeService(serviceName);
}
}
}
/**
* Add service to selection
* @param {string} serviceName - Service name to add
*/
addService(serviceName) {
if (!this.selectedServices.has(serviceName)) {
this.selectedServices.add(serviceName);
this.updateUI();
this.clearSearch();
this.triggerSelectionChange();
}
}
/**
* Remove service from selection
* @param {string} serviceName - Service name to remove
*/
removeService(serviceName) {
if (this.selectedServices.has(serviceName)) {
this.selectedServices.delete(serviceName);
this.updateUI();
this.triggerSelectionChange();
}
}
/**
* Clear search input and hide results
*/
clearSearch() {
this.elements.searchInput.value = "";
this.hideSearchResults();
}
/**
* Update UI components
*/
updateUI() {
this.updateSelectedCount();
this.renderSelectedServices();
}
/**
* Update selected services count display
*/
updateSelectedCount() {
const selectedCount = this.selectedServices.size;
const totalCount = this.allServices.length;
this.elements.selectedCount.textContent = `${selectedCount} of ${totalCount} selected`;
}
/**
* Render selected services list
*/
renderSelectedServices() {
if (this.selectedServices.size === 0) {
this.elements.selectedServices.innerHTML = `
<div class="no-services">
<i class="fas fa-inbox"></i>
<h3>No services selected</h3>
<p>Search for services above to get started</p>
</div>
`;
return;
}
const selectedArray = Array.from(this.selectedServices).sort();
this.elements.selectedServices.innerHTML = selectedArray
.map(
(serviceName, index) => `
<div class="selected-service-item" data-index="${index}">
<div class="service-info">
<i class="fas fa-grip-lines service-drag-handle" title="Drag to reorder"></i>
<span class="service-name">${serviceName}</span>
</div>
<button class="service-remove-btn"
data-service-name="${serviceName}"
title="Remove ${serviceName}"
aria-label="Remove ${serviceName}">
<i class="fas fa-times"></i>
</button>
</div>
`
)
.join("");
}
/**
* Get selected services as array (ordered)
* @returns {Array<string>} Array of selected service names
*/
getSelectedServices() {
return Array.from(this.selectedServices);
}
/**
* Trigger selection change event for other components
*/
triggerSelectionChange() {
const event = new CustomEvent("servicesChanged", {
detail: { services: this.getSelectedServices() },
});
document.dispatchEvent(event);
}
/**
* Check if services are loaded
* @returns {boolean} True if services are loaded
*/
isLoaded() {
return this.allServices.length > 0;
}
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Theme Manager Component
*
* Handles theme detection, switching, and persistence with support for:
* - System theme detection with auto-switching
* - Manual theme override (light/dark/auto)
* - Smooth theme transitions with CSS custom properties
* - LocalStorage persistence
*
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/
export class ThemeManager {
constructor() {
this.themeToggle = document.getElementById("theme-toggle");
this.themes = ["auto", "light", "dark"];
this.currentThemeIndex = 0;
this.init();
}
/**
* Initialize theme system
*/
init() {
this.setupTheme();
this.setupEventListeners();
this.setupSystemThemeListener();
}
/**
* Get current system theme preference
* @returns {string} 'dark' or 'light'
*/
getSystemTheme() {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
/**
* Set up initial theme based on saved preference or system theme
*/
setupTheme() {
const savedTheme = localStorage.getItem("theme");
if (savedTheme && this.themes.includes(savedTheme)) {
this.currentThemeIndex = this.themes.indexOf(savedTheme);
}
this.applyTheme();
}
/**
* Apply current theme to document
*/
applyTheme() {
const currentTheme = this.themes[this.currentThemeIndex];
if (currentTheme === "auto") {
const systemTheme = this.getSystemTheme();
document.documentElement.setAttribute("data-theme", systemTheme);
localStorage.removeItem("theme");
} else {
document.documentElement.setAttribute("data-theme", currentTheme);
localStorage.setItem("theme", currentTheme);
}
this.updateThemeIcon();
}
/**
* Update theme toggle button icon and tooltip
*/
updateThemeIcon() {
const currentTheme = this.themes[this.currentThemeIndex];
const icon = this.themeToggle.querySelector("i");
const iconMap = {
auto: "fas fa-circle-half-stroke",
light: "fas fa-sun",
dark: "fas fa-moon",
};
const tooltipMap = {
auto: "Theme: Auto (following system)",
light: "Theme: Light",
dark: "Theme: Dark",
};
icon.className = iconMap[currentTheme];
this.themeToggle.title = tooltipMap[currentTheme];
this.themeToggle.setAttribute("aria-label", tooltipMap[currentTheme]);
}
/**
* Cycle to next theme
*/
toggleTheme() {
this.currentThemeIndex = (this.currentThemeIndex + 1) % this.themes.length;
this.applyTheme();
}
/**
* Set up event listeners
*/
setupEventListeners() {
this.themeToggle.addEventListener("click", () => this.toggleTheme());
}
/**
* Listen for system theme changes and apply when in auto mode
*/
setupSystemThemeListener() {
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
if (this.themes[this.currentThemeIndex] === "auto") {
this.applyTheme();
}
});
}
}
-378
View File
@@ -1,378 +0,0 @@
/**
* META.js Customizer - Main App Script
*
* A modern, accessible web app for generating custom META.js configurations
* using service rulesets from the xixu-me/RFM repository.
*
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/
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", "");
const formattedName = name;
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();
});
+428 -4
View File
@@ -1,15 +1,24 @@
/** /**
* META.js Customizer - Main Stylesheet * META.js Customizer - Main Stylesheet
* Modern, accessible, and maintainable styles with BEM methodology *
* Author: xixu-me * Modern, accessible, and maintainable styles following:
* License: GPL-3.0 * - BEM methodology for consistent naming
* - CSS Custom Properties for theming
* - Modular architecture with component separation
* - Glassmorphism design with backdrop blur effects
* - Mobile-first responsive design
* - WCAG compliance for accessibility
*
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/ */
/* ========================================================================== /* ==========================================================================
CSS Custom Properties (Design System) CSS Custom Properties (Design System)
========================================================================== */ ========================================================================== */
/* CSS Variables for theming */ /* Design tokens for consistent theming */
:root { :root {
--primary-color: #2563eb; --primary-color: #2563eb;
--primary-hover: #1d4ed8; --primary-hover: #1d4ed8;
@@ -1098,3 +1107,418 @@ body::before {
); );
border-color: rgba(245, 158, 11, 0.4); border-color: rgba(245, 158, 11, 0.4);
} }
/* ==========================================================================
Component Styles
========================================================================== */
/* Loading States */
.app-loading {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
z-index: 9999;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 20px;
padding: 3rem;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loading-spinner {
font-size: 3rem;
color: var(--primary-color);
margin-bottom: 1rem;
}
.loading-spinner i {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Error States */
.initialization-error {
text-align: center;
padding: 4rem 2rem;
max-width: 600px;
margin: 0 auto;
}
.error-icon {
font-size: 4rem;
color: var(--danger-color);
margin-bottom: 1.5rem;
}
.initialization-error h2 {
color: var(--text-primary);
margin-bottom: 1rem;
font-size: 2rem;
}
.initialization-error p {
color: var(--text-secondary);
margin-bottom: 2rem;
font-size: 1.1rem;
}
.initialization-error details {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 1rem;
margin-bottom: 2rem;
text-align: left;
}
.initialization-error summary {
cursor: pointer;
font-weight: 600;
margin-bottom: 0.5rem;
}
.initialization-error pre {
background: rgba(0, 0, 0, 0.2);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Toast Notifications */
.error-toast {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
max-width: 400px;
background: rgba(220, 38, 38, 0.1);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(220, 38, 38, 0.3);
border-radius: 12px;
padding: 1rem;
box-shadow: 0 8px 32px 0 rgba(220, 38, 38, 0.2);
transform: translateX(120%);
transition: transform 0.3s ease;
}
.error-toast.show {
transform: translateX(0);
}
.toast-content {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-primary);
}
.toast-content i:first-child {
color: var(--danger-color);
font-size: 1.25rem;
}
.toast-close {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.25rem;
border-radius: 4px;
transition: color 0.2s ease;
margin-left: auto;
}
.toast-close:hover {
color: var(--text-primary);
}
/* Output Display */
.output-display {
margin-top: 1rem;
}
.output-display pre {
background: rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 1.5rem;
overflow-x: auto;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.875rem;
line-height: 1.6;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.output-display code {
color: var(--text-primary);
}
.no-output {
text-align: center;
padding: 3rem 2rem;
color: var(--text-secondary);
}
.no-output i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.no-output h3 {
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.output-error {
text-align: center;
padding: 2rem;
color: var(--danger-color);
}
.output-error i {
font-size: 2rem;
margin-bottom: 1rem;
}
.output-error h3 {
margin-bottom: 0.5rem;
}
/* Search Results Enhancements */
.search-error {
padding: 1rem;
text-align: center;
color: var(--danger-color);
}
.search-error i {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.search-no-results {
color: var(--text-muted) !important;
cursor: default !important;
font-style: italic;
}
.search-no-results:hover {
background: transparent !important;
transform: none !important;
}
/* Selected Services Enhancements */
.selected-service-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
margin-bottom: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.selected-service-item::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--primary-color);
transition: width 0.2s ease;
}
.selected-service-item:hover {
background: rgba(255, 255, 255, 0.08);
transform: translateY(-2px);
box-shadow: 0 8px 24px 0 rgba(0, 0, 0, 0.1);
}
.selected-service-item:hover::before {
width: 8px;
}
.service-info {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.service-drag-handle {
color: var(--text-muted);
cursor: grab;
font-size: 0.875rem;
transition: color 0.2s ease;
}
.service-drag-handle:hover {
color: var(--text-secondary);
}
.service-drag-handle:active {
cursor: grabbing;
}
.service-name {
font-weight: 500;
color: var(--text-primary);
}
.service-remove-btn {
background: rgba(220, 38, 38, 0.1);
border: 1px solid rgba(220, 38, 38, 0.2);
color: var(--danger-color);
border-radius: 8px;
padding: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
}
.service-remove-btn:hover {
background: rgba(220, 38, 38, 0.2);
border-color: rgba(220, 38, 38, 0.4);
transform: scale(1.1);
}
.service-remove-btn:active {
transform: scale(0.95);
}
/* Button Enhancements */
.btn-danger {
background: linear-gradient(
135deg,
var(--danger-color) 0%,
var(--danger-hover) 100%
);
border: 1px solid var(--danger-color);
}
.btn-danger:hover {
background: linear-gradient(135deg, var(--danger-hover) 0%, #991b1b 100%);
border-color: var(--danger-hover);
}
/* Dark Theme Specific Overrides */
[data-theme="dark"] .output-display pre {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.05);
}
[data-theme="dark"] .selected-service-item {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.05);
}
[data-theme="dark"] .selected-service-item:hover {
background: rgba(255, 255, 255, 0.06);
}
/* Focus Management for Accessibility */
.focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* Reduced Motion Support */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* High Contrast Mode Support */
@media (prefers-contrast: high) {
:root {
--border-color: #000000;
--text-secondary: #000000;
}
[data-theme="dark"] {
--border-color: #ffffff;
--text-secondary: #ffffff;
}
}
/* ==========================================================================
Accessibility Utilities
========================================================================== */
/* Screen reader only content */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Show screen reader content when focused */
.sr-only:focus {
position: static;
width: auto;
height: auto;
padding: 0.5rem;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
background: var(--bg-primary);
border: 2px solid var(--primary-color);
border-radius: 4px;
}
/* Skip link for keyboard navigation */
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: var(--primary-color);
color: white;
padding: 8px;
text-decoration: none;
border-radius: 4px;
z-index: 10001;
transition: top 0.2s ease;
}
.skip-link:focus {
top: 6px;
}
/* ==========================================================================
Global Styles
========================================================================== */
+13 -5
View File
@@ -1,20 +1,28 @@
/** /**
* META.js Template * META.js Template
* Modernized and well-documented template for generating META.js configurations *
* Source: https://github.com/xixu-me/META * Modernized and well-documented template for generating META.js.
* This template is used by the META.js Customizer to create custom configurations
* with user-selected services from the xixu-me/RFM repository.
*
* @source https://github.com/xixu-me/META
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/ */
const metaTemplate = `// from https://github.com/xixu-me/META const metaTemplate = `// Generated by META.js Customizer (https://metajs.xi-xu.me)
// Source: https://github.com/xixu-me/META
// ################################################### this section can be flexibly customized ################################################### // ################################################### this section can be flexibly customized ###################################################
const LOCATION_ICON_SET_URL = "https://img.icons8.com/color/144/";
// Routing rules are matched in order from top to bottom, with the rule at the top of the list taking precedence over the rules below it. // Routing rules are matched in order from top to bottom, with the rule at the top of the list taking precedence over the rules below it.
const services = [ const services = [
/* SERVICES_PLACEHOLDER */ /* SERVICES_PLACEHOLDER */
]; ];
const LOCATION_ICON_SET_URL = "https://img.icons8.com/color/144/";
const locations = [ const locations = [
{ {
name: "Argentina 🇦🇷", name: "Argentina 🇦🇷",
+36
View File
@@ -0,0 +1,36 @@
/**
* Module Validation Script
*
* Simple script to validate that all ES6 modules can be imported correctly
* and that the basic structure is working as expected.
*/
// Validate that all components can be imported
try {
import("./src/scripts/components/ThemeManager.js")
.then((module) => {
console.log("✓ ThemeManager module loaded successfully");
return import("./src/scripts/components/ServicesManager.js");
})
.then((module) => {
console.log("✓ ServicesManager module loaded successfully");
return import("./src/scripts/components/ServiceConfigGenerator.js");
})
.then((module) => {
console.log("✓ ServiceConfigGenerator module loaded successfully");
return import("./src/scripts/components/OutputManager.js");
})
.then((module) => {
console.log("✓ OutputManager module loaded successfully");
return import("./src/scripts/app.js");
})
.then((module) => {
console.log("✓ Main app module loaded successfully");
console.log("🎉 All modules loaded successfully! Architecture is valid.");
})
.catch((error) => {
console.error("❌ Module loading failed:", error);
});
} catch (error) {
console.error("❌ Dynamic import not supported:", error);
}