Add domain preview modal and manager

Introduces a domain preview modal to the UI, allowing users to preview domain rules for each service. Adds DomainPreviewManager.js to handle fetching and displaying domain data from YAML files. Updates ServicesManager.js to support preview actions and UI, and enhances main.css with modal and preview button styles.
This commit is contained in:
xixu-me committed 2025-07-20 22:07:09 +08:00
1 parent 6193723a7f
commit 45101cc497
5 files changed
+949 -1

No files matched your search

+44
View File
@@ -223,6 +223,50 @@
</section> </section>
</main> </main>
<!-- Domain Preview Modal -->
<div id="domain-preview-modal" class="modal-overlay" aria-hidden="true">
<div
class="modal-content"
role="dialog"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<div class="modal-header">
<h3 id="modal-title">Domain Preview</h3>
<button class="modal-close" aria-label="Close domain preview">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<div class="service-info-header">
<div class="service-icon">
<i class="fas fa-globe"></i>
</div>
<div class="service-details">
<h4 id="modal-service-name">Service Name</h4>
<p id="modal-description">Domain rules for this service</p>
</div>
</div>
<div class="domains-container">
<div class="loading-spinner" id="modal-loading">
<i class="fas fa-spinner fa-spin"></i>
<p>Loading domain list...</p>
</div>
<div
class="domains-list"
id="domains-list"
style="display: none"
></div>
<div class="modal-error" id="modal-error" style="display: none">
<i class="fas fa-exclamation-triangle"></i>
<h4>Failed to Load Domains</h4>
<p id="error-message">Unable to fetch domain information.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Information Section --> <!-- Information Section -->
<aside class="info-banner card" aria-labelledby="info-heading"> <aside class="info-banner card" aria-labelledby="info-heading">
<div class="card-header"> <div class="card-header">
+4
View File
@@ -15,6 +15,7 @@
* @license GPL-3.0 * @license GPL-3.0
*/ */
import { DomainPreviewManager } from "./components/DomainPreviewManager.js";
import { OutputManager } from "./components/OutputManager.js"; import { OutputManager } from "./components/OutputManager.js";
import { ServiceConfigGenerator } from "./components/ServiceConfigGenerator.js"; import { ServiceConfigGenerator } from "./components/ServiceConfigGenerator.js";
import { ServicesManager } from "./components/ServicesManager.js"; import { ServicesManager } from "./components/ServicesManager.js";
@@ -64,6 +65,9 @@ export class MetaJSCustomizer {
// Initialize service config generator (no dependencies) // Initialize service config generator (no dependencies)
this.components.serviceConfigGenerator = new ServiceConfigGenerator(); this.components.serviceConfigGenerator = new ServiceConfigGenerator();
// Initialize domain preview manager (no dependencies)
this.components.domainPreviewManager = new DomainPreviewManager();
// Initialize services manager (depends on API) // Initialize services manager (depends on API)
this.components.servicesManager = new ServicesManager(); this.components.servicesManager = new ServicesManager();
@@ -0,0 +1,446 @@
/**
* Domain Preview Manager Com this.elements = {
modal: document.getElementById("domain-preview-modal"),
modalTitle: document.getElementById("modal-title"),
modalServiceName: document.getElementById("modal-service-name"),
modalDescription: document.getElementById("modal-description"),
modalLoading: document.getElementById("modal-loading"),
domainsList: document.getElementById("domains-list"),
modalError: document.getElementById("modal-error"),
errorMessage: document.getElementById("error-message"),
modalClose: document.querySelector(".modal-close")
};Handles domain preview functionality including:
* - Fetching YAML files from GitHub repository
* - Parsing payload arrays from YAML content
* - Displaying domain lists in modal popup
* - Organizing domains by type (DOMAIN, DOMAIN-SUFFIX, DOMAIN-KEYWORD)
*
* @author xixu-me
* @license GPL-3.0
*/
export class DomainPreviewManager {
constructor() {
this.owner = "xixu-me";
this.repo = "RFM";
this.branch = "universal";
this.baseUrl = `https://raw.githubusercontent.com/${this.owner}/${this.repo}/${this.branch}/yaml`;
// Wait for DOM to be ready before initializing elements
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () =>
this.initializeElements()
);
} else {
this.initializeElements();
}
}
/**
* Initialize DOM elements after DOM is ready
*/
initializeElements() {
this.elements = {
modal: document.getElementById("domain-preview-modal"),
modalTitle: document.getElementById("modal-title"),
modalServiceName: document.getElementById("modal-service-name"),
modalDescription: document.getElementById("modal-description"),
modalLoading: document.getElementById("modal-loading"),
domainStats: document.getElementById("domain-stats"),
domainsList: document.getElementById("domains-list"),
modalError: document.getElementById("modal-error"),
errorMessage: document.getElementById("error-message"),
totalDomains: document.getElementById("total-domains"),
domainSuffixes: document.getElementById("domain-suffixes"),
keywords: document.getElementById("keywords"),
modalClose: document.querySelector(".modal-close"),
};
this.init();
}
/**
* Initialize the domain preview manager
*/
init() {
if (!this.elements.modal) {
console.error("Domain preview modal not found in DOM");
return;
}
this.setupEventListeners();
}
/**
* Set up event listeners
*/
setupEventListeners() {
// Close modal events
this.elements.modalClose.addEventListener("click", () => this.closeModal());
this.elements.modal.addEventListener("click", (e) => {
if (e.target === this.elements.modal) {
this.closeModal();
}
});
// Keyboard events
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this.isModalOpen()) {
this.closeModal();
}
});
// Listen for preview requests
document.addEventListener("domainPreviewRequest", (event) => {
this.showPreview(event.detail.serviceName);
});
}
/**
* Show domain preview for a service
* @param {string} serviceName - Name of the service
*/
async showPreview(serviceName) {
this.openModal();
this.showLoading(serviceName);
try {
const domainData = await this.fetchDomainData(serviceName);
this.displayDomainData(serviceName, domainData);
} catch (error) {
console.error("Failed to load domain data:", error);
this.showError("Failed to load domain information for this service.");
}
}
/**
* Fetch domain data from YAML file
* @param {string} serviceName - Name of the service
* @returns {Promise<Object>} Parsed domain data
*/
async fetchDomainData(serviceName) {
const yamlUrl = `${this.baseUrl}/${serviceName}.yaml`;
try {
const response = await fetch(yamlUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const yamlText = await response.text();
return this.parseYamlDomains(yamlText);
} catch (error) {
throw new Error(`Unable to fetch domain data: ${error.message}`);
}
}
/**
* Parse YAML content to extract domain information
* @param {string} yamlText - Raw YAML content
* @returns {Object} Parsed domain data organized by type
*/
parseYamlDomains(yamlText) {
const domainData = {
domains: [],
domainSuffixes: [],
keywords: [],
total: 0,
};
try {
// Simple YAML parsing focused on payload section
const lines = yamlText.split("\n");
let inPayloadSection = false;
let currentIndentation = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmedLine = line.trim();
// Skip comments and empty lines
if (!trimmedLine || trimmedLine.startsWith("#")) {
continue;
}
// Check if we're entering the payload section
if (trimmedLine === "payload:" || trimmedLine.startsWith("payload:")) {
inPayloadSection = true;
currentIndentation = line.indexOf(trimmedLine.split(":")[0]);
continue;
}
// If we're in payload section, look for list items
if (inPayloadSection) {
// Check for array items (lines starting with - after spaces)
const match = line.match(/^(\s*)-\s*(.+)$/);
if (match) {
const domain = match[2].trim();
// Remove quotes if present
const cleanDomain = domain.replace(/^["']|["']$/g, "");
if (cleanDomain) {
// Determine domain type based on prefix or content
if (cleanDomain.startsWith("+.")) {
// Domain suffix (e.g., "+.example.com")
domainData.domainSuffixes.push(cleanDomain);
} else if (
cleanDomain.includes("KEYWORD") ||
cleanDomain.toLowerCase().includes("keyword") ||
cleanDomain.includes("*")
) {
// Keywords or wildcard patterns
domainData.keywords.push(cleanDomain);
} else {
// Regular domain
domainData.domains.push(cleanDomain);
}
}
}
// Check if we've exited the payload section
else if (
trimmedLine &&
!trimmedLine.startsWith("-") &&
line.search(/\S/) <= currentIndentation &&
trimmedLine !== "payload:"
) {
inPayloadSection = false;
}
}
}
domainData.total =
domainData.domains.length +
domainData.domainSuffixes.length +
domainData.keywords.length;
// If no domains found, try a more aggressive approach
if (domainData.total === 0) {
this.parseYamlFallback(yamlText, domainData);
}
} catch (error) {
console.error("Error parsing YAML:", error);
// Try fallback parsing method
this.parseYamlFallback(yamlText, domainData);
}
return domainData;
}
/**
* Fallback YAML parsing method for different structures
* @param {string} yamlText - Raw YAML content
* @param {Object} domainData - Domain data object to populate
*/
parseYamlFallback(yamlText, domainData) {
try {
// Look for any line that looks like a domain
const lines = yamlText.split("\n");
const domainRegex =
/^[\s\-]*(?:["'])?([a-zA-Z0-9\-\+\.]+\.[a-zA-Z]{2,}|[\w\-\+\.]+\*[\w\-\.]*|\+\.[\w\-\.]+)(?:["'])?[\s]*$/;
for (const line of lines) {
const match = line.match(domainRegex);
if (match) {
const domain = match[1].trim();
if (domain.startsWith("+.")) {
domainData.domainSuffixes.push(domain);
} else if (domain.includes("*") || domain.includes("KEYWORD")) {
domainData.keywords.push(domain);
} else {
domainData.domains.push(domain);
}
}
}
domainData.total =
domainData.domains.length +
domainData.domainSuffixes.length +
domainData.keywords.length;
} catch (error) {
console.error("Fallback YAML parsing failed:", error);
}
}
/**
* Display domain data in the modal
* @param {string} serviceName - Name of the service
* @param {Object} domainData - Parsed domain data
*/
displayDomainData(serviceName, domainData) {
// Update service info
this.elements.modalServiceName.textContent = serviceName;
this.elements.modalDescription.textContent = `Domain rules and patterns for ${serviceName} service`;
// Build domains list HTML
let domainsHTML = "";
// Regular domains
if (domainData.domains.length > 0) {
domainsHTML += this.createDomainCategory(
"Exact Domains",
"fas fa-globe",
domainData.domains,
"Exact domain matches"
);
}
// Domain suffixes
if (domainData.domainSuffixes.length > 0) {
domainsHTML += this.createDomainCategory(
"Domain Suffixes",
"fas fa-sitemap",
domainData.domainSuffixes.map((domain) => `+.${domain}`),
"Matches domain and all subdomains"
);
}
// Keywords
if (domainData.keywords.length > 0) {
domainsHTML += this.createDomainCategory(
"Pattern Rules",
"fas fa-key",
domainData.keywords,
"Keyword-based and wildcard matching rules"
);
}
if (domainsHTML === "") {
domainsHTML =
'<div class="no-domains"><i class="fas fa-inbox"></i><p>No domain rules found</p></div>';
}
this.elements.domainsList.innerHTML = domainsHTML;
// Show content and hide loading
this.hideLoading();
this.elements.domainsList.style.display = "block";
// Add click handlers for category toggles
this.setupCategoryToggles();
}
/**
* Create HTML for a domain category
* @param {string} title - Category title
* @param {string} icon - Font Awesome icon class
* @param {Array} domains - Array of domains
* @param {string} description - Category description
* @returns {string} HTML string
*/
createDomainCategory(title, icon, domains, description) {
const domainItems = domains
.map(
(domain) =>
`<div class="domain-item" title="${description}">${domain}</div>`
)
.join("");
return `
<div class="domain-category">
<div class="category-header" data-category="${title.toLowerCase()}">
<div class="category-title">
<i class="${icon}"></i>
<span>${title}</span>
</div>
<div class="category-count">${domains.length}</div>
<i class="fas fa-chevron-down category-toggle"></i>
</div>
<div class="domain-items">
${domainItems}
</div>
</div>
`;
}
/**
* Set up category toggle functionality
*/
setupCategoryToggles() {
const categoryHeaders =
this.elements.domainsList.querySelectorAll(".category-header");
categoryHeaders.forEach((header) => {
header.addEventListener("click", () => {
header.classList.toggle("collapsed");
});
});
}
/**
* Show loading state
* @param {string} serviceName - Name of the service
*/
showLoading(serviceName) {
this.elements.modalServiceName.textContent = serviceName;
this.elements.modalDescription.textContent =
"Loading domain information...";
this.elements.modalLoading.style.display = "block";
this.elements.domainsList.style.display = "none";
this.elements.modalError.style.display = "none";
}
/**
* Hide loading state
*/
hideLoading() {
this.elements.modalLoading.style.display = "none";
}
/**
* Show error state
* @param {string} message - Error message
*/
showError(message) {
this.hideLoading();
this.elements.domainsList.style.display = "none";
this.elements.errorMessage.textContent = message;
this.elements.modalError.style.display = "block";
}
/**
* Open the modal
*/
openModal() {
this.elements.modal.classList.add("show");
this.elements.modal.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
// Focus the close button for accessibility
setTimeout(() => {
this.elements.modalClose.focus();
}, 100);
}
/**
* Close the modal
*/
closeModal() {
this.elements.modal.classList.remove("show");
this.elements.modal.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
}
/**
* Check if modal is currently open
* @returns {boolean} True if modal is open
*/
isModalOpen() {
return this.elements.modal.classList.contains("show");
}
/**
* Trigger domain preview for a service
* @param {string} serviceName - Name of the service
*/
static requestPreview(serviceName) {
const event = new CustomEvent("domainPreviewRequest", {
detail: { serviceName },
});
document.dispatchEvent(event);
}
}
+64 -1
View File
@@ -154,9 +154,17 @@ export class ServicesManager {
.map( .map(
(service) => ` (service) => `
<div class="search-result-item" data-service-name="${service.name}"> <div class="search-result-item" data-service-name="${service.name}">
<div style="display: flex; align-items: center; gap: 0.5rem; flex: 1;">
<i class="fas fa-plus-circle"></i> <i class="fas fa-plus-circle"></i>
<span>${service.name}</span> <span>${service.name}</span>
</div> </div>
<button class="preview-btn"
data-service-name="${service.name}"
title="Preview domains for ${service.name}">
<i class="fas fa-eye"></i>
Preview
</button>
</div>
` `
) )
.join(""); .join("");
@@ -177,6 +185,24 @@ export class ServicesManager {
* @param {Event} event - Click event * @param {Event} event - Click event
*/ */
handleSearchResultClick(event) { handleSearchResultClick(event) {
// Handle preview button clicks
if (
event.target.classList.contains("preview-btn") ||
event.target.closest(".preview-btn")
) {
event.preventDefault();
event.stopPropagation();
const previewBtn = event.target.classList.contains("preview-btn")
? event.target
: event.target.closest(".preview-btn");
const serviceName = previewBtn.dataset.serviceName;
if (serviceName) {
this.requestDomainPreview(serviceName);
}
return;
}
const resultItem = event.target.closest(".search-result-item"); const resultItem = event.target.closest(".search-result-item");
if (!resultItem || resultItem.classList.contains("search-no-results")) if (!resultItem || resultItem.classList.contains("search-no-results"))
return; return;
@@ -188,10 +214,29 @@ export class ServicesManager {
} }
/** /**
* Handle clicks on selected services (removal) * Handle clicks on selected services (removal and preview)
* @param {Event} event - Click event * @param {Event} event - Click event
*/ */
handleSelectedServiceClick(event) { handleSelectedServiceClick(event) {
// Handle preview button clicks
if (
event.target.classList.contains("preview-btn") ||
event.target.closest(".preview-btn")
) {
event.preventDefault();
event.stopPropagation();
const previewBtn = event.target.classList.contains("preview-btn")
? event.target
: event.target.closest(".preview-btn");
const serviceName = previewBtn.dataset.serviceName;
if (serviceName) {
this.requestDomainPreview(serviceName);
}
return;
}
// Handle remove button clicks
if (event.target.classList.contains("service-remove-btn")) { if (event.target.classList.contains("service-remove-btn")) {
const serviceName = event.target.dataset.serviceName; const serviceName = event.target.dataset.serviceName;
if (serviceName) { if (serviceName) {
@@ -238,6 +283,18 @@ export class ServicesManager {
this.hideSearchResults(); this.hideSearchResults();
} }
/**
* Request domain preview for a service
* @param {string} serviceName - Service name to preview
*/
requestDomainPreview(serviceName) {
// Dispatch custom event for domain preview
const event = new CustomEvent("domainPreviewRequest", {
detail: { serviceName },
});
document.dispatchEvent(event);
}
/** /**
* Update UI components * Update UI components
*/ */
@@ -281,6 +338,12 @@ export class ServicesManager {
</div> </div>
<div class="service-info"> <div class="service-info">
<span class="service-name">${serviceName}</span> <span class="service-name">${serviceName}</span>
<button class="preview-btn"
data-service-name="${serviceName}"
title="Preview domains for ${serviceName}">
<i class="fas fa-eye"></i>
Preview
</button>
</div> </div>
<button class="service-remove-btn" <button class="service-remove-btn"
data-service-name="${serviceName}" data-service-name="${serviceName}"
+391
View File
@@ -1471,6 +1471,7 @@ body::before {
.service-info { .service-info {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
flex: 1; flex: 1;
} }
@@ -1643,3 +1644,393 @@ body::before {
.skip-link:focus { .skip-link:focus {
top: 6px; top: 6px;
} }
/* Domain Preview Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.modal-overlay.show {
opacity: 1;
visibility: visible;
}
.modal-content {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 16px;
width: 90vw;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
transform: scale(0.9) translateY(20px);
transition: transform 0.3s ease;
}
.modal-overlay.show .modal-content {
transform: scale(1) translateY(0);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header h3 {
margin: 0;
color: var(--text-primary);
font-size: 1.25rem;
font-weight: 600;
}
.modal-close {
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;
}
.modal-close:hover {
background: rgba(220, 38, 38, 0.2);
transform: scale(1.1);
}
.modal-body {
padding: 1.5rem;
max-height: calc(80vh - 120px);
overflow-y: auto;
}
.service-info-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.service-icon {
width: 48px;
height: 48px;
background: var(--primary-color);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
}
.service-details h4 {
margin: 0 0 0.25rem 0;
color: var(--text-primary);
font-size: 1.1rem;
font-weight: 600;
}
.service-details p {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.domains-container {
position: relative;
}
.loading-spinner {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
.loading-spinner i {
font-size: 2rem;
margin-bottom: 1rem;
color: var(--primary-color);
}
.domain-stats {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.stat-item {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.75rem 1rem;
flex: 1;
min-width: 120px;
}
.stat-label {
display: block;
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.stat-value {
display: block;
font-size: 1.25rem;
font-weight: 600;
color: var(--primary-color);
}
.domains-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
}
.domain-category {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.domain-category:last-child {
border-bottom: none;
}
.category-header {
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
user-select: none;
transition: background 0.2s ease;
display: flex;
align-items: center;
justify-content: space-between;
}
.category-header:hover {
background: rgba(255, 255, 255, 0.08);
}
.category-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: var(--text-primary);
}
.category-count {
font-size: 0.9rem;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.1);
padding: 0.25rem 0.5rem;
border-radius: 12px;
}
.category-toggle {
color: var(--text-muted);
transition: transform 0.2s ease;
}
.category-header.collapsed .category-toggle {
transform: rotate(-90deg);
}
.domain-items {
padding: 0.5rem;
display: grid;
gap: 0.25rem;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
max-height: 200px;
overflow-y: auto;
}
.category-header.collapsed + .domain-items {
display: none;
}
.domain-item {
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
font-family: "Fira Code", monospace;
font-size: 0.85rem;
color: var(--text-secondary);
transition: all 0.2s ease;
word-break: break-all;
}
.domain-item:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
.modal-error {
text-align: center;
padding: 2rem;
color: var(--danger-color);
}
.modal-error i {
font-size: 2rem;
margin-bottom: 1rem;
}
.modal-error h4 {
margin-bottom: 0.5rem;
color: var(--danger-color);
}
.modal-error p {
color: var(--text-muted);
}
/* Preview button styles */
.preview-btn {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.2);
color: var(--primary-color);
border-radius: 6px;
padding: 0.25rem 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
margin-left: 0.5rem;
}
.preview-btn:hover {
background: rgba(99, 102, 241, 0.2);
border-color: rgba(99, 102, 241, 0.3);
transform: scale(1.05);
}
.preview-btn i {
font-size: 0.7rem;
}
/* Scrollbar styling for modal */
.modal-body::-webkit-scrollbar,
.domains-list::-webkit-scrollbar,
.domain-items::-webkit-scrollbar {
width: 6px;
}
.modal-body::-webkit-scrollbar-track,
.domains-list::-webkit-scrollbar-track,
.domain-items::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
.modal-body::-webkit-scrollbar-thumb,
.domains-list::-webkit-scrollbar-thumb,
.domain-items::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.modal-body::-webkit-scrollbar-thumb:hover,
.domains-list::-webkit-scrollbar-thumb:hover,
.domain-items::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
/* Responsive modal styles */
@media (max-width: 768px) {
.modal-content {
width: 95vw;
max-height: 90vh;
margin: 0 1rem;
}
.modal-header,
.modal-body {
padding: 1rem;
}
.service-info-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.domain-stats {
flex-direction: column;
gap: 0.5rem;
}
.stat-item {
min-width: auto;
}
.domain-items {
grid-template-columns: 1fr;
max-height: 150px;
}
.preview-btn {
font-size: 0.75rem;
padding: 0.25rem 0.4rem;
}
.category-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
@media (max-width: 480px) {
.modal-content {
width: 100vw;
height: 100vh;
max-height: 100vh;
border-radius: 0;
margin: 0;
}
.modal-header {
position: sticky;
top: 0;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
z-index: 1;
}
}