Initial commit: Stream Deck daemon with web UI
- Web-based key editor (Alpine.js SPA) - Actions: shell commands, scripts, built-in media/volume/brightness, page switching - Keyboard shortcut action (wtype/xdotool) - On-device display with periodic command output - Auto page switching (Hyprland, Sway, Niri, GNOME, KDE, X11) - Screensaver with idle detection - Config hot-reload - Multi-device support
This commit is contained in:
+557
@@ -0,0 +1,557 @@
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('deckApp', () => ({
|
||||
// ── State ──
|
||||
config: null,
|
||||
pages: [],
|
||||
activePage: '',
|
||||
view: 'grid',
|
||||
showAdvanced: false,
|
||||
showDisplay: false,
|
||||
toast: null,
|
||||
displayOutputs: {},
|
||||
isCapturingKey: false,
|
||||
|
||||
// ── Edit state ──
|
||||
editing: null,
|
||||
editForm: {},
|
||||
|
||||
// ── Sub-views ──
|
||||
subView: null, // 'backups', 'settings', 'switcher', null
|
||||
|
||||
// ── Settings ──
|
||||
settingsForm: {},
|
||||
autoSwitchRules: [],
|
||||
|
||||
// ── Init ──
|
||||
async init() {
|
||||
await this.loadConfig()
|
||||
setInterval(() => this.pollDisplayOutputs(), 3000)
|
||||
},
|
||||
|
||||
async pollDisplayOutputs() {
|
||||
if (this.view !== 'grid') return
|
||||
try {
|
||||
const res = await fetch(`/api/display-outputs`)
|
||||
this.displayOutputs = await res.json()
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async loadConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/config')
|
||||
this.config = await res.json()
|
||||
this.pages = this.config.pages
|
||||
if (this.pages.length > 0) {
|
||||
this.activePage = this.config.default_page || this.pages[0].name
|
||||
}
|
||||
this.syncSettings()
|
||||
} catch (e) {
|
||||
this.showToast('Failed to load config', 'error')
|
||||
}
|
||||
},
|
||||
|
||||
syncSettings() {
|
||||
this.settingsForm = {
|
||||
brightness: this.config.devices?.[0]?.brightness || 75,
|
||||
screensaver_enabled: this.config.screensaver?.enabled || false,
|
||||
screensaver_idle: this.config.screensaver?.idle_seconds || 30,
|
||||
screensaver_brightness: this.config.screensaver?.brightness || 10,
|
||||
}
|
||||
this.autoSwitchRules = [...(this.config.auto_switch || [])]
|
||||
},
|
||||
|
||||
// ── Page helpers ──
|
||||
get currentPage() {
|
||||
return this.pages.find(p => p.name === this.activePage)
|
||||
},
|
||||
|
||||
get pageNames() {
|
||||
return this.pages.map(p => p.name)
|
||||
},
|
||||
|
||||
// ── Grid ──
|
||||
get gridKeys() {
|
||||
const page = this.currentPage
|
||||
if (!page) return []
|
||||
const keys = []
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const kc = page.keys.find(k => k.index === i)
|
||||
keys.push({
|
||||
index: i,
|
||||
...kc,
|
||||
configured: !!kc,
|
||||
hasDisplay: !!(kc?.display),
|
||||
previewUrl: this.keyPreviewUrl(kc ? kc : {}),
|
||||
})
|
||||
}
|
||||
return keys
|
||||
},
|
||||
|
||||
keyPreviewUrl(kc) {
|
||||
const k = kc || {}
|
||||
let url = '/api/render?key_size=72'
|
||||
if (k.icon) url += `&icon=${encodeURIComponent(k.icon)}`
|
||||
const label = this.displayOutputs[k.index] || k.label
|
||||
if (label) url += `&label=${encodeURIComponent(label)}`
|
||||
if (k.icon_scale != null) url += `&icon_scale=${k.icon_scale}`
|
||||
if (k.font_size != null) url += `&font_size=${k.font_size}`
|
||||
if (k.background) url += `&background=${encodeURIComponent(k.background)}`
|
||||
return url
|
||||
},
|
||||
|
||||
isFAIcon(icon) {
|
||||
if (!icon) return false
|
||||
return icon.startsWith('fa:') || icon.startsWith('far:') || icon.startsWith('fab:')
|
||||
},
|
||||
|
||||
faClass(icon) {
|
||||
if (!icon) return ''
|
||||
if (icon.startsWith('fa:')) return 'fa-solid fa-' + icon.slice(3)
|
||||
if (icon.startsWith('far:')) return 'fa-regular fa-' + icon.slice(4)
|
||||
if (icon.startsWith('fab:')) return 'fa-brands fa-' + icon.slice(4)
|
||||
return ''
|
||||
},
|
||||
|
||||
get keyChips() {
|
||||
if (!this.editForm.keys) return []
|
||||
const map = { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', super: 'Super', meta: 'Super' }
|
||||
return this.editForm.keys.split('+').map(k => map[k] || k.charAt(0).toUpperCase() + k.slice(1))
|
||||
},
|
||||
|
||||
toggleMod(mod) {
|
||||
if (mod === 'ctrl') this.editForm.modCtrl = !this.editForm.modCtrl
|
||||
else if (mod === 'alt') this.editForm.modAlt = !this.editForm.modAlt
|
||||
else if (mod === 'shift') this.editForm.modShift = !this.editForm.modShift
|
||||
else if (mod === 'super') this.editForm.modSuper = !this.editForm.modSuper
|
||||
this.rebuildKeys()
|
||||
},
|
||||
|
||||
rebuildKeys() {
|
||||
const mods = []
|
||||
if (this.editForm.modCtrl) mods.push('ctrl')
|
||||
if (this.editForm.modAlt) mods.push('alt')
|
||||
if (this.editForm.modShift) mods.push('shift')
|
||||
if (this.editForm.modSuper) mods.push('super')
|
||||
if (this.editForm.mainKey) mods.push(this.editForm.mainKey)
|
||||
this.editForm.keys = mods.join('+')
|
||||
},
|
||||
|
||||
startKeyCapture() {
|
||||
this.isCapturingKey = true
|
||||
this.$nextTick(() => this.$refs.keyCapture?.focus())
|
||||
},
|
||||
|
||||
onCaptureKey(e) {
|
||||
if (!this.isCapturingKey) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const map = {
|
||||
'Control': '', 'Alt': '', 'Shift': '', 'Meta': '',
|
||||
' ': 'space', 'Enter': 'return', 'Tab': 'tab',
|
||||
'Escape': 'escape', 'Delete': 'delete', 'Backspace': 'backspace',
|
||||
'ArrowUp': 'up', 'ArrowDown': 'down', 'ArrowLeft': 'left', 'ArrowRight': 'right',
|
||||
}
|
||||
let key = map[e.key]
|
||||
if (key === undefined) {
|
||||
key = e.key.length === 1 ? e.key.toLowerCase() : e.key
|
||||
}
|
||||
if (!key) return
|
||||
this.editForm.mainKey = key.toLowerCase()
|
||||
this.isCapturingKey = false
|
||||
this.rebuildKeys()
|
||||
},
|
||||
|
||||
displayKey(key) {
|
||||
const map = { return: '\u21B5', escape: 'Esc', tab: 'Tab', space: '\u2423',
|
||||
delete: 'Del', backspace: '\u232B', up: '\u2191', down: '\u2193', left: '\u2190', right: '\u2192' }
|
||||
return map[key] || key.toUpperCase()
|
||||
},
|
||||
|
||||
// ── Edit Key ──
|
||||
editKey(idx) {
|
||||
this.isCapturingKey = false
|
||||
const page = this.currentPage
|
||||
if (!page) return
|
||||
const kc = page.keys.find(k => k.index === idx)
|
||||
this.editing = idx
|
||||
this.showAdvanced = false
|
||||
this.showDisplay = !!(kc?.display)
|
||||
|
||||
const action = kc?.action || {}
|
||||
|
||||
const parts = (action.keys || '').toLowerCase().split('+')
|
||||
const mainKey = parts.length > 0 ? parts.pop() : ''
|
||||
|
||||
this.editForm = {
|
||||
icon: kc?.icon || '',
|
||||
label: kc?.label || '',
|
||||
icon_scale: kc?.icon_scale ?? 0.55,
|
||||
font_size: kc?.font_size ?? 16,
|
||||
bg_color: kc?.background || '',
|
||||
action_type: action.type || 'command',
|
||||
command: action.command || '',
|
||||
builtin: action.builtin || '',
|
||||
script: action.script || '',
|
||||
page: action.page || '',
|
||||
keys: action.keys || '',
|
||||
modCtrl: parts.includes('ctrl'),
|
||||
modAlt: parts.includes('alt'),
|
||||
modShift: parts.includes('shift'),
|
||||
modSuper: parts.includes('super'),
|
||||
mainKey: mainKey,
|
||||
background: action.background ?? true,
|
||||
display_mode: kc?.display ? (kc.display.command ? 'command' : 'script') : 'none',
|
||||
display_command: kc?.display?.command || '',
|
||||
display_script: kc?.display?.script || '',
|
||||
display_interval: kc?.display?.interval || '30s',
|
||||
display_max_len: kc?.display?.max_len || 128,
|
||||
display_timeout: kc?.display?.timeout || '',
|
||||
}
|
||||
},
|
||||
|
||||
get editPreviewUrl() {
|
||||
const f = this.editForm
|
||||
let url = '/api/render?key_size=96'
|
||||
if (f.icon) url += `&icon=${encodeURIComponent(f.icon)}`
|
||||
if (f.label) url += `&label=${encodeURIComponent(f.label)}`
|
||||
if (f.icon_scale != null) url += `&icon_scale=${f.icon_scale}`
|
||||
if (f.font_size != null) url += `&font_size=${f.font_size}`
|
||||
if (f.bg_color) url += `&background=${encodeURIComponent(f.bg_color)}`
|
||||
return url
|
||||
},
|
||||
|
||||
get editPreviewFaClass() {
|
||||
return this.faClass(this.editForm.icon)
|
||||
},
|
||||
|
||||
async saveKey() {
|
||||
const page = this.currentPage
|
||||
if (!page) return
|
||||
|
||||
const dm = this.editForm.display_mode
|
||||
const hasDisplay = dm !== 'none'
|
||||
const actionType = this.editForm.action_type
|
||||
|
||||
if (dm === 'command' && !this.editForm.display_command) {
|
||||
this.showToast('Display command is required', 'error')
|
||||
return
|
||||
}
|
||||
if (dm === 'script' && !this.editForm.display_script) {
|
||||
this.showToast('Display script path is required', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType && actionType !== 'none') {
|
||||
if (actionType === 'command' && !this.editForm.command) {
|
||||
if (!hasDisplay) { this.showToast('Command is required', 'error'); return }
|
||||
}
|
||||
if (actionType === 'script' && !this.editForm.script) {
|
||||
if (!hasDisplay) { this.showToast('Script path is required', 'error'); return }
|
||||
}
|
||||
if (actionType === 'page' && !this.editForm.page) {
|
||||
this.showToast('Target page is required', 'error')
|
||||
return
|
||||
}
|
||||
if (actionType === 'keyboard' && !this.editForm.keys) {
|
||||
if (!hasDisplay) { this.showToast('Key combination is required', 'error'); return }
|
||||
}
|
||||
}
|
||||
|
||||
const kc = {
|
||||
index: this.editing,
|
||||
icon: this.editForm.icon,
|
||||
label: this.editForm.label,
|
||||
icon_scale: this.editForm.icon_scale,
|
||||
font_size: this.editForm.font_size,
|
||||
background: this.editForm.bg_color || '',
|
||||
}
|
||||
|
||||
if (actionType && actionType !== 'none') {
|
||||
if (actionType === 'command' && this.editForm.command) {
|
||||
kc.action = { type: 'command', command: this.editForm.command, background: this.editForm.background }
|
||||
} else if (actionType === 'builtin') {
|
||||
kc.action = { type: 'builtin', builtin: this.editForm.builtin }
|
||||
} else if (actionType === 'script' && this.editForm.script) {
|
||||
kc.action = { type: 'script', script: this.editForm.script }
|
||||
} else if (actionType === 'page' && this.editForm.page) {
|
||||
kc.action = { type: 'page', page: this.editForm.page }
|
||||
} else if (actionType === 'keyboard' && this.editForm.keys) {
|
||||
kc.action = { type: 'keyboard', keys: this.editForm.keys }
|
||||
} else if (!hasDisplay) {
|
||||
kc.action = null
|
||||
} else {
|
||||
kc.action = null
|
||||
}
|
||||
} else {
|
||||
kc.action = null
|
||||
}
|
||||
|
||||
if (hasDisplay) {
|
||||
kc.display = {
|
||||
command: dm === 'command' ? this.editForm.display_command : '',
|
||||
script: dm === 'script' ? this.editForm.display_script : '',
|
||||
interval: this.editForm.display_interval || '30s',
|
||||
max_len: this.editForm.display_max_len || 0,
|
||||
}
|
||||
if (this.editForm.display_timeout) {
|
||||
kc.display.timeout = this.editForm.display_timeout
|
||||
}
|
||||
} else {
|
||||
kc.display = null
|
||||
}
|
||||
|
||||
const existingIdx = page.keys.findIndex(k => k.index === this.editing)
|
||||
if (existingIdx >= 0) {
|
||||
page.keys[existingIdx] = kc
|
||||
} else {
|
||||
page.keys.push(kc)
|
||||
}
|
||||
|
||||
await this.saveConfig()
|
||||
this.editing = null
|
||||
},
|
||||
|
||||
async deleteKey() {
|
||||
const page = this.currentPage
|
||||
if (!page) return
|
||||
page.keys = page.keys.filter(k => k.index !== this.editing)
|
||||
await this.saveConfig()
|
||||
this.editing = null
|
||||
},
|
||||
|
||||
// ── Image picker ──
|
||||
pickImage() {
|
||||
this.$refs.fileInput.click()
|
||||
},
|
||||
|
||||
async onImagePicked(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||
if (!res.ok) { this.showToast('Upload failed', 'error'); return }
|
||||
const data = await res.json()
|
||||
this.editForm.icon = data.path
|
||||
} catch (err) {
|
||||
this.showToast('Upload failed', 'error')
|
||||
}
|
||||
e.target.value = ''
|
||||
},
|
||||
|
||||
// ── Page management ──
|
||||
switchPage(name) {
|
||||
this.activePage = name
|
||||
this.displayOutputs = {}
|
||||
},
|
||||
|
||||
async addPage() {
|
||||
const name = prompt('New page name:')
|
||||
if (!name || name.trim() === '') return
|
||||
if (this.pages.find(p => p.name === name)) {
|
||||
this.showToast('Page already exists', 'error')
|
||||
return
|
||||
}
|
||||
this.pages.push({ name: name.trim(), keys: [] })
|
||||
this.activePage = name.trim()
|
||||
this.config.pages = this.pages
|
||||
if (!this.config.default_page) {
|
||||
this.config.default_page = name.trim()
|
||||
}
|
||||
await this.saveConfig()
|
||||
},
|
||||
|
||||
async deletePage() {
|
||||
if (this.pages.length <= 1) {
|
||||
this.showToast('Cannot delete the last page', 'error')
|
||||
return
|
||||
}
|
||||
if (!confirm(`Delete page "${this.activePage}"?`)) return
|
||||
const oldName = this.activePage
|
||||
this.pages = this.pages.filter(p => p.name !== oldName)
|
||||
this.activePage = this.pages[0].name
|
||||
if (this.config.default_page === oldName) {
|
||||
this.config.default_page = this.activePage
|
||||
}
|
||||
this.config.pages = this.pages
|
||||
await this.saveConfig()
|
||||
},
|
||||
|
||||
async renamePage() {
|
||||
const oldName = this.activePage
|
||||
const newName = prompt('Rename page:', oldName)
|
||||
if (!newName || newName.trim() === '' || newName === oldName) return
|
||||
if (this.pages.find(p => p.name === newName.trim())) {
|
||||
this.showToast('Page already exists', 'error')
|
||||
return
|
||||
}
|
||||
const page = this.pages.find(p => p.name === oldName)
|
||||
if (page) page.name = newName.trim()
|
||||
this.activePage = newName.trim()
|
||||
if (this.config.default_page === oldName) {
|
||||
this.config.default_page = newName.trim()
|
||||
}
|
||||
|
||||
// Update page references
|
||||
for (const p of this.pages) {
|
||||
for (const k of p.keys) {
|
||||
if (k.action?.page === oldName) k.action.page = newName.trim()
|
||||
}
|
||||
}
|
||||
for (const r of (this.config.auto_switch || [])) {
|
||||
if (r.page === oldName) r.page = newName.trim()
|
||||
}
|
||||
|
||||
await this.saveConfig()
|
||||
},
|
||||
|
||||
// ── Save / Reload ──
|
||||
async saveConfig() {
|
||||
this.config.pages = this.pages
|
||||
try {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.config),
|
||||
})
|
||||
if (res.ok) {
|
||||
this.showToast('Saved', 'success')
|
||||
} else {
|
||||
const text = await res.text()
|
||||
this.showToast(`Save failed: ${text}`, 'error')
|
||||
}
|
||||
} catch (e) {
|
||||
this.showToast('Save failed', 'error')
|
||||
}
|
||||
},
|
||||
|
||||
async reloadConfig() {
|
||||
await this.loadConfig()
|
||||
this.showToast('Reloaded', 'success')
|
||||
},
|
||||
|
||||
// ── Settings ──
|
||||
async saveSettings() {
|
||||
if (!this.config.devices || this.config.devices.length === 0) {
|
||||
this.config.devices = [{ serial: '', brightness: 75 }]
|
||||
}
|
||||
this.config.devices[0].brightness = parseInt(this.settingsForm.brightness)
|
||||
this.config.screensaver = {
|
||||
enabled: this.settingsForm.screensaver_enabled,
|
||||
idle_seconds: parseInt(this.settingsForm.screensaver_idle) || 30,
|
||||
brightness: parseInt(this.settingsForm.screensaver_brightness) || 10,
|
||||
}
|
||||
this.config.auto_switch = this.autoSwitchRules
|
||||
await this.saveConfig()
|
||||
},
|
||||
|
||||
addAutoSwitchRule() {
|
||||
this.autoSwitchRules.push({ wm_class: '', title: '', page: '', stay: false, devices: [] })
|
||||
},
|
||||
|
||||
removeAutoSwitchRule(idx) {
|
||||
this.autoSwitchRules.splice(idx, 1)
|
||||
},
|
||||
|
||||
// ── Backup ──
|
||||
backups: [],
|
||||
|
||||
async loadBackups() {
|
||||
try {
|
||||
const res = await fetch('/api/backups')
|
||||
this.backups = await res.json()
|
||||
} catch (e) {
|
||||
this.backups = []
|
||||
}
|
||||
},
|
||||
|
||||
downloadConfig() {
|
||||
window.open('/api/config/download', '_blank')
|
||||
},
|
||||
|
||||
async downloadBackup(name) {
|
||||
window.open(`/api/backups/${encodeURIComponent(name)}`, '_blank')
|
||||
},
|
||||
|
||||
async restoreConfig() {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.json'
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await fetch('/api/config/restore', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (res.ok) {
|
||||
await this.loadConfig()
|
||||
this.showToast('Config restored', 'success')
|
||||
} else {
|
||||
const text = await res.text()
|
||||
this.showToast(`Restore failed: ${text}`, 'error')
|
||||
}
|
||||
} catch (e) {
|
||||
this.showToast('Restore failed', 'error')
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
},
|
||||
|
||||
async restoreBackup(filename) {
|
||||
if (!confirm(`Restore "${filename}"?`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/backups/${encodeURIComponent(filename)}`)
|
||||
if (!res.ok) {
|
||||
this.showToast('Failed to download backup', 'error')
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, filename)
|
||||
const putRes = await fetch('/api/config/restore', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (putRes.ok) {
|
||||
await this.loadConfig()
|
||||
this.showToast('Backup restored', 'success')
|
||||
} else {
|
||||
const text = await putRes.text()
|
||||
this.showToast(`Restore failed: ${text}`, 'error')
|
||||
}
|
||||
} catch (e) {
|
||||
this.showToast('Restore failed', 'error')
|
||||
}
|
||||
},
|
||||
|
||||
// ── Toast ──
|
||||
showToast(msg, type = 'success') {
|
||||
this.toast = { msg, type }
|
||||
setTimeout(() => { this.toast = null }, 2500)
|
||||
},
|
||||
|
||||
// ── Formatting ──
|
||||
formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
return (bytes / 1024).toFixed(1) + ' KB'
|
||||
},
|
||||
|
||||
formatTime(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString()
|
||||
},
|
||||
|
||||
backupNameTime(name) {
|
||||
// Extract date from config.YYYY-MM-DDTHH-MM-SS.json
|
||||
const m = name.match(/config\.(.+)\.json/)
|
||||
if (!m) return name
|
||||
return m[1].replace('T', ' ')
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -0,0 +1,440 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>StreamDeck</title>
|
||||
<script src="/static/app.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div x-data="deckApp" x-init="init()" class="app" @keydown.escape="editing = null; subView = null">
|
||||
|
||||
<!-- ═══ Header ═══ -->
|
||||
<header>
|
||||
<button class="icon-btn" @click="subView = 'switcher'" title="Switch page">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
</button>
|
||||
|
||||
<div class="page-nav" x-show="view === 'grid'">
|
||||
<template x-for="(p, i) in pages" :key="p.name">
|
||||
<span class="dot"
|
||||
:class="{ active: p.name === activePage }"
|
||||
@click="switchPage(p.name)"
|
||||
:title="p.name"></span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<span x-show="view !== 'grid'" x-text="view === 'settings' ? 'Settings' : 'Backups'"
|
||||
style="font-size:14px;font-weight:600;"></span>
|
||||
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" @click="view = 'grid'; subView = null" title="Grid">
|
||||
<i class="fas fa-table-cells"></i>
|
||||
</button>
|
||||
<button class="icon-btn" @click="view = 'settings'; subView = null" title="Settings">
|
||||
<i class="fas fa-gear"></i>
|
||||
</button>
|
||||
<button class="icon-btn" @click="view = 'backups'; loadBackups(); subView = null" title="Backups">
|
||||
<i class="fas fa-floppy-disk"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ═══ Grid View ═══ -->
|
||||
<main x-show="view === 'grid' && !subView">
|
||||
<div class="deck-grid">
|
||||
<template x-for="key in gridKeys" :key="key.index">
|
||||
<button class="key-btn"
|
||||
:class="{ empty: !key.configured }"
|
||||
@click="editKey(key.index)">
|
||||
<img x-show="key.configured && key.background"
|
||||
:src="key.previewUrl" alt="">
|
||||
<i x-show="key.configured && !key.background && isFAIcon(key.icon)"
|
||||
:class="faClass(key.icon)" class="fa-preview"></i>
|
||||
<img x-show="key.configured && !key.background && !isFAIcon(key.icon) && key.icon"
|
||||
:src="key.previewUrl" alt="">
|
||||
<span x-show="key.configured && !key.background && !key.icon && key.label"
|
||||
style="font-size:12px;color:#fff;opacity:0.75;text-align:center;line-height:1.2;padding:4px;"
|
||||
x-text="key.label"></span>
|
||||
<span x-show="!key.configured" style="color:var(--text-muted);font-size:24px;opacity:0.5;">+</span>
|
||||
<i x-show="key.action?.type === 'keyboard'" class="fas fa-keyboard display-indicator" title="Keyboard shortcut" style="left:4px;right:auto;"></i>
|
||||
<i x-show="key.hasDisplay" class="fas fa-arrows-rotate display-indicator" title="Periodic display"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-outline" @click="addPage">+ Add page</button>
|
||||
<button class="btn btn-outline" @click="deletePage"
|
||||
:disabled="pages.length <= 1"
|
||||
style="margin-left:auto;">Delete page</button>
|
||||
<button class="btn btn-outline" @click="renamePage">Rename</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ═══ Page Switcher (sub-view) ═══ -->
|
||||
<main x-show="view === 'grid' && subView === 'switcher'" style="justify-content:flex-start;padding-top:12px;">
|
||||
<div class="panel" style="width:100%;">
|
||||
<h3>Pages</h3>
|
||||
<template x-for="p in pages" :key="p.name">
|
||||
<button class="btn btn-outline" style="display:block;width:100%;margin-bottom:4px;text-align:left;"
|
||||
:style="{ borderColor: p.name === activePage ? 'var(--accent)' : '' }"
|
||||
@click="switchPage(p.name); subView = null">
|
||||
<span x-text="p.name"></span>
|
||||
<span style="float:right;color:var(--text-muted);font-size:11px;"
|
||||
x-text="p.keys.length + ' keys'"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ═══ Settings View ═══ -->
|
||||
<main x-show="view === 'settings'" style="justify-content:flex-start;padding-top:12px;">
|
||||
<div class="panel" style="width:100%;">
|
||||
|
||||
<!-- Device -->
|
||||
<div class="panel-section">
|
||||
<h4>Device</h4>
|
||||
<div class="form-group">
|
||||
<label>Brightness</label>
|
||||
<div class="slider-group">
|
||||
<input type="range" min="0" max="100" x-model.number="settingsForm.brightness">
|
||||
<span class="value" x-text="settingsForm.brightness + '%'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screensaver -->
|
||||
<div class="panel-section">
|
||||
<h4>Screensaver</h4>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="ss-enabled" x-model="settingsForm.screensaver_enabled">
|
||||
<label for="ss-enabled">Enable screensaver</label>
|
||||
</div>
|
||||
<div class="form-row" x-show="settingsForm.screensaver_enabled">
|
||||
<div class="form-group">
|
||||
<label>Idle (seconds)</label>
|
||||
<input type="number" min="5" x-model.number="settingsForm.screensaver_idle">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Brightness on idle</label>
|
||||
<input type="number" min="0" max="100" x-model.number="settingsForm.screensaver_brightness">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-switch -->
|
||||
<div class="panel-section">
|
||||
<h4>Auto-switch rules</h4>
|
||||
<template x-for="(rule, i) in autoSwitchRules" :key="i">
|
||||
<div class="rule-item">
|
||||
<div style="flex:1;">
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="margin-bottom:4px;">
|
||||
<label>WM Class</label>
|
||||
<input type="text" x-model="rule.wm_class" placeholder="regex">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:4px;">
|
||||
<label>Title</label>
|
||||
<input type="text" x-model="rule.title" placeholder="regex">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="margin-bottom:0;">
|
||||
<label>Page</label>
|
||||
<select x-model="rule.page">
|
||||
<option value="">--</option>
|
||||
<template x-for="p in pages" :key="p.name">
|
||||
<option :value="p.name" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="checkbox-row" style="margin-bottom:0;padding-top:16px;">
|
||||
<input type="checkbox" x-model="rule.stay">
|
||||
<label>Stay</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-danger" style="flex-shrink:0;" @click="removeAutoSwitchRule(i)">x</button>
|
||||
</div>
|
||||
</template>
|
||||
<button class="btn btn-outline" @click="addAutoSwitchRule" style="width:100%;margin-top:8px;">
|
||||
+ Add rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" @click="saveSettings">Save settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ═══ Backup View ═══ -->
|
||||
<main x-show="view === 'backups'" style="justify-content:flex-start;padding-top:12px;">
|
||||
<div class="panel" style="width:100%;">
|
||||
|
||||
<div class="panel-section">
|
||||
<h4>Actions</h4>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-primary" @click="downloadConfig">
|
||||
<i class="fas fa-download" style="margin-right:4px;"></i> Export JSON
|
||||
</button>
|
||||
<button class="btn btn-outline" @click="restoreConfig">
|
||||
<i class="fas fa-upload" style="margin-right:4px;"></i> Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h4>Auto backups <span style="font-weight:400;color:var(--text-muted);"
|
||||
x-text="backups.length ? '(' + backups.length + ')' : ''"></span></h4>
|
||||
<template x-if="backups.length === 0">
|
||||
<p style="color:var(--text-muted);font-size:12px;">No backups yet. They are created automatically when you save.</p>
|
||||
</template>
|
||||
<template x-for="b in backups" :key="b.name">
|
||||
<div class="backup-item">
|
||||
<div>
|
||||
<div x-text="backupNameTime(b.name)"></div>
|
||||
<div class="meta" x-text="formatSize(b.size)"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn btn-outline" style="padding:4px 8px;font-size:11px;"
|
||||
@click="downloadBackup(b.name)" title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline" style="padding:4px 8px;font-size:11px;"
|
||||
@click="restoreBackup(b.name)" title="Restore">
|
||||
<i class="fas fa-rotate-left"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ═══ Key Editor Modal ═══ -->
|
||||
<div class="modal-overlay" x-show="editing !== null" @click.self="editing = null">
|
||||
<div class="modal" x-show="editing !== null" x-transition>
|
||||
<h3>Edit Key <span x-text="editing + 1"></span></h3>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="preview-box" @click="pickImage" title="Click to upload image">
|
||||
<img :src="editPreviewUrl" alt="">
|
||||
<div class="preview-overlay">
|
||||
<i class="fas fa-image"></i>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" accept="image/*" id="image-picker" style="display:none"
|
||||
x-ref="fileInput" @change="onImagePicked">
|
||||
|
||||
<!-- Basic fields -->
|
||||
<div class="form-group">
|
||||
<label>Icon</label>
|
||||
<input type="text" x-model="editForm.icon"
|
||||
placeholder="fa:terminal, @system-icon, /path/to/image.png">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Label</label>
|
||||
<input type="text" x-model="editForm.label" placeholder="Optional text">
|
||||
</div>
|
||||
|
||||
<!-- Advanced toggle -->
|
||||
<button class="collapse-toggle" :class="{ open: showAdvanced }" @click="showAdvanced = !showAdvanced">
|
||||
<i class="fas fa-chevron-right"></i> More
|
||||
</button>
|
||||
|
||||
<!-- Advanced fields -->
|
||||
<div x-show="showAdvanced" x-transition>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Icon Scale</label>
|
||||
<input type="number" step="0.01" min="0.1" max="1.0" x-model.number="editForm.icon_scale">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Font Size</label>
|
||||
<input type="number" step="0.5" min="8" max="32" x-model.number="editForm.font_size">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Background</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input type="color" x-model="editForm.bg_color"
|
||||
style="width:40px;height:40px;padding:2px;border-radius:var(--radius-xs);cursor:pointer;">
|
||||
<input type="text" x-model="editForm.bg_color"
|
||||
placeholder="#rrggbb or #rrggbbaa" maxlength="9"
|
||||
style="flex:1;">
|
||||
<button class="icon-btn" style="width:24px;height:24px;font-size:10px;flex-shrink:0;"
|
||||
@click="editForm.background = ''" title="Clear"
|
||||
x-show="editForm.background !== ''">
|
||||
<i class="fas fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Action type</label>
|
||||
<select x-model="editForm.action_type">
|
||||
<option value="none">None</option>
|
||||
<option value="command">Command</option>
|
||||
<option value="builtin">Builtin</option>
|
||||
<option value="script">Script</option>
|
||||
<option value="page">Page</option>
|
||||
<option value="keyboard">Keyboard shortcut</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Command fields -->
|
||||
<template x-if="editForm.action_type === 'command'">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label>Command</label>
|
||||
<input type="text" x-model="editForm.command" placeholder="firefox">
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="bg-cb" x-model="editForm.bg_color">
|
||||
<label for="bg-cb">Run in background</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.action_type === 'builtin'">
|
||||
<div class="form-group">
|
||||
<label>Builtin action</label>
|
||||
<select x-model="editForm.builtin">
|
||||
<option value="">-- select --</option>
|
||||
<option value="volume_up">Volume Up</option>
|
||||
<option value="volume_down">Volume Down</option>
|
||||
<option value="volume_mute">Volume Mute</option>
|
||||
<option value="brightness_up">Brightness Up</option>
|
||||
<option value="brightness_down">Brightness Down</option>
|
||||
<option value="media_play_pause">Play/Pause</option>
|
||||
<option value="media_next">Next Track</option>
|
||||
<option value="media_prev">Previous Track</option>
|
||||
<option value="media_stop">Stop</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.action_type === 'script'">
|
||||
<div class="form-group">
|
||||
<label>Script path</label>
|
||||
<input type="text" x-model="editForm.script" placeholder="/path/to/script.sh">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.action_type === 'page'">
|
||||
<div class="form-group">
|
||||
<label>Target page</label>
|
||||
<select x-model="editForm.page">
|
||||
<option value="">-- select --</option>
|
||||
<template x-for="p in pages" :key="p.name">
|
||||
<option :value="p.name" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.action_type === 'keyboard'">
|
||||
<div class="form-group">
|
||||
<label>Key combination</label>
|
||||
<div class="mod-row">
|
||||
<button class="mod-btn" :class="{ active: editForm.modCtrl }" @click="toggleMod('ctrl')" type="button">Ctrl</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.modAlt }" @click="toggleMod('alt')" type="button">Alt</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.modShift }" @click="toggleMod('shift')" type="button">Shift</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.modSuper }" @click="toggleMod('super')" type="button">Super</button>
|
||||
</div>
|
||||
<div class="key-capture"
|
||||
x-ref="keyCapture"
|
||||
:class="{ capturing: isCapturingKey }"
|
||||
tabindex="0"
|
||||
@keydown.prevent="onCaptureKey"
|
||||
@blur="isCapturingKey = false"
|
||||
@click="startKeyCapture">
|
||||
<span x-show="!isCapturingKey && editForm.mainKey" class="captured-key" x-text="displayKey(editForm.mainKey)"></span>
|
||||
<span x-show="isCapturingKey" class="capture-hint">Press any key...</span>
|
||||
<span x-show="!isCapturingKey && !editForm.mainKey" class="capture-placeholder">Click to capture</span>
|
||||
</div>
|
||||
<div class="key-chips" x-show="editForm.keys" style="margin-top:8px;">
|
||||
<template x-for="(chip, ki) in keyChips" :key="ki">
|
||||
<span class="key-chip" x-text="chip"></span>
|
||||
<span x-show="ki < keyChips.length - 1" class="key-plus">+</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Display section -->
|
||||
<button class="collapse-toggle" :class="{ open: showDisplay }" @click="showDisplay = !showDisplay" style="margin-top:8px;">
|
||||
<i class="fas fa-chevron-right"></i> Display (periodic output)
|
||||
</button>
|
||||
|
||||
<div x-show="showDisplay" x-transition>
|
||||
<div class="form-group">
|
||||
<label>Display mode</label>
|
||||
<div class="tab-row">
|
||||
<button class="tab-btn" :class="{ active: editForm.display_mode === 'none' }"
|
||||
@click="editForm.display_mode = 'none'">Off</button>
|
||||
<button class="tab-btn" :class="{ active: editForm.display_mode === 'command' }"
|
||||
@click="editForm.display_mode = 'command'">Command</button>
|
||||
<button class="tab-btn" :class="{ active: editForm.display_mode === 'script' }"
|
||||
@click="editForm.display_mode = 'script'">Script</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="editForm.display_mode === 'command'">
|
||||
<div class="form-group">
|
||||
<label>Shell command</label>
|
||||
<input type="text" x-model="editForm.display_command"
|
||||
placeholder="curl -s http://...">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.display_mode === 'script'">
|
||||
<div class="form-group">
|
||||
<label>Script path</label>
|
||||
<input type="text" x-model="editForm.display_script"
|
||||
placeholder="/path/to/script.sh">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.display_mode !== 'none'">
|
||||
<div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Interval</label>
|
||||
<input type="text" x-model="editForm.display_interval" placeholder="30s">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Timeout</label>
|
||||
<input type="text" x-model="editForm.display_timeout" placeholder="30s">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max output length</label>
|
||||
<input type="number" x-model.number="editForm.display_max_len" min="16" max="4096" placeholder="128">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" @click="saveKey">Save</button>
|
||||
<button class="btn btn-danger" @click="deleteKey">Delete</button>
|
||||
<button class="btn btn-outline" @click="editing = null">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Toast ═══ -->
|
||||
<div class="toast" :class="toast?.type" x-show="toast" x-transition x-text="toast?.msg"></div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,697 @@
|
||||
:root {
|
||||
--bg: #0a0a10;
|
||||
--surface: #141420;
|
||||
--surface-hover: #1a1a2a;
|
||||
--border: #252540;
|
||||
--border-light: #333358;
|
||||
--text: #e4e4f0;
|
||||
--text-muted: #7a7a90;
|
||||
--accent: #6366f1;
|
||||
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--radius-xs: 6px;
|
||||
--shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.page-nav .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.page-nav .dot:hover { background: var(--text-muted); }
|
||||
.page-nav .dot.active { background: var(--accent); box-shadow: 0 0 8px var(--accent-glow); }
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
font-size: 14px;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-light);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Grid ── */
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.deck-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.key-btn {
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #0d0d18;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all var(--transition);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.key-btn:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.key-btn:active { transform: scale(0.97); }
|
||||
|
||||
.key-btn img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.key-btn.empty {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.key-btn.empty:hover {
|
||||
opacity: 0.5;
|
||||
border-color: var(--border-light);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.key-label {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
font-size: 9px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fa-preview {
|
||||
font-size: 34px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 150ms ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 32px);
|
||||
box-shadow: var(--shadow);
|
||||
animation: slideUp 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(16px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: var(--radius-xs);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
background: #0d0d18;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preview-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.preview-box:hover .preview-overlay { opacity: 1; }
|
||||
|
||||
.preview-overlay i {
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-glow);
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.form-group select {
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%237a7a90' stroke-width='1.5'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-row .form-group { flex: 1; }
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.checkbox-row input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-row label {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Collapsible */
|
||||
.collapse-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 8px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.collapse-toggle:hover { color: var(--accent); }
|
||||
.collapse-toggle i { font-size: 10px; transition: transform var(--transition); }
|
||||
.collapse-toggle.open i { transform: rotate(90deg); }
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border: 1px solid transparent;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #4f46e5;
|
||||
box-shadow: 0 0 16px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--danger);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* ── Settings / Backup panels ── */
|
||||
.panel {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel-section:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.panel-section h4 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.backup-item:last-child { border-bottom: none; }
|
||||
|
||||
.backup-item .meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg);
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status-badge .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-badge .dot.online { background: var(--success); }
|
||||
.status-badge .dot.offline { background: var(--text-muted); }
|
||||
|
||||
/* ── Toasts ── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px 24px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
z-index: 200;
|
||||
box-shadow: var(--shadow);
|
||||
animation: slideUp 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.toast.success { border-color: var(--success); }
|
||||
.toast.error { border-color: var(--danger); }
|
||||
|
||||
.slider-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.slider-group input[type="range"] {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slider-group input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
|
||||
.slider-group .value {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar .btn {
|
||||
font-size: 12px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
|
||||
|
||||
/* ── Auto-switch rule item ── */
|
||||
.rule-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius-xs);
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rule-item code {
|
||||
background: var(--surface);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Display indicator on grid keys ── */
|
||||
.display-indicator {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
font-size: 7px;
|
||||
color: var(--accent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Tab buttons ── */
|
||||
.tab-row {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg);
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tab-btn:last-child { border-right: none; }
|
||||
.tab-btn:hover { color: var(--text); }
|
||||
.tab-btn.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Modifier buttons ── */
|
||||
.mod-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.mod-btn {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
text-align: center;
|
||||
}
|
||||
.mod-btn:hover { color: var(--text); border-color: var(--border-light); }
|
||||
.mod-btn.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Key capture ── */
|
||||
.key-capture {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 8px;
|
||||
min-height: 34px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all var(--transition);
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.key-capture:hover { border-color: var(--accent); }
|
||||
.key-capture:focus-visible { border-color: var(--accent); }
|
||||
.key-capture.capturing {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
animation: recorderPulse 1s infinite;
|
||||
}
|
||||
.captured-key {
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
}
|
||||
.capture-hint { color: var(--accent); font-weight: 600; }
|
||||
.capture-placeholder { color: var(--text-muted); opacity: 0.6; }
|
||||
|
||||
@keyframes recorderPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
.key-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.key-chip {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--accent);
|
||||
}
|
||||
.key-plus {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
Reference in New Issue
Block a user