diff --git a/README.md b/README.md index 82f4406..fae041c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## 🎯 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 @@ -89,47 +89,72 @@ The app automatically detects system theme changes and applies them when in auto ## 🏗️ 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 -- Supports system theme detection with auto-switching -- Smooth theme transitions with CSS custom properties +#### **🎨 ThemeManager** -#### 🔍 **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 -- Real-time filtering and searching of services -- Intelligent service configuration generation -- Selected services management with persistence +#### **🔍 ServicesManager** -#### ⚙️ **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 -- Service-specific configuration logic -- Handles complex domain patterns and special cases +#### **⚙️ ServiceConfigGenerator** -#### 📤 **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 -- Clipboard integration with error handling -- File download functionality with progress indication +#### **📤 OutputManager** -#### 🎯 **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 -- Error handling and user feedback -- Component orchestration and lifecycle management +#### **🎯 MetaJSCustomizer (Main Controller)** -### 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+) -- **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 -- **Icons**: Font Awesome 6.5.1 -- **API**: GitHub REST API for dynamic service fetching -- **Build**: No build process required - runs directly in browser +- **Icons**: Font Awesome 6.5.1 for consistent iconography +- **API Integration**: GitHub REST API for dynamic service fetching +- **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 diff --git a/index.html b/index.html index 18523ab..761c6c7 100644 --- a/index.html +++ b/index.html @@ -47,111 +47,206 @@ - + + +
-
+
+
-

META.js Customizer

+

+ + META.js Customizer +

Create your custom META.js of - - xixu-me/META - with service rulesets from - - xixu-me/RFM xixu-me/META + + using service rulesets from + + xixu-me/RFM +

-
-
-

Services

-
-
-
- -
- Routing Priority: - - 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. - -
+ +
+
+
+

+ + Services Configuration +

-
-
-
- - -
-
- - 0 selected +
+ +
+ +
+ Routing Priority: + + 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. +
-
-
-
-
- -

No services selected

- Search for services above to get started + + +
+
+
+ + +
+
+ + 0 of 0 selected +
+
+ + + +
+ + +
+
+ +

No services selected

+

+ Search for services above to get started +

+
-
-
+
-
-
-

Generated META.js

-
-
-
- - + +
+
+

+ + Generated META.js +

-
-
+
+
+ + +
+
+

+ Copy the generated META.js to your clipboard +

+

+ Download the generated META.js as a file +

+
+
+ +
-
+ +
+ +
+ + - + diff --git a/src/scripts/app.js b/src/scripts/app.js new file mode 100644 index 0000000..ca6ad2b --- /dev/null +++ b/src/scripts/app.js @@ -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} + */ + 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 = ` +
+ +
+

Loading META.js Customizer...

+ `; + 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 = ` +
+
+ +
+

Failed to Initialize

+

META.js Customizer failed to load properly.

+
+ Error Details +
${error.message}
+
+ +
+ `; + } + } + + /** + * 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 = ` +
+ + ${message} + +
+ `; + + 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; diff --git a/src/scripts/components/OutputManager.js b/src/scripts/components/OutputManager.js new file mode 100644 index 0000000..92551ac --- /dev/null +++ b/src/scripts/components/OutputManager.js @@ -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} 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} services - Selected services + * @returns {Promise} 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 = ` +
${this.escapeHtml(
+          output
+        )}
+ `; + } else { + outputDisplay.innerHTML = ` +
+ +

No configuration generated

+

Select services to generate your META.js

+
+ `; + } + } + + /** + * 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 = ` ${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 = ' 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 = + ' 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 = + ' 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 = + ' 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 = ` +
+ +

Error

+

${message}

+
+ `; + + if (!outputDisplay.parentNode) { + this.elements.outputContainer + .querySelector(".card-content") + .appendChild(outputDisplay); + } + } +} diff --git a/src/scripts/components/ServiceConfigGenerator.js b/src/scripts/components/ServiceConfigGenerator.js new file mode 100644 index 0000000..9df3945 --- /dev/null +++ b/src/scripts/components/ServiceConfigGenerator.js @@ -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} 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} 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} serviceNames - Array of service names + * @returns {Promise>} 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} 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); + } +} diff --git a/src/scripts/components/ServicesManager.js b/src/scripts/components/ServicesManager.js new file mode 100644 index 0000000..010e0c3 --- /dev/null +++ b/src/scripts/components/ServicesManager.js @@ -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} + */ + 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 = ` +
+ +

${message}

+
+ `; + } + + /** + * 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 = ` +
+ + No matching services found +
+ `; + } else { + this.elements.searchResults.innerHTML = services + .slice(0, 10) // Limit to 10 results for performance + .map( + (service) => ` +
+ + ${service.name} +
+ ` + ) + .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 = ` +
+ +

No services selected

+

Search for services above to get started

+
+ `; + return; + } + + const selectedArray = Array.from(this.selectedServices).sort(); + this.elements.selectedServices.innerHTML = selectedArray + .map( + (serviceName, index) => ` +
+
+ + ${serviceName} +
+ +
+ ` + ) + .join(""); + } + + /** + * Get selected services as array (ordered) + * @returns {Array} 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; + } +} diff --git a/src/scripts/components/ThemeManager.js b/src/scripts/components/ThemeManager.js new file mode 100644 index 0000000..3726619 --- /dev/null +++ b/src/scripts/components/ThemeManager.js @@ -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(); + } + }); + } +} diff --git a/src/scripts/main.js b/src/scripts/main.js deleted file mode 100644 index d75af4c..0000000 --- a/src/scripts/main.js +++ /dev/null @@ -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 = `

Error loading services: ${error.message}

`; - 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 = - '
No matching services found
'; - } else { - searchResults.innerHTML = filteredServices - .map( - (service) => ` -
- ${service.name} -
- ` - ) - .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 = ` -
- -

No services selected

- Search for services above to get started -
- `; - return; - } - - selectedServices.innerHTML = selectedServicesList - .map( - (service) => ` -
- ${service.name} - -
- ` - ) - .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 = ' 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 = - ' 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(); -}); diff --git a/src/styles/main.css b/src/styles/main.css index a866a2d..da388f7 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -1,15 +1,24 @@ /** * META.js Customizer - Main Stylesheet - * Modern, accessible, and maintainable styles with BEM methodology - * Author: xixu-me - * License: GPL-3.0 + * + * Modern, accessible, and maintainable styles following: + * - 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 Variables for theming */ +/* Design tokens for consistent theming */ :root { --primary-color: #2563eb; --primary-hover: #1d4ed8; @@ -1098,3 +1107,418 @@ body::before { ); 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 + ========================================================================== */ diff --git a/src/templates/meta-template.js b/src/templates/meta-template.js index 0633ab5..061afb1 100644 --- a/src/templates/meta-template.js +++ b/src/templates/meta-template.js @@ -1,20 +1,28 @@ /** * 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 ################################################### -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. const services = [ /* SERVICES_PLACEHOLDER */ ]; +const LOCATION_ICON_SET_URL = "https://img.icons8.com/color/144/"; + const locations = [ { name: "Argentina 🇦🇷", diff --git a/validate-modules.js b/validate-modules.js new file mode 100644 index 0000000..fdb3af9 --- /dev/null +++ b/validate-modules.js @@ -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); +}