Compare commits
2 Commits
a7a488403f
...
b3fbe31a2d
| Author | SHA1 | Date | |
|---|---|---|---|
| b3fbe31a2d | |||
| 955bfdfe07 |
+2
-2
@@ -336,9 +336,9 @@ PORT=3001 npm start
|
|||||||
|
|
||||||
### Priorité moyenne
|
### Priorité moyenne
|
||||||
- [ ] **Graphiques monitoring** : Chart.js pour latence/bande passante
|
- [ ] **Graphiques monitoring** : Chart.js pour latence/bande passante
|
||||||
- [ ] **Export logs** : bouton télécharger CSV/JSON
|
- [x] **Export logs** : bouton télécharger JSON (filtre niveau appliqué)
|
||||||
- [ ] **Matrice routing** : interface graphique drag & drop
|
- [ ] **Matrice routing** : interface graphique drag & drop
|
||||||
- [ ] **Notifications desktop** : via Electron Notification API
|
- [x] **Export & import config** : bouton télécharger YAML et charger config (backup auto .bak)
|
||||||
|
|
||||||
### Priorité basse
|
### Priorité basse
|
||||||
- [ ] **Thème toggle** : dark/light mode
|
- [ ] **Thème toggle** : dark/light mode
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
const { app, BrowserWindow, ipcMain, Menu, Tray, dialog } = require('electron');
|
const { app, BrowserWindow, ipcMain, Menu, Tray, dialog } = require('electron');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
@@ -365,6 +366,52 @@ app.whenReady().then(async () => {
|
|||||||
return setupHelper.getNetworkIP();
|
return setupHelper.getNetworkIP();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('config:export', async () => {
|
||||||
|
const configPath = path.join(__dirname, '..', 'server', 'config', 'config.yaml');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(configPath, 'utf8');
|
||||||
|
|
||||||
|
const { filePath } = await dialog.showSaveDialog(mainWindow, {
|
||||||
|
title: 'Exporter la configuration',
|
||||||
|
defaultPath: 'config.yaml',
|
||||||
|
filters: [{ name: 'YAML', extensions: ['yaml', 'yml'] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!filePath) return { success: false, cancelled: true };
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, content, 'utf8');
|
||||||
|
return { success: true, filePath };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('config:import', async () => {
|
||||||
|
const { filePaths } = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
title: 'Importer une configuration',
|
||||||
|
filters: [{ name: 'YAML', extensions: ['yaml', 'yml'] }],
|
||||||
|
properties: ['openFile']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!filePaths || filePaths.length === 0) return { success: false, cancelled: true };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePaths[0], 'utf8');
|
||||||
|
const configPath = path.join(__dirname, '..', 'server', 'config', 'config.yaml');
|
||||||
|
|
||||||
|
// Backup de l'ancienne config avant remplacement
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
fs.copyFileSync(configPath, configPath + '.bak');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(configPath, content, 'utf8');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Créer fenêtre
|
// Créer fenêtre
|
||||||
createWindow();
|
createWindow();
|
||||||
createTray();
|
createTray();
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// IP réseau locale (même détection que pour les certificats mkcert)
|
// IP réseau locale (même détection que pour les certificats mkcert)
|
||||||
getNetworkIP: () => ipcRenderer.invoke('network:ip'),
|
getNetworkIP: () => ipcRenderer.invoke('network:ip'),
|
||||||
|
|
||||||
|
// Export/import configuration YAML via dialog système
|
||||||
|
config: {
|
||||||
|
export: () => ipcRenderer.invoke('config:export'),
|
||||||
|
import: () => ipcRenderer.invoke('config:import')
|
||||||
|
},
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
version: process.env.npm_package_version || '0.3.0'
|
version: process.env.npm_package_version || '0.3.0'
|
||||||
|
|||||||
+258
-10
@@ -312,18 +312,97 @@ async function fetchGroups() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = data.groups.map(group => `
|
container.innerHTML = data.groups.map(group => {
|
||||||
|
const id = slugify(group.name);
|
||||||
|
return `
|
||||||
<div class="group-item">
|
<div class="group-item">
|
||||||
<div class="group-info">
|
<div class="group-info">
|
||||||
<h4>${group.name}</h4>
|
<h4>${escapeHtml(group.name)}</h4>
|
||||||
<p>Bitrate: ${group.audioBitrate || 96} kbps • ID: ${group.id}</p>
|
<p>Bitrate: ${group.audioBitrate || 96} kbps • ID: ${escapeHtml(id)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="group-actions">
|
<div class="group-actions">
|
||||||
<button class="btn btn-small btn-secondary">Modifier</button>
|
<button class="btn btn-small btn-secondary"
|
||||||
<button class="btn btn-small btn-secondary">Supprimer</button>
|
data-action="edit"
|
||||||
|
data-id="${escapeHtml(id)}"
|
||||||
|
data-name="${escapeHtml(group.name)}"
|
||||||
|
data-bitrate="${group.audioBitrate || 96}">Modifier</button>
|
||||||
|
<button class="btn btn-small btn-danger"
|
||||||
|
data-action="delete"
|
||||||
|
data-id="${escapeHtml(id)}"
|
||||||
|
data-name="${escapeHtml(group.name)}">Supprimer</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editGroup(id, currentName, currentBitrate) {
|
||||||
|
const result = await showModal({
|
||||||
|
title: 'Modifier le groupe',
|
||||||
|
fields: [
|
||||||
|
{ name: 'name', label: 'Nom', default: currentName },
|
||||||
|
{ name: 'bitrate', label: 'Bitrate (kbps)', type: 'number', default: currentBitrate, min: 32, max: 320, step: 1 }
|
||||||
|
],
|
||||||
|
confirmLabel: 'Modifier'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const newName = result.name.trim();
|
||||||
|
const newBitrate = parseInt(result.bitrate);
|
||||||
|
|
||||||
|
if (!newName) { showNotification('Nom requis', 'error'); return; }
|
||||||
|
if (isNaN(newBitrate) || newBitrate < 32 || newBitrate > 320) {
|
||||||
|
showNotification('Bitrate invalide (32-320 kbps)', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/admin/groups/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: newName, audioBitrate: newBitrate })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showNotification('Groupe modifié', 'success');
|
||||||
|
await fetchGroups();
|
||||||
|
} else {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
showNotification('Erreur: ' + (err.error || 'Modification échouée'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur edit group:', error);
|
||||||
|
showNotification('Erreur réseau', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGroup(id, name) {
|
||||||
|
const confirmed = await showModal({
|
||||||
|
title: 'Supprimer le groupe',
|
||||||
|
message: `Supprimer le groupe "${name}" ? Cette action est irréversible.`,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
confirmClass: 'btn-danger'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/admin/groups/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showNotification('Groupe supprimé', 'success');
|
||||||
|
await fetchGroups();
|
||||||
|
} else {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
showNotification('Erreur: ' + (err.error || 'Suppression échouée'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur delete group:', error);
|
||||||
|
showNotification('Erreur réseau', 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
@@ -559,21 +638,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const btnAddGroup = document.getElementById('btn-add-group');
|
const btnAddGroup = document.getElementById('btn-add-group');
|
||||||
if (btnAddGroup) {
|
if (btnAddGroup) {
|
||||||
btnAddGroup.addEventListener('click', async () => {
|
btnAddGroup.addEventListener('click', async () => {
|
||||||
const name = prompt('Nom du groupe:');
|
const result = await showModal({
|
||||||
if (!name) return;
|
title: 'Nouveau groupe',
|
||||||
|
fields: [
|
||||||
|
{ name: 'name', label: 'Nom du groupe' },
|
||||||
|
{ name: 'bitrate', label: 'Bitrate (kbps)', type: 'number', default: 96, min: 32, max: 320, step: 1 }
|
||||||
|
],
|
||||||
|
confirmLabel: 'Créer'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || !result.name.trim()) return;
|
||||||
|
|
||||||
|
const name = result.name.trim();
|
||||||
|
const audioBitrate = parseInt(result.bitrate) || 96;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/admin/groups`, {
|
const response = await fetch(`${API_BASE}/admin/groups`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, audioBitrate: 96 })
|
body: JSON.stringify({ name, audioBitrate })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showNotification('Groupe créé', 'success');
|
showNotification('Groupe créé', 'success');
|
||||||
await fetchGroups();
|
await fetchGroups();
|
||||||
} else {
|
} else {
|
||||||
showNotification('Erreur création groupe', 'error');
|
const err = await response.json().catch(() => ({}));
|
||||||
|
showNotification('Erreur: ' + (err.error || 'Création échouée'), 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur add group:', error);
|
console.error('Erreur add group:', error);
|
||||||
@@ -581,10 +672,167 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exporter config.yaml
|
||||||
|
const btnExportConfig = document.getElementById('btn-export-config');
|
||||||
|
if (btnExportConfig) {
|
||||||
|
btnExportConfig.addEventListener('click', async () => {
|
||||||
|
const result = await window.electronAPI.config.export();
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('Configuration exportée', 'success');
|
||||||
|
} else if (!result.cancelled) {
|
||||||
|
showNotification('Erreur export: ' + (result.error || 'Échec'), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Importer config.yaml
|
||||||
|
const btnImportConfig = document.getElementById('btn-import-config');
|
||||||
|
if (btnImportConfig) {
|
||||||
|
btnImportConfig.addEventListener('click', async () => {
|
||||||
|
const result = await window.electronAPI.config.import();
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('Configuration importée - Redémarrez le serveur pour appliquer', 'warning');
|
||||||
|
if (serverRunning) {
|
||||||
|
await fetchConfig();
|
||||||
|
}
|
||||||
|
} else if (!result.cancelled) {
|
||||||
|
showNotification('Erreur import: ' + (result.error || 'Échec'), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exporter les logs
|
||||||
|
const btnExportLogs = document.getElementById('btn-export-logs');
|
||||||
|
if (btnExportLogs) {
|
||||||
|
btnExportLogs.addEventListener('click', () => {
|
||||||
|
const levelFilter = document.getElementById('log-level-filter').value;
|
||||||
|
const logs = levelFilter ? logsBuffer.filter(l => l.level === levelFilter) : logsBuffer;
|
||||||
|
|
||||||
|
if (logs.length === 0) {
|
||||||
|
showNotification('Aucun log à exporter', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = JSON.stringify(logs, null, 2);
|
||||||
|
const blob = new Blob([content], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `ptt-live-logs-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-')}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
showNotification(`${logs.length} logs exportés`, 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Délégation d'événements pour modifier/supprimer un groupe
|
||||||
|
const groupsList = document.getElementById('groups-list');
|
||||||
|
if (groupsList) {
|
||||||
|
groupsList.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
const name = btn.dataset.name;
|
||||||
|
|
||||||
|
if (action === 'edit') {
|
||||||
|
await editGroup(id, name, parseInt(btn.dataset.bitrate));
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
await deleteGroup(id, name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== Helpers ==========
|
// ========== Helpers ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal générique (remplace prompt/confirm, non supportés dans Electron).
|
||||||
|
* - fields[] → formulaire ; message → confirmation simple
|
||||||
|
* Retourne : objet {champ: valeur} | true (confirm) | null (annulé)
|
||||||
|
*/
|
||||||
|
function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClass = 'btn-primary', message = null }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = document.getElementById('modal-overlay');
|
||||||
|
const titleEl = document.getElementById('modal-title');
|
||||||
|
const bodyEl = document.getElementById('modal-body');
|
||||||
|
const cancelBtn = document.getElementById('modal-cancel');
|
||||||
|
const confirmBtn = document.getElementById('modal-confirm');
|
||||||
|
|
||||||
|
titleEl.textContent = title;
|
||||||
|
confirmBtn.textContent = confirmLabel;
|
||||||
|
confirmBtn.className = `btn ${confirmClass}`;
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
bodyEl.innerHTML = `<p class="modal-message">${escapeHtml(message)}</p>`;
|
||||||
|
} else {
|
||||||
|
bodyEl.innerHTML = fields.map(field => `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${escapeHtml(field.label)}</label>
|
||||||
|
<input
|
||||||
|
type="${field.type || 'text'}"
|
||||||
|
id="modal-field-${field.name}"
|
||||||
|
class="form-control"
|
||||||
|
value="${escapeHtml(String(field.default ?? ''))}"
|
||||||
|
${field.min !== undefined ? `min="${field.min}"` : ''}
|
||||||
|
${field.max !== undefined ? `max="${field.max}"` : ''}
|
||||||
|
${field.step !== undefined ? `step="${field.step}"` : ''}>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
|
||||||
|
const firstInput = bodyEl.querySelector('input');
|
||||||
|
if (firstInput) { firstInput.focus(); firstInput.select(); }
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
overlay.classList.add('hidden');
|
||||||
|
cancelBtn.removeEventListener('click', onCancel);
|
||||||
|
confirmBtn.removeEventListener('click', onConfirm);
|
||||||
|
overlay.removeEventListener('click', onOverlayClick);
|
||||||
|
document.removeEventListener('keydown', onKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() { cleanup(); resolve(null); }
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
if (message) {
|
||||||
|
cleanup(); resolve(true);
|
||||||
|
} else {
|
||||||
|
const result = {};
|
||||||
|
fields.forEach(f => {
|
||||||
|
const input = document.getElementById(`modal-field-${f.name}`);
|
||||||
|
result[f.name] = input ? input.value : '';
|
||||||
|
});
|
||||||
|
cleanup(); resolve(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOverlayClick(e) { if (e.target === overlay) onCancel(); }
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
if (e.key === 'Enter' && document.activeElement?.tagName !== 'BUTTON') onConfirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', onCancel);
|
||||||
|
confirmBtn.addEventListener('click', onConfirm);
|
||||||
|
overlay.addEventListener('click', onOverlayClick);
|
||||||
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(text) {
|
||||||
|
return text.toString().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w-]+/g, '').replace(/--+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
function formatUptime(seconds) {
|
function formatUptime(seconds) {
|
||||||
if (!seconds) return '--';
|
if (!seconds) return '--';
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,15 @@
|
|||||||
<button class="btn btn-primary" id="btn-save-device">Appliquer</button>
|
<button class="btn btn-primary" id="btn-save-device">Appliquer</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>💾 Sauvegarde de configuration</h3>
|
||||||
|
<div class="config-actions">
|
||||||
|
<button class="btn btn-secondary" id="btn-export-config">Exporter config.yaml</button>
|
||||||
|
<button class="btn btn-secondary" id="btn-import-config">Importer config.yaml</button>
|
||||||
|
</div>
|
||||||
|
<p class="config-note">L'import remplace config.yaml (backup automatique en .bak). Redémarrez le serveur pour appliquer.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>🎚️ Paramètres Audio</h3>
|
<h3>🎚️ Paramètres Audio</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -170,6 +179,7 @@
|
|||||||
<h2>Logs Serveur</h2>
|
<h2>Logs Serveur</h2>
|
||||||
<div class="logs-controls">
|
<div class="logs-controls">
|
||||||
<button class="btn btn-small" id="btn-clear-logs">Effacer</button>
|
<button class="btn btn-small" id="btn-clear-logs">Effacer</button>
|
||||||
|
<button class="btn btn-small btn-secondary" id="btn-export-logs">Exporter JSON</button>
|
||||||
<select id="log-level-filter" class="form-control form-control-small">
|
<select id="log-level-filter" class="form-control form-control-small">
|
||||||
<option value="">Tous les niveaux</option>
|
<option value="">Tous les niveaux</option>
|
||||||
<option value="error">Erreurs</option>
|
<option value="error">Erreurs</option>
|
||||||
@@ -186,6 +196,20 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal générique -->
|
||||||
|
<div id="modal-overlay" class="modal-overlay hidden">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modal-title"></h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modal-body"></div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="modal-cancel">Annuler</button>
|
||||||
|
<button class="btn btn-primary" id="modal-confirm">Confirmer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Le QR Code est généré côté Main Process (lib qrcode Node), pas de dépendance CDN -->
|
<!-- Le QR Code est généré côté Main Process (lib qrcode Node), pas de dépendance CDN -->
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -121,6 +121,15 @@ body {
|
|||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--accent-error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
/* Main Content */
|
/* Main Content */
|
||||||
.main-content {
|
.main-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -370,6 +379,70 @@ body {
|
|||||||
border-color: var(--accent-primary);
|
border-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-message {
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config actions */
|
||||||
|
.config-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-note {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Groups List */
|
/* Groups List */
|
||||||
.groups-list {
|
.groups-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ audio:
|
|||||||
sampleRate: 48000
|
sampleRate: 48000
|
||||||
channels: 2
|
channels: 2
|
||||||
frameSize: 20
|
frameSize: 20
|
||||||
defaultBitrate: 96
|
defaultBitrate: 128
|
||||||
jitterBufferMs: 40
|
jitterBufferMs: 40
|
||||||
device:
|
device:
|
||||||
inputDeviceId: Loopback Audio 4
|
inputDeviceId: Loopback Audio 4
|
||||||
|
|||||||
Reference in New Issue
Block a user