refactor: restructure to standard Go project layout (cmd/ + internal/)
- Move entry point to cmd/streamdeck-lets-go/ - Split package main into internal packages: - internal/deck: hardware control (Deck, events, brightness) - internal/render: key rendering, icons, fonts, PageManager - internal/web: HTTP API + embedded SPA - internal/daemon: event loop, autoswitch, screensaver, actions - Add ConfigDir() to internal/config - Export cross-package API (Deck, PageManager, WebServer, etc.) - Move dist/arch/ → packaging/ with updated PKGBUILD/.SRCINFO - Add Makefile (build, test, vet, fmt, install) - Update README with new build commands and project structure - Delete unused media.go stub
This commit is contained in:
Vendored
+5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,745 @@
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('deckApp', () => ({
|
||||
// ── State ──
|
||||
config: null,
|
||||
pages: [],
|
||||
activePage: '',
|
||||
view: 'grid',
|
||||
showAdvanced: false,
|
||||
toast: null,
|
||||
displayOutputs: {},
|
||||
isCapturingKey: false,
|
||||
decks: [],
|
||||
activeDeckSerial: '',
|
||||
|
||||
// ── Edit state ──
|
||||
editing: null,
|
||||
editForm: {
|
||||
bg_color: '',
|
||||
actions: {
|
||||
tap: {},
|
||||
long_press: {},
|
||||
double_tap: {},
|
||||
hold: { start: {}, end: {} },
|
||||
},
|
||||
},
|
||||
|
||||
// ── Sub-views ──
|
||||
subView: null,
|
||||
|
||||
// ── Settings ──
|
||||
settingsForm: {},
|
||||
autoSwitchRules: [],
|
||||
|
||||
// ── Constants ──
|
||||
actionIconMap: {
|
||||
command: 'terminal',
|
||||
builtin: 'cog',
|
||||
script: 'file-code',
|
||||
page: 'layer-group',
|
||||
keyboard: 'keyboard',
|
||||
},
|
||||
actionTitleMap: {
|
||||
command: 'Command',
|
||||
builtin: 'Built-in',
|
||||
script: 'Script',
|
||||
page: 'Switch page',
|
||||
keyboard: 'Keyboard shortcut',
|
||||
},
|
||||
|
||||
// ── Init ──
|
||||
async init() {
|
||||
await this.loadConfig()
|
||||
await this.loadDecks()
|
||||
setInterval(() => this.pollDisplayOutputs(), 3000)
|
||||
setInterval(() => this.loadEffectiveKeys(), 10000)
|
||||
this.connectSSE()
|
||||
},
|
||||
|
||||
connectSSE() {
|
||||
if (this._sse) this._sse.close()
|
||||
const es = new EventSource('/api/events')
|
||||
this._sse = es
|
||||
es.addEventListener('page_changed', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
if (data.page && this.pages.find(p => p.name === data.page)) {
|
||||
this.activePage = data.page
|
||||
localStorage.setItem('sd_active_page', data.page)
|
||||
this.displayOutputs = {}
|
||||
}
|
||||
} catch (_) {}
|
||||
})
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
this._sse = null
|
||||
setTimeout(() => this.connectSSE(), 3000)
|
||||
}
|
||||
},
|
||||
|
||||
async loadDecks() {
|
||||
try {
|
||||
const res = await fetch('/api/decks')
|
||||
if (res.ok) {
|
||||
this.decks = await res.json()
|
||||
if (this.decks.length > 0 && !this.activeDeckSerial) {
|
||||
this.activeDeckSerial = this.decks[0].serial
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
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
|
||||
await this.loadEffectiveKeys()
|
||||
if (this.pages.length > 0) {
|
||||
const saved = localStorage.getItem('sd_active_page')
|
||||
if (saved && this.pages.find(p => p.name === saved)) {
|
||||
this.activePage = saved
|
||||
} else {
|
||||
this.activePage = this.config.default_page || this.pages[0].name
|
||||
}
|
||||
}
|
||||
this.syncSettings()
|
||||
} catch (e) {
|
||||
this.showToast('Failed to load config', 'error')
|
||||
}
|
||||
},
|
||||
|
||||
async loadEffectiveKeys() {
|
||||
try {
|
||||
const res = await fetch('/api/pages')
|
||||
const pagesData = await res.json()
|
||||
for (const pd of pagesData) {
|
||||
const page = this.pages.find(p => p.name === pd.name)
|
||||
if (page && pd.effective_keys && pd.effective_keys.length > 0) {
|
||||
page.effective_keys = pd.effective_keys
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
syncSettings() {
|
||||
this.settingsForm = {
|
||||
brightness: this.getDeviceConfig(this.activeDeckSerial)?.brightness ?? 75,
|
||||
font: this.config.font || 'medium',
|
||||
show_label_background: this.config.show_label_background ?? true,
|
||||
screensaver_enabled: this.config.screensaver?.enabled || false,
|
||||
screensaver_idle: this.config.screensaver?.idle_seconds || 30,
|
||||
screensaver_brightness: this.config.screensaver?.brightness || 10,
|
||||
long_press_ms: this.config.timing?.long_press_ms || 500,
|
||||
double_tap_ms: this.config.timing?.double_tap_ms || 300,
|
||||
}
|
||||
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)
|
||||
},
|
||||
|
||||
get activeDeck() {
|
||||
return this.decks.find(d => d.serial === this.activeDeckSerial)
|
||||
|| { keys_x: 5, keys_y: 3, num_keys: 15 }
|
||||
},
|
||||
|
||||
getDeviceConfig(serial) {
|
||||
return this.config.devices?.find(d => d.serial === serial)
|
||||
},
|
||||
|
||||
// ── Grid ──
|
||||
get gridKeys() {
|
||||
const page = this.currentPage
|
||||
if (!page) return []
|
||||
const sourceKeys = (page.effective_keys && page.effective_keys.length > 0)
|
||||
? page.effective_keys
|
||||
: (page.keys || [])
|
||||
const keys = []
|
||||
const n = this.activeDeck.num_keys
|
||||
for (let i = 0; i < n; i++) {
|
||||
const kc = sourceKeys.find(k => k.index === i)
|
||||
const dout = this.displayOutputs[i]
|
||||
keys.push({
|
||||
index: i,
|
||||
...kc,
|
||||
configured: !!kc,
|
||||
hasDisplay: !!(kc?.display),
|
||||
hasAction: !!(kc?.actions?.length),
|
||||
actionTypes: this.getActionTypes(kc?.actions || []),
|
||||
displayBg: (kc?.display && dout?.background) || '',
|
||||
displayText: (kc?.display && dout?.text) || '',
|
||||
previewUrl: this.keyPreviewUrl(kc ? kc : {}, dout),
|
||||
isDynamic: !!(page.dynamic_keys && page.effective_keys?.find(k => k.index === i)),
|
||||
})
|
||||
}
|
||||
return keys
|
||||
},
|
||||
|
||||
getActionTypes(actions) {
|
||||
const seen = {}
|
||||
for (const a of actions) {
|
||||
if (a.type && a.type !== 'none') {
|
||||
seen[a.type] = true
|
||||
}
|
||||
}
|
||||
return Object.keys(seen)
|
||||
},
|
||||
|
||||
keyPreviewUrl(kc, dout) {
|
||||
const k = kc || {}
|
||||
let url = '/api/render?key_size=72'
|
||||
if (k.icon) url += `&icon=${encodeURIComponent(k.icon)}`
|
||||
const label = (k.display && (dout?.text || this.displayOutputs[k.index]?.text)) || 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}`
|
||||
const bg = dout?.background || k.background
|
||||
if (bg) url += `&background=${encodeURIComponent(bg)}`
|
||||
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 ''
|
||||
},
|
||||
|
||||
// ── Multi-action helpers ──
|
||||
getCurrentAction() {
|
||||
const t = this.editForm.activeTrigger || 'tap'
|
||||
if (t === 'hold') {
|
||||
return this.editForm.actions?.hold?.[this.editForm.holdPhase || 'start']
|
||||
}
|
||||
return this.editForm.actions?.[t]
|
||||
},
|
||||
|
||||
hasCurrentAction(trigger) {
|
||||
const a = this.editForm.actions?.[trigger]
|
||||
return a && a.type && a.type !== 'none'
|
||||
},
|
||||
|
||||
hasHoldAction(phase) {
|
||||
const a = this.editForm.actions?.hold?.[phase]
|
||||
return a && a.type && a.type !== 'none'
|
||||
},
|
||||
|
||||
triggerLabel(trigger) {
|
||||
return { tap: 'Tap', long_press: 'Long Press', double_tap: 'Double Tap', hold: 'Hold' }[trigger] || trigger
|
||||
},
|
||||
|
||||
// ── Keyboard helpers (for current action) ──
|
||||
get keyChips() {
|
||||
const a = this.getCurrentAction()
|
||||
if (!a || !a.keys) return []
|
||||
const map = { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', super: 'Super', meta: 'Super' }
|
||||
return a.keys.split('+').map(k => map[k] || k.charAt(0).toUpperCase() + k.slice(1))
|
||||
},
|
||||
|
||||
toggleMod(mod) {
|
||||
const a = this.getCurrentAction()
|
||||
if (!a) return
|
||||
if (mod === 'ctrl') a.modCtrl = !a.modCtrl
|
||||
else if (mod === 'alt') a.modAlt = !a.modAlt
|
||||
else if (mod === 'shift') a.modShift = !a.modShift
|
||||
else if (mod === 'super') a.modSuper = !a.modSuper
|
||||
this.rebuildKeys()
|
||||
},
|
||||
|
||||
rebuildKeys() {
|
||||
const a = this.getCurrentAction()
|
||||
if (!a) return
|
||||
const mods = []
|
||||
if (a.modCtrl) mods.push('ctrl')
|
||||
if (a.modAlt) mods.push('alt')
|
||||
if (a.modShift) mods.push('shift')
|
||||
if (a.modSuper) mods.push('super')
|
||||
if (a.mainKey) mods.push(a.mainKey)
|
||||
a.keys = mods.join('+')
|
||||
},
|
||||
|
||||
startKeyCapture() {
|
||||
this.isCapturingKey = true
|
||||
this.$nextTick(() => {
|
||||
this.$el.querySelectorAll('.key-capture').forEach(el => {
|
||||
if (el.offsetParent !== null) el.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
|
||||
const a = this.getCurrentAction()
|
||||
if (a) a.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 ──
|
||||
defaultAction(trigger) {
|
||||
return { trigger, type: 'none', command: '', builtin: '', script: '', page: '', keys: '', background: true, modCtrl: false, modAlt: false, modShift: false, modSuper: false, mainKey: '' }
|
||||
},
|
||||
|
||||
editKey(idx) {
|
||||
this.isCapturingKey = false
|
||||
const page = this.currentPage
|
||||
if (!page) return
|
||||
const kc = page.keys.find(k => k.index === idx)
|
||||
if (!kc && page.dynamic_keys && page.effective_keys?.find(k => k.index === idx)) {
|
||||
return
|
||||
}
|
||||
this.editing = idx
|
||||
this.showAdvanced = false
|
||||
|
||||
const existing = kc?.actions || []
|
||||
|
||||
const tap = existing.find(a => a.trigger === 'tap') || null
|
||||
const longPress = existing.find(a => a.trigger === 'long_press') || null
|
||||
const doubleTap = existing.find(a => a.trigger === 'double_tap') || null
|
||||
const holdStart = existing.find(a => a.trigger === 'hold_start') || null
|
||||
const holdEnd = existing.find(a => a.trigger === 'hold_end') || null
|
||||
|
||||
const build = (src, trigger) => {
|
||||
const def = this.defaultAction(trigger)
|
||||
if (!src) return def
|
||||
const parts = (src.keys || '').toLowerCase().split('+')
|
||||
const main = parts.length > 0 ? parts.pop() : ''
|
||||
return {
|
||||
...def,
|
||||
type: src.type || 'none',
|
||||
command: src.command || '',
|
||||
builtin: src.builtin || '',
|
||||
script: src.script || '',
|
||||
page: src.page || '',
|
||||
keys: src.keys || '',
|
||||
background: src.background ?? true,
|
||||
modCtrl: parts.includes('ctrl'),
|
||||
modAlt: parts.includes('alt'),
|
||||
modShift: parts.includes('shift'),
|
||||
modSuper: parts.includes('super'),
|
||||
mainKey: main,
|
||||
}
|
||||
}
|
||||
|
||||
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 || '',
|
||||
activeTrigger: 'tap',
|
||||
holdPhase: 'start',
|
||||
actions: {
|
||||
tap: build(tap, 'tap'),
|
||||
long_press: build(longPress, 'long_press'),
|
||||
double_tap: build(doubleTap, 'double_tap'),
|
||||
hold: {
|
||||
start: build(holdStart, 'hold_start'),
|
||||
end: build(holdEnd, 'hold_end'),
|
||||
},
|
||||
},
|
||||
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'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Validate and collect actions
|
||||
const actions = []
|
||||
const checkAction = (a) => {
|
||||
if (!a || !a.type || a.type === 'none') return
|
||||
if (a.type === 'command' && !a.command) return
|
||||
if (a.type === 'script' && !a.script) return
|
||||
if (a.type === 'page' && !a.page) return
|
||||
if (a.type === 'keyboard' && !a.keys) return
|
||||
const entry = {
|
||||
trigger: a.trigger,
|
||||
type: a.type,
|
||||
command: a.type === 'command' ? a.command : undefined,
|
||||
builtin: a.type === 'builtin' ? a.builtin : undefined,
|
||||
script: a.type === 'script' ? a.script : undefined,
|
||||
page: a.type === 'page' ? a.page : undefined,
|
||||
keys: a.type === 'keyboard' ? a.keys : undefined,
|
||||
background: a.background || undefined,
|
||||
}
|
||||
// Clean undefined
|
||||
for (const k of Object.keys(entry)) {
|
||||
if (entry[k] === undefined) delete entry[k]
|
||||
}
|
||||
actions.push(entry)
|
||||
}
|
||||
checkAction(this.editForm.actions.tap)
|
||||
checkAction(this.editForm.actions.long_press)
|
||||
checkAction(this.editForm.actions.double_tap)
|
||||
checkAction(this.editForm.actions.hold.start)
|
||||
checkAction(this.editForm.actions.hold.end)
|
||||
|
||||
const kc = {
|
||||
index: this.editing,
|
||||
icon: this.editForm.icon || undefined,
|
||||
label: this.editForm.label || undefined,
|
||||
icon_scale: this.editForm.icon_scale !== 0.55 ? this.editForm.icon_scale : undefined,
|
||||
font_size: this.editForm.font_size !== 16 ? this.editForm.font_size : undefined,
|
||||
background: this.editForm.bg_color || undefined,
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
kc.actions = actions
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
localStorage.setItem('sd_active_page', name)
|
||||
this.displayOutputs = {}
|
||||
fetch('/api/activate-page', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ page: name })
|
||||
}).catch(() => {})
|
||||
},
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
for (const p of this.pages) {
|
||||
for (const k of p.keys) {
|
||||
for (const a of (k.actions || [])) {
|
||||
if (a.page === oldName) a.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 = []
|
||||
let dev = this.config.devices.find(d => d.serial === this.activeDeckSerial)
|
||||
if (!dev) {
|
||||
dev = { serial: this.activeDeckSerial, brightness: 75 }
|
||||
this.config.devices.push(dev)
|
||||
}
|
||||
dev.brightness = parseInt(this.settingsForm.brightness)
|
||||
this.config.font = this.settingsForm.font || 'medium'
|
||||
this.config.show_label_background = this.settingsForm.show_label_background
|
||||
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.timing = {
|
||||
long_press_ms: parseInt(this.settingsForm.long_press_ms) || 500,
|
||||
double_tap_ms: parseInt(this.settingsForm.double_tap_ms) || 300,
|
||||
}
|
||||
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) {
|
||||
const m = name.match(/config\.(.+)\.json/)
|
||||
if (!m) return name
|
||||
return m[1].replace('T', ' ')
|
||||
},
|
||||
}))
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,589 @@
|
||||
<!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="/static/alpine.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/fontawesome/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="view = 'grid'; 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-selector" x-show="decks.length > 1">
|
||||
<template x-for="d in decks" :key="d.serial">
|
||||
<button class="deck-btn"
|
||||
:class="{ active: d.serial === activeDeckSerial }"
|
||||
@click="activeDeckSerial = d.serial"
|
||||
x-text="d.model + ' (' + d.keys_x + '\u00d7' + d.keys_y + ')'"></button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="deck-grid" :style="'grid-template-columns: repeat(' + activeDeck.keys_x + ', minmax(0, 1fr))'">
|
||||
<template x-for="key in gridKeys" :key="key.index">
|
||||
<button class="key-btn"
|
||||
:style="key.displayBg ? { backgroundColor: key.displayBg } : {}"
|
||||
:class="{ empty: !key.configured }"
|
||||
@click="editKey(key.index)">
|
||||
<img x-show="key.configured && (key.icon || key.label || key.displayText || key.displayBg || key.background)"
|
||||
:src="key.previewUrl" alt="">
|
||||
<span x-show="!key.configured" style="color:var(--text-muted);font-size:24px;opacity:0.5;line-height:1;">+</span>
|
||||
<div class="action-icons" x-show="key.hasAction">
|
||||
<template x-for="(t, ti) in key.actionTypes.slice(0,3)" :key="ti">
|
||||
<i :class="'fas fa-' + actionIconMap[t] + ' action-icon type-' + t"
|
||||
:title="actionTitleMap[t]"></i>
|
||||
</template>
|
||||
<span class="action-icon" x-show="key.actionTypes.length > 3"
|
||||
style="background:var(--text-muted);font-size:7px;font-weight:700;padding:2px 3px;"
|
||||
x-text="'+' + (key.actionTypes.length - 3)"></span>
|
||||
</div>
|
||||
<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 x-show="p.dynamic_keys"
|
||||
title="Dynamic keys generator"
|
||||
style="color:var(--accent);font-size:11px;margin-left:4px;">⚡</span>
|
||||
<span style="float:right;color:var(--text-muted);font-size:11px;"
|
||||
x-text="(p.effective_keys?.length || 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 class="form-group">
|
||||
<label>Default Font</label>
|
||||
<select x-model="settingsForm.font">
|
||||
<option value="medium">Medium</option>
|
||||
<option value="regular">Regular</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="show-label-bg" x-model="settingsForm.show_label_background">
|
||||
<label for="show-label-bg">Show label background</label>
|
||||
</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>
|
||||
|
||||
<!-- Gesture Timing -->
|
||||
<div class="panel-section">
|
||||
<h4>Gesture Timing</h4>
|
||||
<div class="form-group">
|
||||
<label>Long press threshold</label>
|
||||
<div class="slider-group">
|
||||
<input type="range" min="200" max="1500" step="50" x-model.number="settingsForm.long_press_ms">
|
||||
<span class="value" x-text="settingsForm.long_press_ms + 'ms'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Double tap threshold</label>
|
||||
<div class="slider-group">
|
||||
<input type="range" min="100" max="600" step="25" x-model.number="settingsForm.double_tap_ms">
|
||||
<span class="value" x-text="settingsForm.double_tap_ms + 'ms'"></span>
|
||||
</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>
|
||||
|
||||
<!-- Actions section -->
|
||||
<h4 style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;margin-top:12px;">Actions</h4>
|
||||
|
||||
<!-- Action tabs -->
|
||||
<div class="action-tabs">
|
||||
<template x-for="t in ['tap','long_press','double_tap','hold']" :key="t">
|
||||
<button class="action-tab"
|
||||
:class="{ active: editForm.activeTrigger === t, configured: (t === 'hold' ? (hasHoldAction('start') || hasHoldAction('end')) : hasCurrentAction(t)) }"
|
||||
@click="editForm.activeTrigger = t">
|
||||
<span x-text="triggerLabel(t)"></span>
|
||||
<span class="tab-check" x-show="(t === 'hold' ? (hasHoldAction('start') || hasHoldAction('end')) : hasCurrentAction(t))">✓</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Non-hold trigger panes -->
|
||||
<template x-for="t in ['tap','long_press','double_tap']" :key="t">
|
||||
<div x-show="editForm.activeTrigger === t">
|
||||
<div class="form-group">
|
||||
<select x-model="editForm.actions[t].type">
|
||||
<option value="none">None</option>
|
||||
<option value="command">Command</option>
|
||||
<option value="builtin">Builtin</option>
|
||||
<option value="script">Script</option>
|
||||
<option value="page">Switch page</option>
|
||||
<option value="keyboard">Keyboard shortcut</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<template x-if="editForm.actions[t].type === 'command'">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<input type="text" x-model="editForm.actions[t].command" placeholder="firefox">
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" :id="'bg-cb-' + t" x-model="editForm.actions[t].background">
|
||||
<label :for="'bg-cb-' + t">Run in background</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.actions[t].type === 'builtin'">
|
||||
<div class="form-group">
|
||||
<select x-model="editForm.actions[t].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.actions[t].type === 'script'">
|
||||
<div class="form-group">
|
||||
<input type="text" x-model="editForm.actions[t].script" placeholder="/path/to/script.sh">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="form-group" x-show="editForm.actions[t].type === 'page'">
|
||||
<select x-model="editForm.actions[t].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 x-if="editForm.actions[t].type === 'keyboard'">
|
||||
<div class="form-group">
|
||||
<label>Key combination</label>
|
||||
<div class="mod-row">
|
||||
<button class="mod-btn" :class="{ active: editForm.actions[t].modCtrl }" @click="toggleMod('ctrl')" type="button">Ctrl</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.actions[t].modAlt }" @click="toggleMod('alt')" type="button">Alt</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.actions[t].modShift }" @click="toggleMod('shift')" type="button">Shift</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.actions[t].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.actions[t].mainKey" class="captured-key" x-text="displayKey(editForm.actions[t].mainKey)"></span>
|
||||
<span x-show="isCapturingKey" class="capture-hint">Press any key...</span>
|
||||
<span x-show="!isCapturingKey && !editForm.actions[t].mainKey" class="capture-placeholder">Click to capture</span>
|
||||
</div>
|
||||
<div class="key-chips" x-show="editForm.actions[t].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>
|
||||
</template>
|
||||
|
||||
<!-- Hold pane -->
|
||||
<div x-show="editForm.activeTrigger === 'hold'">
|
||||
<div class="hold-subtabs">
|
||||
<button class="hold-stab" :class="{ active: editForm.holdPhase === 'start' }"
|
||||
@click="editForm.holdPhase = 'start'">On press & hold</button>
|
||||
<button class="hold-stab" :class="{ active: editForm.holdPhase === 'end' }"
|
||||
@click="editForm.holdPhase = 'end'">On release</button>
|
||||
</div>
|
||||
|
||||
<template x-for="phase in ['start','end']" :key="phase">
|
||||
<div x-show="editForm.holdPhase === phase">
|
||||
<div class="form-group">
|
||||
<select x-model="editForm.actions.hold[phase].type">
|
||||
<option value="none">None</option>
|
||||
<option value="command">Command</option>
|
||||
<option value="builtin">Builtin</option>
|
||||
<option value="script">Script</option>
|
||||
<option value="page">Switch page</option>
|
||||
<option value="keyboard">Keyboard shortcut</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<template x-if="editForm.actions.hold[phase].type === 'command'">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<input type="text" x-model="editForm.actions.hold[phase].command" placeholder="firefox">
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" :id="'hold-bg-' + phase" x-model="editForm.actions.hold[phase].background">
|
||||
<label :for="'hold-bg-' + phase">Run in background</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.actions.hold[phase].type === 'builtin'">
|
||||
<div class="form-group">
|
||||
<select x-model="editForm.actions.hold[phase].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.actions.hold[phase].type === 'script'">
|
||||
<div class="form-group">
|
||||
<input type="text" x-model="editForm.actions.hold[phase].script" placeholder="/path/to/script.sh">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="form-group" x-show="editForm.actions.hold[phase].type === 'page'">
|
||||
<select x-model="editForm.actions.hold[phase].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 x-if="editForm.actions.hold[phase].type === 'keyboard'">
|
||||
<div class="form-group">
|
||||
<label>Key combination</label>
|
||||
<div class="mod-row">
|
||||
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modCtrl }" @click="toggleMod('ctrl')" type="button">Ctrl</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modAlt }" @click="toggleMod('alt')" type="button">Alt</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modShift }" @click="toggleMod('shift')" type="button">Shift</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].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.actions.hold[phase].mainKey" class="captured-key" x-text="displayKey(editForm.actions.hold[phase].mainKey)"></span>
|
||||
<span x-show="isCapturingKey" class="capture-hint">Press any key...</span>
|
||||
<span x-show="!isCapturingKey && !editForm.actions.hold[phase].mainKey" class="capture-placeholder">Click to capture</span>
|
||||
</div>
|
||||
<div class="key-chips" x-show="editForm.actions.hold[phase].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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Advanced toggle -->
|
||||
<button class="collapse-toggle" :class="{ open: showAdvanced }" @click="showAdvanced = !showAdvanced" style="margin-top:8px;">
|
||||
<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-init="$el.value = editForm.bg_color || '#000000'; $el.addEventListener('input', () => editForm.bg_color = $el.value); $watch('editForm.bg_color', v => $el.value = v || '#000000')"
|
||||
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.bg_color = ''" title="Clear"
|
||||
x-show="editForm.bg_color !== ''">
|
||||
<i class="fas fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid var(--border);margin:8px 0;"></div>
|
||||
|
||||
<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>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,813 @@
|
||||
: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;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
width: 100%;
|
||||
max-width: 525px;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.deck-selector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
align-self: stretch;
|
||||
}
|
||||
.deck-btn {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
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;
|
||||
}
|
||||
.deck-btn:hover { color: var(--text); border-color: var(--border-light); }
|
||||
.deck-btn.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.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%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.key-btn.empty {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
/* ── Indicators on grid keys ── */
|
||||
.display-indicator {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
font-size: 7px;
|
||||
color: var(--accent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.action-icons {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
pointer-events: none;
|
||||
flex-wrap: wrap;
|
||||
max-width: calc(100% - 6px);
|
||||
}
|
||||
.action-icon {
|
||||
font-size: 7px;
|
||||
color: var(--accent);
|
||||
opacity: 0.6;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
/* ── Action tabs ── */
|
||||
.action-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.action-tab {
|
||||
flex: 1;
|
||||
padding: 5px 2px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
transition: all var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.action-tab:hover { color: var(--text); border-color: var(--border-light); }
|
||||
.action-tab.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.action-tab.configured {
|
||||
border-color: var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
.action-tab.active.configured {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.tab-check { font-size: 8px; }
|
||||
|
||||
/* ── Hold sub-tabs ── */
|
||||
.hold-subtabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hold-stab {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.hold-stab:hover { color: var(--text); border-color: var(--border-light); }
|
||||
.hold-stab.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
"streamdeck-lets-go/internal/deck"
|
||||
"streamdeck-lets-go/internal/render"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
var staticRoot fs.FS
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
staticRoot, err = fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("static embed: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
type WebServer struct {
|
||||
cfg *config.Config
|
||||
configPath string
|
||||
pm *render.PageManager
|
||||
extraPMs []*render.PageManager
|
||||
decks []*deck.Deck
|
||||
mu sync.RWMutex
|
||||
|
||||
sseClients map[chan string]struct{}
|
||||
sseMu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewWebServer(cfg *config.Config, configPath string) *WebServer {
|
||||
return &WebServer{
|
||||
cfg: cfg,
|
||||
configPath: configPath,
|
||||
sseClients: make(map[chan string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebServer) UpdateConfig(cfg *config.Config) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cfg = cfg
|
||||
}
|
||||
|
||||
func (s *WebServer) SetPageManager(pm *render.PageManager) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pm = pm
|
||||
}
|
||||
|
||||
func (s *WebServer) SetDecks(decks []*deck.Deck) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.decks = decks
|
||||
}
|
||||
|
||||
func (s *WebServer) SetExtraPageManagers(pms []*render.PageManager) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.extraPMs = pms
|
||||
}
|
||||
|
||||
func (s *WebServer) BroadcastPageChange(page string) {
|
||||
s.sseMu.RLock()
|
||||
defer s.sseMu.RUnlock()
|
||||
for ch := range s.sseClients {
|
||||
select {
|
||||
case ch <- page:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebServer) Serve(ctx context.Context, addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /api/config", s.handleGetConfig)
|
||||
mux.HandleFunc("PUT /api/config", s.handlePutConfig)
|
||||
mux.HandleFunc("GET /api/config/download", s.handleDownloadConfig)
|
||||
mux.HandleFunc("POST /api/config/restore", s.handleRestoreConfig)
|
||||
|
||||
mux.HandleFunc("GET /api/pages", s.handleGetPages)
|
||||
|
||||
mux.HandleFunc("GET /api/render", s.handleRender)
|
||||
|
||||
mux.HandleFunc("GET /api/display-outputs", s.handleDisplayOutputs)
|
||||
|
||||
mux.HandleFunc("GET /api/backups", s.handleListBackups)
|
||||
mux.HandleFunc("GET /api/backups/{filename}", s.handleGetBackup)
|
||||
|
||||
mux.HandleFunc("GET /api/models", s.handleGetModels)
|
||||
|
||||
mux.HandleFunc("GET /api/decks", s.handleGetDecks)
|
||||
|
||||
mux.HandleFunc("GET /api/status", s.handleGetStatus)
|
||||
|
||||
mux.HandleFunc("GET /api/events", s.handleSSE)
|
||||
mux.HandleFunc("POST /api/activate-page", s.handleActivatePage)
|
||||
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot))))
|
||||
|
||||
mux.HandleFunc("/", s.handleSPA)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/api/upload" {
|
||||
s.handleUpload(w, r)
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
srv := &http.Server{Addr: addr, Handler: handler}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
slog.Info("shutting down web server")
|
||||
srv.Close()
|
||||
}()
|
||||
|
||||
slog.Info("web server listening", "addr", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("web server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WebServer) handleSPA(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
data, err := fs.ReadFile(staticRoot, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
ch := make(chan string, 8)
|
||||
|
||||
s.sseMu.Lock()
|
||||
s.sseClients[ch] = struct{}{}
|
||||
s.sseMu.Unlock()
|
||||
|
||||
s.mu.RLock()
|
||||
if s.pm != nil {
|
||||
if active := s.pm.ActivePageName(); active != "" {
|
||||
ch <- active
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
notify := r.Context().Done()
|
||||
go func() {
|
||||
<-notify
|
||||
s.sseMu.Lock()
|
||||
delete(s.sseClients, ch)
|
||||
s.sseMu.Unlock()
|
||||
}()
|
||||
|
||||
flusher.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
return
|
||||
case page := <-ch:
|
||||
data, _ := json.Marshal(map[string]string{"page": page})
|
||||
fmt.Fprintf(w, "event: page_changed\ndata: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebServer) handleActivatePage(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Page string `json:"page"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Page == "" {
|
||||
http.Error(w, "page is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
allPMs := append([]*render.PageManager{s.pm}, s.extraPMs...)
|
||||
s.mu.RUnlock()
|
||||
|
||||
for _, pm := range allPMs {
|
||||
if pm == nil {
|
||||
continue
|
||||
}
|
||||
if err := pm.ActivatePage(req.Page); err != nil {
|
||||
slog.Warn("activate page", "page", req.Page, "error", err)
|
||||
} else {
|
||||
pm.StopPeriodicKeys()
|
||||
pm.StartPeriodicKeys()
|
||||
}
|
||||
}
|
||||
|
||||
s.BroadcastPageChange(req.Page)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "page": req.Page})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(s.cfg)
|
||||
}
|
||||
|
||||
func (s *WebServer) handlePutConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var newCfg config.Config
|
||||
if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := newCfg.Validate(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid config: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
path := config.ConfigPath(s.configPath)
|
||||
|
||||
s.mu.Lock()
|
||||
oldCfg := s.cfg
|
||||
s.cfg = &newCfg
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.cfg.Save(s.configPath); err != nil {
|
||||
s.mu.Lock()
|
||||
s.cfg = oldCfg
|
||||
s.mu.Unlock()
|
||||
slog.Error("save config", "error", err)
|
||||
http.Error(w, fmt.Sprintf("save failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
backupOldConfig(path)
|
||||
gcImages(&newCfg)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "saved"})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleDownloadConfig(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
data, err := json.MarshalIndent(s.cfg, "", " ")
|
||||
if err != nil {
|
||||
http.Error(w, "serialization error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("streamdeck-config-%s.json", time.Now().Format("2006-01-02"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleRestoreConfig(w http.ResponseWriter, r *http.Request) {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
var newCfg config.Config
|
||||
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
http.Error(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("file field 'file' required: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if err := json.NewDecoder(file).Decode(&newCfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := newCfg.Validate(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid config: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
path := config.ConfigPath(s.configPath)
|
||||
backupOldConfig(path)
|
||||
|
||||
s.mu.Lock()
|
||||
s.cfg = &newCfg
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.cfg.Save(s.configPath); err != nil {
|
||||
http.Error(w, fmt.Sprintf("save failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
gcImages(&newCfg)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "restored"})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleGetPages(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
type pageInfo struct {
|
||||
Name string `json:"name"`
|
||||
Keys []config.KeyConfig `json:"keys"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
DynamicKeys *config.DynamicKeyGen `json:"dynamic_keys,omitempty"`
|
||||
Background string `json:"background,omitempty"`
|
||||
EffectiveKeys []config.KeyConfig `json:"effective_keys,omitempty"`
|
||||
}
|
||||
|
||||
pages := make([]pageInfo, 0, len(s.cfg.Pages))
|
||||
for _, p := range s.cfg.Pages {
|
||||
pi := pageInfo{
|
||||
Name: p.Name,
|
||||
Keys: p.Keys,
|
||||
Icon: p.Icon,
|
||||
DynamicKeys: p.DynamicKeys,
|
||||
Background: p.Background,
|
||||
}
|
||||
if p.DynamicKeys != nil && s.pm != nil {
|
||||
pi.EffectiveKeys = s.pm.GetEffectiveKeys(p.Name)
|
||||
}
|
||||
pages = append(pages, pi)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(pages)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleRender(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
keySize := 72
|
||||
if s := q.Get("key_size"); s != "" {
|
||||
fmt.Sscanf(s, "%d", &keySize)
|
||||
}
|
||||
|
||||
kc := &config.KeyConfig{}
|
||||
|
||||
if page := q.Get("page"); page != "" {
|
||||
key := q.Get("key")
|
||||
s.mu.RLock()
|
||||
for _, p := range s.cfg.Pages {
|
||||
if p.Name == page {
|
||||
for _, k := range p.Keys {
|
||||
if fmt.Sprintf("%d", k.Index) == key {
|
||||
kc = &k
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
|
||||
if icon := q.Get("icon"); icon != "" {
|
||||
kc.Icon = icon
|
||||
if s := q.Get("icon_scale"); s != "" {
|
||||
v := 0.0
|
||||
fmt.Sscanf(s, "%f", &v)
|
||||
kc.IconScale = &v
|
||||
}
|
||||
}
|
||||
if label := q.Get("label"); label != "" {
|
||||
kc.Label = label
|
||||
}
|
||||
if fs := q.Get("font_size"); fs != "" {
|
||||
v := 0.0
|
||||
fmt.Sscanf(fs, "%f", &v)
|
||||
kc.FontSize = &v
|
||||
}
|
||||
if bg := q.Get("background"); bg != "" {
|
||||
kc.Background = bg
|
||||
}
|
||||
|
||||
img := render.RenderKeyToImage(kc, keySize, s.cfg.ShowLabelBackground)
|
||||
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
png.Encode(w, img)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleDisplayOutputs(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
pm := s.pm
|
||||
s.mu.RUnlock()
|
||||
|
||||
if pm == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte("{}"))
|
||||
return
|
||||
}
|
||||
|
||||
outputs := pm.GetDisplayOutputs()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(outputs)
|
||||
}
|
||||
|
||||
func backupOldConfig(path string) {
|
||||
backupDir := filepath.Join(filepath.Dir(path), "backups")
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
slog.Warn("backup: create dir", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("config.%s.json", time.Now().Format("2006-01-02T15-04-05"))
|
||||
dst := filepath.Join(backupDir, name)
|
||||
if err := os.WriteFile(dst, data, 0644); err != nil {
|
||||
slog.Warn("backup: write", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(backupDir)
|
||||
if len(entries) > 10 {
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
fi, _ := entries[i].Info()
|
||||
fj, _ := entries[j].Info()
|
||||
return fi.ModTime().Before(fj.ModTime())
|
||||
})
|
||||
for _, e := range entries[:len(entries)-10] {
|
||||
os.Remove(filepath.Join(backupDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectIcons(cfg *config.Config, used map[string]bool) {
|
||||
for _, p := range cfg.Pages {
|
||||
for _, k := range p.Keys {
|
||||
if k.Icon != "" {
|
||||
used[k.Icon] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func gcImages(cfg *config.Config) {
|
||||
imgDir := filepath.Join(config.ConfigDir(), "images")
|
||||
entries, err := os.ReadDir(imgDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
used := make(map[string]bool)
|
||||
collectIcons(cfg, used)
|
||||
|
||||
backupDir := filepath.Join(config.ConfigDir(), "backups")
|
||||
backupEntries, _ := os.ReadDir(backupDir)
|
||||
for _, be := range backupEntries {
|
||||
if be.IsDir() {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(backupDir, be.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var backupCfg config.Config
|
||||
if err := json.Unmarshal(data, &backupCfg); err != nil {
|
||||
continue
|
||||
}
|
||||
collectIcons(&backupCfg, used)
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(imgDir, e.Name())
|
||||
if !used[path] {
|
||||
if err := os.Remove(path); err != nil {
|
||||
slog.Warn("gc: remove orphan image", "path", path, "error", err)
|
||||
} else {
|
||||
slog.Debug("gc: removed orphan image", "path", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebServer) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
path := config.ConfigPath(s.configPath)
|
||||
backupDir := filepath.Join(filepath.Dir(path), "backups")
|
||||
|
||||
entries, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode([]map[string]interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
type backupInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
var backups []backupInfo
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasPrefix(e.Name(), "config.") || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
backups = append(backups, backupInfo{
|
||||
Name: e.Name(),
|
||||
Size: fi.Size(),
|
||||
Time: fi.ModTime().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(backups, func(i, j int) bool {
|
||||
return backups[i].Time > backups[j].Time
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(backups)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleGetBackup(w http.ResponseWriter, r *http.Request) {
|
||||
filename := r.PathValue("filename")
|
||||
if filename == "" || strings.Contains(filename, "/") || strings.Contains(filename, "..") {
|
||||
http.Error(w, "invalid filename", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
path := config.ConfigPath(s.configPath)
|
||||
backupDir := filepath.Join(filepath.Dir(path), "backups")
|
||||
fullPath := filepath.Join(backupDir, filename)
|
||||
|
||||
if !strings.HasPrefix(fullPath, backupDir) {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
http.Error(w, "backup not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleGetModels(w http.ResponseWriter, r *http.Request) {
|
||||
type modelInfo struct {
|
||||
PID uint16 `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
KeysX int `json:"keys_x"`
|
||||
KeysY int `json:"keys_y"`
|
||||
KeySize int `json:"key_size"`
|
||||
}
|
||||
|
||||
models := make([]modelInfo, 0, len(deck.KnownDecks))
|
||||
for _, d := range deck.KnownDecks {
|
||||
models = append(models, modelInfo{
|
||||
PID: d.PID,
|
||||
Name: d.Name,
|
||||
KeysX: d.KeysX,
|
||||
KeysY: d.KeysY,
|
||||
KeySize: d.KeySize,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleGetDecks(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
infos := make([]deck.DeckInfo, 0, len(s.decks))
|
||||
for _, d := range s.decks {
|
||||
infos = append(infos, d.DeckInfo())
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(infos)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleGetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"config_version": s.cfg.Version,
|
||||
"default_page": s.cfg.DefaultPage,
|
||||
"page_count": len(s.cfg.Pages),
|
||||
"device_count": len(s.cfg.Devices),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
http.Error(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, handler, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("file field 'file' required: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
uploadDir := filepath.Join(config.ConfigDir(), "images")
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
http.Error(w, fmt.Sprintf("create upload dir: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ext := filepath.Ext(handler.Filename)
|
||||
if ext == "" {
|
||||
ext = ".png"
|
||||
}
|
||||
filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
|
||||
savePath := filepath.Join(uploadDir, filename)
|
||||
|
||||
dst, err := os.Create(savePath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("create file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
http.Error(w, fmt.Sprintf("write file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"path": savePath})
|
||||
}
|
||||
Reference in New Issue
Block a user