Add WLED auto-discovery via mDNS with zeroconf
Scan the local network for WLED devices advertising _wled._tcp.local. and present them in the Add Device modal for one-click selection. - New discovery.py: async mDNS browse + parallel /json/info enrichment - GET /api/v1/devices/discover endpoint with already_added dedup - Header scan button (magnifying glass icon) in add-device modal - Discovered devices show name, IP, LED count, version; click to fill form - en/ru locale strings for discovery UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ dependencies = [
|
|||||||
"python-dateutil>=2.9.0",
|
"python-dateutil>=2.9.0",
|
||||||
"python-multipart>=0.0.12",
|
"python-multipart>=0.0.12",
|
||||||
"wmi>=1.5.1; sys_platform == 'win32'",
|
"wmi>=1.5.1; sys_platform == 'win32'",
|
||||||
|
"zeroconf>=0.131.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from wled_controller.api.schemas.devices import (
|
|||||||
DeviceResponse,
|
DeviceResponse,
|
||||||
DeviceStateResponse,
|
DeviceStateResponse,
|
||||||
DeviceUpdate,
|
DeviceUpdate,
|
||||||
|
DiscoveredDeviceResponse,
|
||||||
|
DiscoverDevicesResponse,
|
||||||
)
|
)
|
||||||
from wled_controller.core.calibration import (
|
from wled_controller.core.calibration import (
|
||||||
calibration_from_dict,
|
calibration_from_dict,
|
||||||
@@ -143,6 +145,45 @@ async def list_devices(
|
|||||||
return DeviceListResponse(devices=responses, count=len(responses))
|
return DeviceListResponse(devices=responses, count=len(responses))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/devices/discover", response_model=DiscoverDevicesResponse, tags=["Devices"])
|
||||||
|
async def discover_devices(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: DeviceStore = Depends(get_device_store),
|
||||||
|
timeout: float = 3.0,
|
||||||
|
):
|
||||||
|
"""Scan the local network for WLED devices via mDNS."""
|
||||||
|
import time
|
||||||
|
from wled_controller.core.discovery import discover_wled_devices
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
discovered = await discover_wled_devices(timeout=min(timeout, 10.0))
|
||||||
|
elapsed_ms = (time.time() - start) * 1000
|
||||||
|
|
||||||
|
existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for d in discovered:
|
||||||
|
already_added = d.url.rstrip("/").lower() in existing_urls
|
||||||
|
results.append(
|
||||||
|
DiscoveredDeviceResponse(
|
||||||
|
name=d.name,
|
||||||
|
url=d.url,
|
||||||
|
device_type=d.device_type,
|
||||||
|
ip=d.ip,
|
||||||
|
mac=d.mac,
|
||||||
|
led_count=d.led_count,
|
||||||
|
version=d.version,
|
||||||
|
already_added=already_added,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return DiscoverDevicesResponse(
|
||||||
|
devices=results,
|
||||||
|
count=len(results),
|
||||||
|
scan_duration_ms=round(elapsed_ms, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"])
|
@router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"])
|
||||||
async def get_device(
|
async def get_device(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
|
|||||||
@@ -113,3 +113,24 @@ class DeviceStateResponse(BaseModel):
|
|||||||
device_error: Optional[str] = Field(None, description="Last health check error")
|
device_error: Optional[str] = Field(None, description="Last health check error")
|
||||||
test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
|
test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
|
||||||
test_mode_edges: List[str] = Field(default_factory=list, description="Currently lit edges in test mode")
|
test_mode_edges: List[str] = Field(default_factory=list, description="Currently lit edges in test mode")
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoveredDeviceResponse(BaseModel):
|
||||||
|
"""A single device found via network discovery."""
|
||||||
|
|
||||||
|
name: str = Field(description="Device name (from mDNS or firmware)")
|
||||||
|
url: str = Field(description="Device URL")
|
||||||
|
device_type: str = Field(default="wled", description="Device type")
|
||||||
|
ip: str = Field(description="IP address")
|
||||||
|
mac: str = Field(default="", description="MAC address")
|
||||||
|
led_count: Optional[int] = Field(None, description="LED count (if reachable)")
|
||||||
|
version: Optional[str] = Field(None, description="Firmware version")
|
||||||
|
already_added: bool = Field(default=False, description="Whether this device is already in the system")
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoverDevicesResponse(BaseModel):
|
||||||
|
"""Response from device discovery scan."""
|
||||||
|
|
||||||
|
devices: List[DiscoveredDeviceResponse] = Field(description="Discovered devices")
|
||||||
|
count: int = Field(description="Total devices found")
|
||||||
|
scan_duration_ms: float = Field(description="How long the scan took in milliseconds")
|
||||||
|
|||||||
113
server/src/wled_controller/core/discovery.py
Normal file
113
server/src/wled_controller/core/discovery.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""Network discovery for LED devices (mDNS/DNS-SD)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from zeroconf import ServiceStateChange, Zeroconf
|
||||||
|
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
|
||||||
|
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
WLED_MDNS_TYPE = "_wled._tcp.local."
|
||||||
|
DEFAULT_SCAN_TIMEOUT = 3.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiscoveredDevice:
|
||||||
|
"""A device found via network discovery."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
device_type: str
|
||||||
|
ip: str
|
||||||
|
mac: str
|
||||||
|
led_count: Optional[int]
|
||||||
|
version: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
async def _enrich_wled_device(
|
||||||
|
url: str, fallback_name: str
|
||||||
|
) -> tuple[str, Optional[str], Optional[int], str]:
|
||||||
|
"""Probe a WLED device's /json/info to get name, version, LED count, MAC."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=2) as client:
|
||||||
|
resp = await client.get(f"{url}/json/info")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return (
|
||||||
|
data.get("name", fallback_name),
|
||||||
|
data.get("ver"),
|
||||||
|
data.get("leds", {}).get("count"),
|
||||||
|
data.get("mac", ""),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not fetch WLED info from {url}: {e}")
|
||||||
|
return fallback_name, None, None, ""
|
||||||
|
|
||||||
|
|
||||||
|
async def discover_wled_devices(
|
||||||
|
timeout: float = DEFAULT_SCAN_TIMEOUT,
|
||||||
|
) -> List[DiscoveredDevice]:
|
||||||
|
"""Scan the local network for WLED devices via mDNS."""
|
||||||
|
discovered: dict[str, AsyncServiceInfo] = {}
|
||||||
|
|
||||||
|
def on_state_change(**kwargs):
|
||||||
|
service_type = kwargs.get("service_type", "")
|
||||||
|
name = kwargs.get("name", "")
|
||||||
|
state_change = kwargs.get("state_change")
|
||||||
|
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
|
||||||
|
discovered[name] = AsyncServiceInfo(service_type, name)
|
||||||
|
|
||||||
|
aiozc = AsyncZeroconf()
|
||||||
|
browser = AsyncServiceBrowser(
|
||||||
|
aiozc.zeroconf, WLED_MDNS_TYPE, handlers=[on_state_change]
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(timeout)
|
||||||
|
|
||||||
|
# Resolve all discovered services
|
||||||
|
for info in discovered.values():
|
||||||
|
await info.async_request(aiozc.zeroconf, timeout=2000)
|
||||||
|
|
||||||
|
await browser.async_cancel()
|
||||||
|
|
||||||
|
# Build raw list with IPs, then enrich in parallel
|
||||||
|
raw: list[tuple[str, str, str]] = [] # (service_name, ip, url)
|
||||||
|
for name, info in discovered.items():
|
||||||
|
addrs = info.parsed_addresses()
|
||||||
|
if not addrs:
|
||||||
|
continue
|
||||||
|
ip = addrs[0]
|
||||||
|
port = info.port or 80
|
||||||
|
url = f"http://{ip}" if port == 80 else f"http://{ip}:{port}"
|
||||||
|
service_name = name.replace(f".{WLED_MDNS_TYPE}", "")
|
||||||
|
raw.append((service_name, ip, url))
|
||||||
|
|
||||||
|
# Enrich all devices in parallel
|
||||||
|
enrichment = await asyncio.gather(
|
||||||
|
*[_enrich_wled_device(url, sname) for sname, _, url in raw]
|
||||||
|
)
|
||||||
|
|
||||||
|
results: List[DiscoveredDevice] = []
|
||||||
|
for (service_name, ip, url), (wled_name, version, led_count, mac) in zip(
|
||||||
|
raw, enrichment
|
||||||
|
):
|
||||||
|
results.append(
|
||||||
|
DiscoveredDevice(
|
||||||
|
name=wled_name,
|
||||||
|
url=url,
|
||||||
|
device_type="wled",
|
||||||
|
ip=ip,
|
||||||
|
mac=mac,
|
||||||
|
led_count=led_count,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await aiozc.async_close()
|
||||||
|
logger.info(f"mDNS scan found {len(results)} WLED device(s)")
|
||||||
|
return results
|
||||||
@@ -879,6 +879,16 @@ function showAddDevice() {
|
|||||||
const error = document.getElementById('add-device-error');
|
const error = document.getElementById('add-device-error');
|
||||||
form.reset();
|
form.reset();
|
||||||
error.style.display = 'none';
|
error.style.display = 'none';
|
||||||
|
// Reset discovery section
|
||||||
|
const section = document.getElementById('discovery-section');
|
||||||
|
if (section) {
|
||||||
|
section.style.display = 'none';
|
||||||
|
document.getElementById('discovery-list').innerHTML = '';
|
||||||
|
document.getElementById('discovery-empty').style.display = 'none';
|
||||||
|
document.getElementById('discovery-loading').style.display = 'none';
|
||||||
|
}
|
||||||
|
const scanBtn = document.getElementById('scan-network-btn');
|
||||||
|
if (scanBtn) scanBtn.disabled = false;
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
lockBody();
|
lockBody();
|
||||||
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
||||||
@@ -890,6 +900,79 @@ function closeAddDeviceModal() {
|
|||||||
unlockBody();
|
unlockBody();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function scanForDevices() {
|
||||||
|
const loading = document.getElementById('discovery-loading');
|
||||||
|
const list = document.getElementById('discovery-list');
|
||||||
|
const empty = document.getElementById('discovery-empty');
|
||||||
|
const section = document.getElementById('discovery-section');
|
||||||
|
const scanBtn = document.getElementById('scan-network-btn');
|
||||||
|
|
||||||
|
section.style.display = 'block';
|
||||||
|
loading.style.display = 'flex';
|
||||||
|
list.innerHTML = '';
|
||||||
|
empty.style.display = 'none';
|
||||||
|
if (scanBtn) scanBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/devices/discover?timeout=3`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) { handle401Error(); return; }
|
||||||
|
|
||||||
|
loading.style.display = 'none';
|
||||||
|
if (scanBtn) scanBtn.disabled = false;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
empty.style.display = 'block';
|
||||||
|
empty.querySelector('small').textContent = t('device.scan.error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.devices.length === 0) {
|
||||||
|
empty.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.devices.forEach(device => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : '');
|
||||||
|
const meta = [device.ip];
|
||||||
|
if (device.led_count) meta.push(device.led_count + ' LEDs');
|
||||||
|
if (device.version) meta.push('v' + device.version);
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="discovery-item-info">
|
||||||
|
<strong>${escapeHtml(device.name)}</strong>
|
||||||
|
<small>${escapeHtml(meta.join(' \u00b7 '))}</small>
|
||||||
|
</div>
|
||||||
|
${device.already_added
|
||||||
|
? '<span class="discovery-badge">' + t('device.scan.already_added') + '</span>'
|
||||||
|
: ''}
|
||||||
|
`;
|
||||||
|
if (!device.already_added) {
|
||||||
|
card.onclick = () => selectDiscoveredDevice(device);
|
||||||
|
}
|
||||||
|
list.appendChild(card);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
if (scanBtn) scanBtn.disabled = false;
|
||||||
|
empty.style.display = 'block';
|
||||||
|
empty.querySelector('small').textContent = t('device.scan.error');
|
||||||
|
console.error('Device scan failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDiscoveredDevice(device) {
|
||||||
|
document.getElementById('device-name').value = device.name;
|
||||||
|
document.getElementById('device-url').value = device.url;
|
||||||
|
const typeSelect = document.getElementById('device-type');
|
||||||
|
if (typeSelect) typeSelect.value = device.device_type;
|
||||||
|
showToast(t('device.scan.selected'), 'info');
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAddDevice(event) {
|
async function handleAddDevice(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
|||||||
@@ -570,9 +570,22 @@
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 data-i18n="devices.add">Add New Device</h2>
|
<h2 data-i18n="devices.add">Add New Device</h2>
|
||||||
<button class="modal-close-btn" onclick="closeAddDeviceModal()" title="Close">✕</button>
|
<div class="modal-header-actions">
|
||||||
|
<button type="button" class="modal-header-btn" id="scan-network-btn" onclick="scanForDevices()" data-i18n-title="device.scan" title="Auto Discovery">🔍</button>
|
||||||
|
<button class="modal-close-btn" onclick="closeAddDeviceModal()" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<div id="discovery-section" class="discovery-section" style="display: none;">
|
||||||
|
<div id="discovery-loading" class="discovery-loading" style="display: none;">
|
||||||
|
<span class="discovery-spinner"></span>
|
||||||
|
</div>
|
||||||
|
<div id="discovery-list" class="discovery-list"></div>
|
||||||
|
<div id="discovery-empty" style="display: none;">
|
||||||
|
<small data-i18n="device.scan.empty">No WLED devices found on the network</small>
|
||||||
|
</div>
|
||||||
|
<hr class="modal-divider">
|
||||||
|
</div>
|
||||||
<form id="add-device-form">
|
<form id="add-device-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
|
|||||||
@@ -101,6 +101,11 @@
|
|||||||
"devices.wled_webui_link": "WLED Web UI",
|
"devices.wled_webui_link": "WLED Web UI",
|
||||||
"devices.wled_note_webui": "(open your device's IP in a browser).",
|
"devices.wled_note_webui": "(open your device's IP in a browser).",
|
||||||
"devices.wled_note2": "This controller sends pixel color data and controls brightness per device.",
|
"devices.wled_note2": "This controller sends pixel color data and controls brightness per device.",
|
||||||
|
"device.scan": "Auto Discovery",
|
||||||
|
"device.scan.empty": "No WLED devices found on the network",
|
||||||
|
"device.scan.error": "Network scan failed",
|
||||||
|
"device.scan.already_added": "Already added",
|
||||||
|
"device.scan.selected": "Device selected",
|
||||||
"device.type": "Device Type:",
|
"device.type": "Device Type:",
|
||||||
"device.type.hint": "Select the type of LED controller",
|
"device.type.hint": "Select the type of LED controller",
|
||||||
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
||||||
|
|||||||
@@ -101,6 +101,11 @@
|
|||||||
"devices.wled_webui_link": "веб-интерфейс WLED",
|
"devices.wled_webui_link": "веб-интерфейс WLED",
|
||||||
"devices.wled_note_webui": "(откройте IP устройства в браузере).",
|
"devices.wled_note_webui": "(откройте IP устройства в браузере).",
|
||||||
"devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.",
|
"devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.",
|
||||||
|
"device.scan": "Автопоиск",
|
||||||
|
"device.scan.empty": "WLED устройства не найдены в сети",
|
||||||
|
"device.scan.error": "Ошибка сканирования сети",
|
||||||
|
"device.scan.already_added": "Уже добавлено",
|
||||||
|
"device.scan.selected": "Устройство выбрано",
|
||||||
"device.type": "Тип устройства:",
|
"device.type": "Тип устройства:",
|
||||||
"device.type.hint": "Выберите тип LED контроллера",
|
"device.type.hint": "Выберите тип LED контроллера",
|
||||||
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
||||||
|
|||||||
@@ -352,6 +352,73 @@ section {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Device discovery */
|
||||||
|
.discovery-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.btn-block {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.discovery-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.discovery-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.discovery-item:not(.discovery-item--added):hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.discovery-item--added {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.discovery-item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.discovery-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--border-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.modal-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.discovery-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.discovery-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-top-color: var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.channel-indicator {
|
.channel-indicator {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -949,6 +1016,34 @@ input:-webkit-autofill:focus {
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.modal-header-btn:hover {
|
||||||
|
background: rgba(128, 128, 128, 0.15);
|
||||||
|
}
|
||||||
|
.modal-header-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-close-btn {
|
.modal-close-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user