First build

This commit is contained in:
olcxja 2026-09-25 17:09:38 +02:00
commit fd773f5832
954 changed files with 305115 additions and 2 deletions

BIN
renderer/Nunito-Black.ttf Normal file

Binary file not shown.

BIN
renderer/Nunito-Regular.ttf Normal file

Binary file not shown.

766
renderer/app.js Normal file
View file

@ -0,0 +1,766 @@
'use strict'
const qs = s => document.querySelector(s)
const qsa = s => [...document.querySelectorAll(s)]
const runningCards = new Map()
const state = {
lang: 'en-us',
texts: {},
langs: [],
settings: null,
wines: [],
prefixes: { default: '', prefixes: [] },
games: [],
currentGame: null,
currentView: 'library'
}
function t(key, ...args) {
let text = state.texts[key] ?? key
args.forEach((v, i) => {
text = String(text).split(`{${i}}`).join(String(v))
})
return text
}
const ICONS = {
library: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5V3H5v2"/><path d="M3 5h18v6a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4z"/><path d="M12 15v4M8 19h8"/></svg>',
prefixes: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/></svg>',
wines: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></svg>',
settings: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.5 2 1.5a7 7 0 0 0 0 2.4l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z"/></svg>',
plus: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
config: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/></svg>',
folder: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
trash: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 11v6M14 11v6"/></svg>',
terminal: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l6-5-6-5M12 19h8"/></svg>',
logs: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 3h9l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM9 12h6M9 16h6"/></svg>',
star: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l2.7 5.8 6.3.8-4.6 4.3 1.2 6.1-5.6-3.1-5.6 3.1 1.2-6.1L3 9.6l6.3-.8z"/></svg>',
download: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 9l6 6 6-6"/><path d="M4 21h16"/></svg>',
chevronDown: () => '<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'
}
function applyI18n() {
qsa('[data-t]').forEach(el => {
el.textContent = t(el.dataset.t)
})
qsa('[data-ph]').forEach(el => {
el.placeholder = t(el.dataset.ph)
})
qsa('custom-select').forEach(el => {
if (el.refresh) el.refresh()
})
document.title = t('app.title')
qs('#loadingstatus').textContent = t('common.loading')
}
function setButtonIcons() {
const map = {
'nav-library': ICONS.library(),
'nav-prefixes': ICONS.prefixes(),
'nav-wines': ICONS.wines(),
'nav-settings': ICONS.settings(),
'btn-import-side': ICONS.plus()
}
for (const [id, svg] of Object.entries(map)) {
const el = document.getElementById(id)
const btn = el && el.querySelector('button')
if (btn) btn.innerHTML = svg
}
}
function toast(message, kind) {
const el = document.createElement('div')
el.className = 'toast' + (kind ? ' ' + kind : '')
el.textContent = message
qs('#toasts').appendChild(el)
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')))
setTimeout(() => {
el.classList.add('hide')
setTimeout(() => el.remove(), 300)
}, 4000)
}
function showModal(html) {
const overlay = qs('#modal-overlay')
qs('#modal').innerHTML = html
overlay.style.display = 'flex'
requestAnimationFrame(() => requestAnimationFrame(() => overlay.classList.add('show')))
return qs('#modal')
}
function closeModal() {
const overlay = qs('#modal-overlay')
const modal = qs('#modal')
if (overlay.style.display === 'none') return
modal.classList.add('closing')
overlay.classList.remove('show')
setTimeout(() => {
overlay.style.display = 'none'
modal.classList.remove('closing')
modal.innerHTML = ''
}, 160)
}
function confirmModal(message, okLabel) {
return new Promise(resolve => {
const modal = showModal(`
<h2>${t('dialog.confirm')}</h2>
<p>${message}</p>
<div class="modal-actions">
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
<button class="button primary" id="modal-ok">${okLabel || t('dialog.ok')}</button>
</div>
`)
modal.querySelector('#modal-cancel').onclick = () => {
closeModal()
resolve(false)
}
modal.querySelector('#modal-ok').onclick = () => {
closeModal()
resolve(true)
}
})
}
class CustomSelect {
constructor(host, options, value, onChange) {
this.host = host
this.options = options
this.value = value
this.onChange = onChange
this.render()
if (!this.host.dataset.wired) {
this.wire()
this.host.dataset.wired = '1'
}
}
render() {
const selected = this.options.find(o => o.value === this.value)
this.host.classList.add('custom-select')
this.host.dataset.value = this.value ?? ''
this.host.innerHTML = `
<div class="custom-select-value">
<span></span>
${ICONS.chevronDown()}
</div>
<div class="custom-select-options"></div>
`
this.host.querySelector('.custom-select-value span').textContent = selected ? selected.label : ''
const box = this.host.querySelector('.custom-select-options')
for (const opt of this.options) {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'custom-select-option' + (opt.value === this.value ? ' selected' : '')
btn.textContent = opt.label
btn.onclick = e => {
e.stopPropagation()
this.setValue(opt.value)
this.close()
}
box.appendChild(btn)
}
this.host.refresh = () => {
const sel = this.options.find(o => o.value === this.value)
this.host.querySelector('.custom-select-value span').textContent = sel ? sel.label : ''
const btns = box.querySelectorAll('.custom-select-option')
this.options.forEach((opt, i) => {
btns[i].textContent = opt.label
btns[i].classList.toggle('selected', opt.value === this.value)
})
}
}
wire() {
this.host.onclick = e => {
if (e.target.closest('.custom-select-option')) return
e.stopPropagation()
this.toggle()
}
document.addEventListener('click', () => this.close())
}
toggle() {
const wasOpen = this.host.classList.contains('open')
CustomSelect.closeAll()
if (!wasOpen) this.host.classList.add('open')
}
close() {
this.host.classList.remove('open')
}
setValue(v) {
this.value = v
this.host.dataset.value = v ?? ''
this.render()
if (this.onChange) this.onChange(v)
}
update(options, value) {
this.options = options
if (value !== undefined) this.value = value
this.host.dataset.value = this.value ?? ''
this.render()
}
static closeAll() {
qsa('.custom-select.open').forEach(el => el.classList.remove('open'))
}
}
const selects = {}
const NAV_PARENT = {
gameconfig: 'library'
}
function showView(name) {
state.currentView = name
qsa('.view').forEach(v => v.classList.remove('active'))
const view = qs('#view-' + name)
if (view) view.classList.add('active')
const navName = NAV_PARENT[name] || name
qsa('sidebarelement').forEach(el => el.classList.remove('active'))
const nav = qs('#nav-' + navName)
if (nav) nav.classList.add('active')
if (name === 'library') renderLibrary()
if (name === 'prefixes') renderPrefixes()
if (name === 'wines') renderWines()
if (name === 'settings') renderSettings()
}
async function loadAll() {
const [langs, settings, wines, prefixes, games] = await Promise.all([
window.plgl.i18nList(),
window.plgl.settingsGet(),
window.plgl.winesList(),
window.plgl.prefixesList(),
window.plgl.gamesList()
])
state.langs = langs
state.settings = settings
state.wines = wines
state.prefixes = prefixes
state.games = games
state.lang = settings.language || 'en-us'
await loadLangTexts(state.lang)
}
async function loadLangTexts(code) {
const lang = await window.plgl.i18nGet(code)
if (lang && lang.texts) {
state.texts = lang.texts
state.lang = code
}
}
function wineLabel(id) {
const w = state.wines.find(w => w.id === id)
return w ? w.label : (id === 'system' ? t('wines.system') : id)
}
function renderLibrary() {
const list = qs('#game-list')
list.innerHTML = ''
runningCards.clear()
qs('#library-empty').style.display = state.games.length ? 'none' : 'flex'
state.games.forEach((game, idx) => {
const card = document.createElement('div')
card.className = 'game-card'
card.style.animationDelay = `${idx * 0.04}s`
const portable = game.mode === 'copy' || game.mode === 'move'
const last = game.lastPlayed ? new Date(game.lastPlayed).toLocaleDateString() : t('library.never')
card.innerHTML = `
<h3 title=""></h3>
<div class="meta">
<span class="badge ${portable ? 'primary' : ''}">${portable ? t('library.portable') : t('library.external')}</span>
<span class="badge">${wineLabel(game.wine)}</span>
</div>
<div class="meta">${t('library.lastPlayed', last)}</div>
<div class="actions">
<button class="button primary small" data-act="play">${t('library.play')}</button>
<button class="button small danger" data-act="kill" style="display:none">${t('library.stop')}</button>
<button class="button small" data-act="config" title="${t('library.config')}">${ICONS.config()}</button>
<button class="button small" data-act="folder" title="${t('library.openFolder')}">${ICONS.folder()}</button>
<button class="button small" data-act="logs" title="${t('library.viewLogs')}">${ICONS.logs()}</button>
<button class="button small danger" data-act="remove" title="${t('library.remove')}">${ICONS.trash()}</button>
</div>
`
card.querySelector('h3').textContent = game.name
const playBtn = card.querySelector('[data-act=play]')
const killBtn = card.querySelector('[data-act=kill]')
const lastMeta = card.querySelectorAll('.meta')[1]
const applyRunning = isRunning => {
playBtn.style.display = isRunning ? 'none' : ''
killBtn.style.display = isRunning ? '' : 'none'
}
runningCards.set(game.id, { applyRunning, lastMeta, game })
window.plgl.gamesIsRunning(game.id).then(applyRunning)
playBtn.onclick = () => playGame(game)
killBtn.onclick = async () => {
await window.plgl.gamesKill(game.id)
applyRunning(false)
}
card.querySelector('[data-act=config]').onclick = () => openGameConfig(game.id)
card.querySelector('[data-act=folder]').onclick = () => window.plgl.shellOpenPath(game.workdir)
card.querySelector('[data-act=logs]').onclick = async () => {
const logs = await window.plgl.gamesGetLogs(game.id)
if (logs.length) window.plgl.shellShowItemInFolder(logs[logs.length - 1])
else toast(t('library.viewLogs'))
}
card.querySelector('[data-act=remove]').onclick = async () => {
const note = game.mode === 'copy' || game.mode === 'move' ? t('library.removeFilesNote') : ''
const ok = await confirmModal(t('library.removeConfirm', game.name, note), t('library.remove'))
if (!ok) return
await window.plgl.gamesRemove(game.id)
toast(t('toast.gameRemoved', game.name), 'success')
state.games = await window.plgl.gamesList()
renderLibrary()
}
list.appendChild(card)
})
}
async function playGame(game) {
const res = await window.plgl.gamesRun(game.id)
if (res.ok) {
const entry = runningCards.get(game.id)
if (entry) entry.applyRunning(true)
toast(t('toast.started', game.name), 'success')
} else if (res.error === 'already-running') {
const entry = runningCards.get(game.id)
if (entry) entry.applyRunning(true)
toast(t('library.running'), 'error')
} else {
toast(t('toast.startFailed', game.name, res.error || ''), 'error')
}
}
async function refreshLibraryData() {
state.games = await window.plgl.gamesList()
const runningList = await window.plgl.gamesListRunning()
const runningSet = new Set(runningList)
for (const [id, entry] of runningCards) {
entry.applyRunning(runningSet.has(id))
const fresh = state.games.find(g => g.id === id)
if (fresh && entry.lastMeta) {
const last = fresh.lastPlayed ? new Date(fresh.lastPlayed).toLocaleDateString() : t('library.never')
entry.lastMeta.textContent = t('library.lastPlayed', last)
}
}
}
function wireRealtime() {
window.plgl.onGamesChanged(() => {
if (state.currentView === 'library') refreshLibraryData()
})
}
async function startImportFlow() {
const modal = showModal(`
<h2>${t('library.import')}</h2>
<p>${t('import.question')}</p>
<div class="mode-buttons">
<button class="button" data-mode="copy">${t('import.mode.copy')}</button>
<button class="button" data-mode="move">${t('import.mode.move')}</button>
<button class="button" data-mode="leave">${t('import.mode.leave')}</button>
</div>
<div class="modal-actions">
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
</div>
`)
modal.querySelector('#modal-cancel').onclick = closeModal
modal.querySelectorAll('[data-mode]').forEach(btn => {
btn.onclick = async () => {
const mode = btn.dataset.mode
closeModal()
const srcDir = await window.plgl.gamesPickFolder()
if (!srcDir) return
await doImport(srcDir, mode)
}
})
}
async function doImport(srcDir, mode) {
const name = srcDir.split('/').filter(Boolean).pop()
const modal = showModal(`
<h2>${t('import.progress', name)}</h2>
<div class="progress-row"><progress id="import-progress"></progress></div>
<p id="import-status"></p>
`)
let count = 0
window.plgl.onImportProgress(() => {
count++
const status = modal.querySelector('#import-status')
if (status) status.textContent = t('import.fileDone', count)
})
try {
const game = await window.plgl.gamesImport(srcDir, mode)
closeModal()
toast(t('toast.importDone', game.name), 'success')
state.games = await window.plgl.gamesList()
renderLibrary()
await openGameConfig(game.id)
} catch (err) {
closeModal()
toast(t('toast.importFailed') + ': ' + (err.message || err), 'error')
}
}
function renderPrefixes() {
const list = qs('#prefix-list')
list.innerHTML = ''
const defs = state.prefixes
if (!defs.prefixes.length) {
const empty = document.createElement('p')
empty.className = 'subtitle'
empty.textContent = t('prefixes.empty')
list.appendChild(empty)
return
}
for (const p of defs.prefixes) {
const locked = !!p.locked || p.name === 'Main'
const row = document.createElement('div')
row.className = 'list-row'
row.innerHTML = `
<div class="grow">
<div class="title"></div>
</div>
${p.name === defs.default ? `<span class="badge primary">${t('prefixes.default')}</span>` : ''}
<div class="actions">
<button class="button small" data-act="default" ${p.name === defs.default ? 'style="display:none"' : ''}></button>
<button class="button small danger" data-act="delete" ${locked ? 'style="display:none"' : ''}></button>
</div>
`
row.querySelector('.title').textContent = p.name
const defBtn = row.querySelector('[data-act=default]')
defBtn.textContent = t('prefixes.makeDefault')
defBtn.onclick = async () => {
const res = await window.plgl.prefixesSetDefault(p.name)
if (res && res.ok !== false) toast(t('toast.prefixDefault', p.name), 'success')
state.prefixes = await window.plgl.prefixesList()
renderPrefixes()
}
const delBtn = row.querySelector('[data-act=delete]')
delBtn.innerHTML = `${ICONS.trash()}<span>${t('prefixes.delete')}</span>`
delBtn.onclick = async () => {
const ok = await confirmModal(t('prefixes.deleteConfirm', p.name), t('prefixes.delete'))
if (!ok) return
const res = await window.plgl.prefixesDelete(p.name)
if (res.ok) toast(t('toast.prefixDeleted', p.name), 'success')
else if (res.error === 'isMain') toast(t('prefixes.mainLocked'), 'error')
else toast(t('toast.prefixDeleteFailed', p.name), 'error')
state.prefixes = await window.plgl.prefixesList()
renderPrefixes()
}
list.appendChild(row)
}
}
function renderWines() {
const list = qs('#wine-list')
list.innerHTML = ''
const defaultWine = (state.settings && state.settings.defaultWine) || 'system'
if (!state.wines.length) {
const empty = document.createElement('p')
empty.className = 'subtitle'
empty.textContent = t('wines.empty')
list.appendChild(empty)
}
for (const w of state.wines) {
const isDefault = w.id === defaultWine
const row = document.createElement('div')
row.className = 'list-row'
const typeBadge = w.type === 'proton' ? t('wines.type.proton') : (w.type === 'system' ? t('wines.type.system') : t('wines.type.wine'))
row.innerHTML = `
<div class="grow">
<div class="title"></div>
<div class="sub" title=""></div>
</div>
${isDefault ? `<span class="badge primary">${t('wines.default')}</span>` : ''}
<span class="badge">${typeBadge}</span>
${w.version ? `<span class="badge">${w.version}</span>` : ''}
<div class="actions">
<button class="button small" data-act="default" ${isDefault ? 'style="display:none"' : ''}></button>
${w.id !== 'system' ? `<button class="button small danger" data-act="delete"></button>` : ''}
</div>
`
row.querySelector('.title').textContent = w.label
row.querySelector('.sub').textContent = w.path || t('wines.systemHint')
row.querySelector('.sub').title = row.querySelector('.sub').textContent
const defBtn = row.querySelector('[data-act=default]')
defBtn.textContent = t('wines.makeDefault')
defBtn.onclick = async () => {
const res = await window.plgl.winesSetDefault(w.id)
if (res && res.ok) {
state.settings = await window.plgl.settingsGet()
toast(t('wines.defaultSet', w.label), 'success')
}
renderWines()
}
const delBtn = row.querySelector('[data-act=delete]')
if (delBtn) {
delBtn.innerHTML = `${ICONS.trash()}<span>${t('wines.delete')}</span>`
delBtn.onclick = async () => {
const ok = await confirmModal(t('wines.deleteConfirm', w.label), t('wines.delete'))
if (!ok) return
const res = await window.plgl.winesDelete(w.id)
if (res.ok) {
toast(t('wines.deleted', w.label), 'success')
state.wines = await window.plgl.winesList()
state.settings = await window.plgl.settingsGet()
} else if (res.error === 'isSystem') {
toast(t('wines.systemLocked'), 'error')
}
renderWines()
}
}
list.appendChild(row)
}
}
function renderSettings() {
if (!selects['set-language']) {
selects['set-language'] = new CustomSelect(qs('#set-language'), [], state.lang)
selects['set-wine'] = new CustomSelect(qs('#set-wine'), [], 'system')
selects['set-prefix'] = new CustomSelect(qs('#set-prefix'), [], '')
}
selects['set-language'].update(state.langs.map(l => ({ value: l.code, label: l.nameSelf })), state.lang)
selects['set-wine'].update(state.wines.map(w => ({ value: w.id, label: w.label })), state.settings.defaultWine || 'system')
selects['set-prefix'].update(state.prefixes.prefixes.map(p => ({ value: p.name, label: p.name })), state.prefixes.default)
}
async function saveSettings() {
state.settings = await window.plgl.settingsSet({
language: selects['set-language'].value,
defaultWine: selects['set-wine'].value
})
await window.plgl.prefixesSetDefault(selects['set-prefix'].value || state.prefixes.default)
state.prefixes = await window.plgl.prefixesList()
const lang = selects['set-language'].value
if (lang !== state.lang) {
await loadLangTexts(lang)
applyI18n()
}
toast(t('settings.saved'), 'success')
if (state.currentView === 'settings') renderSettings()
}
function renderWineOptions(selectId, selected) {
const defaultWine = (state.settings && state.settings.defaultWine) || 'system'
const defLabel = wineLabel(defaultWine)
const opts = [
{ value: 'default', label: t('config.wineDefault', defLabel) },
...state.wines.map(w => ({ value: w.id, label: w.label }))
]
const value = !selected || selected === 'default' ? 'default' : selected
if (!selects[selectId]) selects[selectId] = new CustomSelect(qs('#' + selectId), opts, value)
else selects[selectId].update(opts, value)
}
function renderPrefixOptions(selectId, selected) {
const defs = state.prefixes
const opts = [
{ value: '', label: t('config.prefixDefault', defs.default) },
...defs.prefixes.filter(p => p.name !== defs.default).map(p => ({ value: p.name, label: p.name }))
]
if (!selects[selectId]) selects[selectId] = new CustomSelect(qs('#' + selectId), opts, selected || '')
else selects[selectId].update(opts, selected || '')
}
async function openGameConfig(id) {
const game = state.games.find(g => g.id === id)
if (!game) return
state.currentGame = JSON.parse(JSON.stringify(game))
showView('gameconfig')
qs('#gc-title').textContent = game.name
qs('#gc-name').value = game.name
qs('#gc-exe').value = game.exePath
qs('#gc-workdir').value = game.workdir
qs('#gc-args').value = game.args || ''
qs('#gc-env').value = Object.entries(game.env || {}).map(([k, v]) => `${k}=${v}`).join('\n')
renderWineOptions('gc-wine', game.wine)
renderPrefixOptions('gc-prefix', game.prefix)
renderOverrides(game.dllOverrides || [])
renderTools()
}
function renderOverrides(overrides) {
const box = qs('#gc-overrides')
box.innerHTML = ''
for (const o of overrides) {
box.appendChild(buildOverrideRow(o))
}
}
const OVERRIDE_OPTIONS = [
{ value: 'native', label: 'native' },
{ value: 'builtin', label: 'builtin' },
{ value: 'native,builtin', label: 'native,builtin' },
{ value: 'builtin,native', label: 'builtin,native' },
{ value: '', label: 'disabled' }
]
function buildOverrideRow(o) {
const row = document.createElement('div')
row.className = 'override-row'
row.innerHTML = `
<input class="dll" placeholder="dinput8">
<button class="button small danger" data-act="del">${ICONS.trash()}</button>
`
row.querySelector('.dll').value = o.dll || ''
const selectHost = document.createElement('span')
new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting || 'native')
row.insertBefore(selectHost, row.querySelector('[data-act=del]'))
row.querySelector('[data-act=del]').onclick = () => row.remove()
return row
}
function addOverrideRow() {
qs('#gc-overrides').appendChild(buildOverrideRow({ dll: '', setting: 'native' }))
const rows = qsa('#gc-overrides .override-row')
rows[rows.length - 1].querySelector('.dll').focus()
}
function readOverrides() {
return qsa('#gc-overrides .override-row').map(row => ({
dll: row.querySelector('.dll').value.trim(),
setting: row.querySelector('.custom-select').dataset.value ?? 'native'
})).filter(o => o.dll)
}
async function saveGameConfig() {
const game = state.currentGame
if (!game) return
const env = {}
qs('#gc-env').value.split('\n').forEach(line => {
const idx = line.indexOf('=')
if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
})
const patch = {
name: qs('#gc-name').value.trim() || game.name,
exePath: qs('#gc-exe').value.trim(),
workdir: qs('#gc-workdir').value.trim(),
args: qs('#gc-args').value,
wine: selects['gc-wine'].value,
prefix: selects['gc-prefix'].value,
env,
dllOverrides: readOverrides()
}
await window.plgl.gamesUpdate(game.id, patch)
state.games = await window.plgl.gamesList()
toast(t('config.saved'), 'success')
if (state.currentView === 'gameconfig') {
state.currentGame = { ...state.currentGame, ...patch }
qs('#gc-title').textContent = patch.name
}
}
function renderTools() {
const grid = qs('#gc-tools')
grid.innerHTML = ''
const tools = ['winecfg', 'regedit', 'taskmgr', 'cmd', 'control', 'eject']
for (const tool of tools) {
const btn = document.createElement('button')
btn.className = 'button small'
btn.innerHTML = `${ICONS.terminal()}<span>${tool}</span>`
btn.onclick = async () => {
const game = state.currentGame
const res = await window.plgl.gamesRunTool(game.id, tool, '')
if (res.ok) toast(t('toast.started', tool))
else toast(t('toast.startFailed', tool, res.error || ''), 'error')
}
grid.appendChild(btn)
}
}
async function runCustomTool() {
const input = qs('#gc-custom-tool').value.trim()
if (!input) return
const [tool, ...rest] = input.split(/\s+/)
const game = state.currentGame
const res = await window.plgl.gamesRunTool(game.id, tool, rest.join(' '))
if (res.ok) toast(t('toast.started', tool))
else toast(t('toast.startFailed', tool, res.error || ''), 'error')
}
function wireEvents() {
qsa('[data-nav]').forEach(btn => {
btn.addEventListener('click', () => showView(btn.dataset.nav))
})
qs('#btn-import-side').addEventListener('click', startImportFlow)
qs('#btn-import-top').addEventListener('click', startImportFlow)
qs('#btn-import-empty').addEventListener('click', startImportFlow)
qs('#btn-prefix-create').addEventListener('click', async () => {
const input = qs('#prefix-name-input')
const name = input.value.trim()
if (!name) return
const res = await window.plgl.prefixesCreate(name)
if (!res.ok) {
toast(t(res.error === 'alreadyExists' ? 'prefixes.exists' : 'prefixes.invalidName'), 'error')
return
}
input.value = ''
toast(t('toast.prefixCreated', res.name), 'success')
state.prefixes = await window.plgl.prefixesList()
renderPrefixes()
})
qs('#btn-wines-rescan').addEventListener('click', async () => {
await window.plgl.winesRefresh()
state.wines = await window.plgl.winesList()
renderWines()
})
qs('#btn-wines-import').addEventListener('click', async () => {
const srcDir = await window.plgl.dialogPickFile({ properties: ['openDirectory'], title: t('wines.importPick') })
if (!srcDir) return
const modal = showModal(`
<h2>${t('wines.importing')}</h2>
<div class="progress-row"><progress></progress></div>
`)
const res = await window.plgl.winesImport(srcDir)
closeModal()
if (res.ok) {
toast(t('wines.imported', res.path), 'success')
state.wines = await window.plgl.winesList()
} else {
toast(t('toast.importFailed') + ': ' + (res.error || ''), 'error')
}
renderWines()
})
qs('#btn-settings-save').addEventListener('click', saveSettings)
qs('#btn-open-files').addEventListener('click', () => window.plgl.shellOpenPath(window.plgl.FILES_DIR))
qs('#btn-gc-back').addEventListener('click', () => showView('library'))
qs('#btn-gc-save').addEventListener('click', saveGameConfig)
qs('#gc-override-add').addEventListener('click', addOverrideRow)
qs('#gc-exe-browse').addEventListener('click', async () => {
const p = await window.plgl.dialogPickFile({ properties: ['openFile'], defaultPath: qs('#gc-workdir').value || window.plgl.FILES_DIR })
if (p) qs('#gc-exe').value = p
})
qs('#gc-workdir-browse').addEventListener('click', async () => {
const p = await window.plgl.dialogPickFile({ properties: ['openDirectory'], defaultPath: qs('#gc-exe').value || window.plgl.FILES_DIR })
if (p) qs('#gc-workdir').value = p
})
qs('#gc-tool-run').addEventListener('click', runCustomTool)
qs('#modal-overlay').addEventListener('click', e => {
if (e.target === qs('#modal-overlay')) closeModal()
})
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal()
})
}
async function boot() {
setButtonIcons()
await loadAll()
applyI18n()
wireEvents()
wireRealtime()
qs('#loading').style.display = 'none'
qs('#main').style.display = 'flex'
showView('library')
}
boot()

175
renderer/index.html Normal file
View file

@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
<title>PLGL</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<loading id="loading">
<div class="loading-inner">
<div class="loading-logo">PLGL</div>
<p id="loadingstatus"></p>
</div>
</loading>
<main id="main" style="display:none">
<sidebar>
<div class="sidebar-top">
<sidebarelement id="nav-library" class="active">
<indicator></indicator>
<button class="icon-button" data-nav="library"></button>
</sidebarelement>
<sidebarelement id="nav-prefixes">
<indicator></indicator>
<button class="icon-button" data-nav="prefixes"></button>
</sidebarelement>
<sidebarelement id="nav-wines">
<indicator></indicator>
<button class="icon-button" data-nav="wines"></button>
</sidebarelement>
<sidebarelement id="nav-settings">
<indicator></indicator>
<button class="icon-button" data-nav="settings"></button>
</sidebarelement>
</div>
<div class="sidebar-bottom">
<hr>
<sidebarelement id="nav-import">
<indicator></indicator>
<button class="icon-button" id="btn-import-side"></button>
</sidebarelement>
</div>
</sidebar>
<content>
<section id="view-library" class="view active">
<div class="view-header">
<div>
<h1 data-t="library.title"></h1>
<p class="subtitle" data-t="app.subtitle"></p>
</div>
<button class="button primary" id="btn-import-top" data-t="library.import"></button>
</div>
<div id="game-list" class="game-list"></div>
<div id="library-empty" class="empty-state" style="display:none">
<p class="empty-title" data-t="library.empty"></p>
<p class="empty-hint" data-t="library.emptyHint"></p>
<button class="button primary" id="btn-import-empty" data-t="library.import"></button>
</div>
</section>
<section id="view-prefixes" class="view">
<div class="view-header">
<div>
<h1 data-t="prefixes.title"></h1>
<p class="subtitle" data-t="prefixes.subtitle"></p>
</div>
</div>
<div class="toolbar">
<input id="prefix-name-input" data-ph="prefixes.namePlaceholder" type="text">
<button class="button primary" id="btn-prefix-create" data-t="prefixes.create"></button>
</div>
<div id="prefix-list" class="list"></div>
</section>
<section id="view-wines" class="view">
<div class="view-header">
<div>
<h1 data-t="wines.title"></h1>
<p class="subtitle" data-t="wines.subtitle"></p>
</div>
<div class="header-actions">
<button class="button" id="btn-wines-import" data-t="wines.import"></button>
<button class="button" id="btn-wines-rescan" data-t="wines.rescan"></button>
</div>
</div>
<div id="wine-list" class="list"></div>
</section>
<section id="view-settings" class="view">
<div class="view-header">
<div>
<h1 data-t="settings.title"></h1>
</div>
</div>
<div class="form-card">
<label class="field-label" data-t="settings.language"></label>
<span id="set-language"></span>
<label class="field-label" data-t="settings.defaultWine"></label>
<span id="set-wine"></span>
<label class="field-label" data-t="settings.defaultPrefix"></label>
<span id="set-prefix"></span>
<div class="form-actions">
<button class="button primary" id="btn-settings-save" data-t="settings.save"></button>
<button class="button" id="btn-open-files" data-t="settings.openFiles"></button>
</div>
</div>
</section>
<section id="view-gameconfig" class="view">
<div class="view-header">
<div>
<h1 id="gc-title"></h1>
<p class="subtitle" data-t="config.title"></p>
</div>
<button class="button" id="btn-gc-back" data-t="config.back"></button>
</div>
<div class="form-card">
<label class="field-label" data-t="config.name"></label>
<input type="text" id="gc-name">
<label class="field-label" data-t="config.exePath"></label>
<div class="path-row">
<input type="text" id="gc-exe">
<button class="button" id="gc-exe-browse" data-t="config.browse"></button>
</div>
<label class="field-label" data-t="config.workdir"></label>
<div class="path-row">
<input type="text" id="gc-workdir">
<button class="button" id="gc-workdir-browse" data-t="config.browse"></button>
</div>
<label class="field-label" data-t="config.args"></label>
<input type="text" id="gc-args" data-ph="config.argsHint">
<label class="field-label" data-t="config.wine"></label>
<span id="gc-wine"></span>
<label class="field-label" data-t="config.prefix"></label>
<span id="gc-prefix"></span>
<label class="field-label" data-t="config.env"></label>
<textarea id="gc-env" rows="3" data-ph="config.envHint"></textarea>
<label class="field-label" data-t="config.dllOverrides"></label>
<div id="gc-overrides" class="overrides"></div>
<button class="button" id="gc-override-add" data-t="config.overrides.add"></button>
<div class="form-actions">
<button class="button primary" id="btn-gc-save" data-t="config.save"></button>
</div>
</div>
<div class="form-card">
<h2 data-t="tools.title"></h2>
<p class="subtitle" data-t="tools.subtitle"></p>
<div class="tools-grid" id="gc-tools"></div>
<div class="path-row">
<input type="text" id="gc-custom-tool" data-ph="tools.customPlaceholder">
<button class="button" id="gc-tool-run" data-t="tools.run"></button>
</div>
</div>
</section>
</content>
</main>
<div id="modal-overlay" class="modal-overlay" style="display:none">
<div id="modal" class="modal"></div>
</div>
<div id="toasts" class="toasts"></div>
<script src="app.js"></script>
</body>
</html>

582
renderer/style.css Normal file
View file

@ -0,0 +1,582 @@
@font-face {
font-family: 'Nunito';
src: url('Nunito-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'Nunito Black';
src: url('Nunito-Black.ttf') format('truetype');
}
:root {
--main-bg-color: rgb(20, 20, 20);
--panel-color: rgb(26, 26, 26);
--text-color: rgb(240, 240, 245);
--darker-text-color: rgb(160, 160, 170);
--light-border-color: rgba(255, 255, 255, 0.08);
--button-margin: 0.4rem;
--button-height: 2.4rem;
--icon-button-size: 3.4rem;
--border-width: 0.09rem;
--big-default: rgb(207, 207, 207);
--big-red: hsl(0, 68%, 52%);
--big-green: hsl(120, 60%, 55%);
--press-scale: scale(0.92);
--spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
--smooth: cubic-bezier(0.25, 1, 0.5, 1);
}
* {
font-size: 1rem;
box-sizing: border-box;
margin: 0;
padding: 0;
transition: all 0.2s ease;
color: var(--text-color);
font-family: "Nunito", sans-serif;
-webkit-tap-highlight-color: transparent;
scrollbar-width: none;
}
svg, svg * {
color: inherit;
}
.button span {
font-size: inherit;
color: inherit;
}
*::-webkit-scrollbar { display: none; width: 0; height: 0; }
*:focus { outline: none; box-shadow: none; }
html { font-size: max(15px, calc(100vw / 90)); }
body {
background-color: var(--main-bg-color);
overflow: hidden;
display: flex;
height: 100vh;
width: 100%;
}
.loading-inner {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
text-align: center;
}
.loading-logo {
font-family: 'Nunito Black', 'Nunito', sans-serif;
font-size: 4rem;
letter-spacing: 0.3rem;
}
main {
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
}
sidebar {
display: flex;
flex-direction: column;
justify-content: space-between;
width: var(--icon-button-size);
min-width: var(--icon-button-size);
border-right: var(--border-width) solid var(--light-border-color);
padding: 0.6rem 0;
background: var(--panel-color);
}
sidebarelement { display: block; position: relative; }
sidebar hr {
border: none;
border-top: var(--border-width) solid var(--light-border-color);
margin: 0.4rem 0.7rem;
}
sidebarelement indicator {
content: "";
position: absolute;
width: 0;
height: 2rem;
left: -0.08rem;
top: 50%;
transform: translateY(-50%);
border: 0.15rem solid transparent;
border-radius: 0.8rem;
}
sidebarelement.active indicator { border-color: var(--text-color); }
sidebarelement.active .icon-button { background: rgba(255, 255, 255, 0.05); }
.icon-button {
width: var(--icon-button-size);
height: var(--icon-button-size);
margin: var(--button-margin) auto;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: var(--border-width) solid transparent;
border-radius: 0.8rem;
cursor: pointer;
backface-visibility: hidden;
transform: translateZ(0);
will-change: transform;
transform-origin: center center;
}
.icon-button:hover { background: rgba(255, 255, 255, 0.05); }
.icon-button:active { background: rgba(255, 255, 255, 0.1); transform: var(--press-scale); }
.icon-button svg { width: 55%; height: 55%; }
content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0 2rem 2rem;
}
.view { display: none; }
.view.active {
display: block;
animation: view-in 0.25s var(--spring);
}
@keyframes view-in {
0% { opacity: 0; transform: translateY(0.8rem); }
100% { opacity: 1; transform: translateY(0); }
}
.view-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1.6rem 0 1rem;
gap: 1rem;
}
.view-header h1 {
font-family: 'Nunito Black', 'Nunito', sans-serif;
font-size: 1.8rem;
}
.header-actions { display: flex; gap: 0.6rem; align-items: center; }
.subtitle { color: var(--darker-text-color); font-size: 0.9rem; margin-top: 0.2rem; }
.button {
height: var(--button-height);
padding: 0 1rem;
margin: var(--button-margin) 0;
border-radius: 0.8rem;
background-color: rgba(255, 255, 255, 0.05);
border: var(--border-width) solid var(--light-border-color);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
cursor: pointer;
white-space: nowrap;
backface-visibility: hidden;
transform: translateZ(0);
will-change: transform;
transform-origin: center center;
}
.button:hover { background-color: rgba(255, 255, 255, 0.09); }
.button:active { background-color: rgba(255, 255, 255, 0.1); transform: var(--press-scale); }
.button svg {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
pointer-events: none;
color: inherit;
}
.button.primary {
background-color: var(--text-color);
color: var(--main-bg-color);
font-weight: 800;
border: none;
}
.button.primary:hover { filter: brightness(0.92); }
.button.primary:active { filter: brightness(0.85); transform: var(--press-scale); }
.button.primary svg { color: var(--main-bg-color); }
.button.danger {
color: var(--big-red);
border-color: rgba(255, 0, 0, 0.32);
}
.button.danger:hover { background-color: rgba(255, 0, 0, 0.09); border-color: var(--big-red); }
.button.danger:active { background-color: rgba(255, 0, 0, 0.14); transform: var(--press-scale); }
.button.danger svg { color: var(--big-red); }
.button.small { height: 2rem; padding: 0 0.7rem; font-size: 0.85rem; }
.button.small svg { width: 1.05rem; height: 1.05rem; }
.button span {
font-size: inherit;
color: inherit;
}
input, textarea {
width: 100%;
border-radius: 0.8rem;
background-color: rgba(255, 255, 255, 0.05);
border: var(--border-width) solid var(--light-border-color);
margin: var(--button-margin) 0;
padding: 0.4rem 0.7rem;
min-height: var(--button-height);
user-select: text !important;
cursor: text;
backface-visibility: hidden;
transform: translateZ(0);
will-change: transform;
transform-origin: center center;
}
input:hover, textarea:hover { background-color: rgba(255, 255, 255, 0.09); }
input:active, textarea:active { background-color: rgba(255, 255, 255, 0.1); }
input:focus, textarea:focus { border-color: rgba(255, 255, 255, 0.25); background-color: rgba(255, 255, 255, 0.1); }
textarea { resize: vertical; font-family: inherit; }
select {
display: none;
}
.custom-select {
position: relative;
border-radius: 0.8rem;
background-color: rgba(255, 255, 255, 0.05);
margin: var(--button-margin) 0;
height: var(--button-height);
display: flex;
cursor: pointer;
align-items: center;
border: var(--border-width) solid var(--light-border-color);
width: 100%;
user-select: none;
z-index: 2;
backface-visibility: hidden;
transform: translateZ(0);
will-change: transform;
transform-origin: center center;
}
.custom-select:hover { background-color: rgba(255, 255, 255, 0.09); }
.custom-select:active { background-color: rgba(255, 255, 255, 0.1); transform: var(--press-scale); }
.custom-select.wide { width: 100%; }
.custom-select-value {
padding: var(--button-margin) calc(var(--button-margin) * 2);
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.custom-select-value span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.custom-select .chevron {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
margin-left: auto;
margin-right: 0.2rem;
opacity: 0.7;
transition: transform 0.2s ease;
}
.custom-select.open .chevron { transform: rotate(180deg); }
.custom-select.open {
border-color: rgba(255, 255, 255, 0.25);
z-index: 50;
transform: none;
}
.custom-select-options {
position: absolute;
top: calc(100% + 0.4rem);
left: 0;
width: 100%;
background-color: var(--main-bg-color);
border: var(--border-width) solid var(--light-border-color);
border-radius: 0.8rem;
max-height: 15rem;
overflow-y: auto;
z-index: 100;
opacity: 0;
visibility: hidden;
transform: translateY(-0.625rem);
transition: all 0.2s ease;
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.5);
padding: 0.4rem;
pointer-events: none;
}
.custom-select.open .custom-select-options {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.custom-select-option {
padding: var(--button-margin) calc(var(--button-margin) * 2);
height: var(--button-height);
display: flex;
align-items: center;
border-radius: 0.8rem;
background-color: transparent;
cursor: pointer;
transition: background-color 0.2s;
text-align: left;
margin-bottom: 0.2rem;
width: 100%;
border: none;
font-weight: 500;
}
.custom-select-option:last-child { margin-bottom: 0; }
.custom-select-option:hover { background-color: rgba(255, 255, 255, 0.1); }
.custom-select-option.selected {
background-color: rgba(255, 255, 255, 0.15);
font-weight: bold;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0.5rem 0 1rem;
}
.toolbar input { max-width: 22rem; }
.form-card {
background: var(--panel-color);
border: var(--border-width) solid var(--light-border-color);
border-radius: 1rem;
padding: 1.2rem 1.4rem;
margin-bottom: 1.2rem;
max-width: 46rem;
}
.form-card h2 {
font-size: 1.2rem;
font-family: 'Nunito Black', 'Nunito', sans-serif;
margin-bottom: 0.3rem;
}
.form-actions {
display: flex;
gap: 0.6rem;
justify-content: flex-end;
margin-top: 1rem;
}
.field-label {
display: block;
font-size: 0.85rem;
color: var(--darker-text-color);
margin-top: 0.9rem;
}
.path-row { display: flex; gap: 0.6rem; align-items: center; }
.path-row input { flex: 1; min-width: 0; }
.path-row .custom-select { flex: 1; margin: var(--button-margin) 0; }
.game-list {
display: flex;
flex-wrap: wrap;
gap: 0.9rem;
margin: 1rem 0;
}
.game-card {
flex: 1 1 15rem;
min-width: 15rem;
max-width: 24rem;
background: var(--panel-color);
border: var(--border-width) solid var(--light-border-color);
border-radius: 1rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
animation: view-in 0.3s var(--spring);
}
.game-card:hover { border-color: rgba(255, 255, 255, 0.2); }
.game-card h3 {
font-size: 1.05rem;
font-family: 'Nunito Black', 'Nunito', sans-serif;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-card .meta {
color: var(--darker-text-color);
font-size: 0.8rem;
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.badge {
font-size: 0.72rem;
padding: 0.1rem 0.5rem;
border-radius: 0.6rem;
border: var(--border-width) solid var(--light-border-color);
color: var(--darker-text-color);
}
.badge.primary { color: var(--text-color); border-color: rgba(255, 255, 255, 0.35); }
.game-card .actions {
display: flex;
gap: 0.5rem;
margin-top: 0.4rem;
}
.game-card .actions .button { flex: 1; margin: 0; padding: 0; }
.game-card .actions .button[data-act=kill] { font-size: 0.78rem; padding: 0 0.3rem; }
.list { display: flex; flex-direction: column; gap: 0.6rem; margin: 1rem 0; }
.list-row {
display: flex;
align-items: center;
gap: 1rem;
background: var(--panel-color);
border: var(--border-width) solid var(--light-border-color);
border-radius: 0.9rem;
padding: 0.7rem 1.1rem;
animation: view-in 0.25s var(--spring);
}
.list-row .grow { flex: 1; min-width: 0; }
.list-row .title { font-weight: 700; }
.list-row .sub {
color: var(--darker-text-color);
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-row .actions { display: flex; gap: 0.5rem; }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.6rem;
padding: 4rem 1rem;
max-width: 34rem;
margin: 0 auto;
}
.empty-title { font-family: 'Nunito Black', 'Nunito', sans-serif; font-size: 1.4rem; }
.empty-hint { color: var(--darker-text-color); }
.overrides { display: flex; flex-direction: column; gap: 0.4rem; }
.override-row { display: flex; gap: 0.5rem; align-items: center; }
.override-row input { margin: 0.1rem 0; }
.override-row input.dll { max-width: 12rem; }
.override-row .custom-select { width: 10rem; margin: 0.1rem 0; flex-shrink: 0; }
.override-row .custom-select-options { min-width: 10rem; }
.override-row .button svg { width: 1rem; height: 1rem; }
.tools-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.6rem 0 1rem;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0);
backdrop-filter: blur(0);
-webkit-backdrop-filter: blur(0);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
transition: background 0.3s ease, backdrop-filter 0.3s ease, -webkit-backdrop-filter 0.3s ease;
}
.modal-overlay.show {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(0.25rem);
-webkit-backdrop-filter: blur(0.25rem);
}
.modal {
background: var(--panel-color);
border: var(--border-width) solid var(--light-border-color);
border-radius: 0.9rem;
padding: 1.4rem 1.6rem;
max-width: 30rem;
width: 90%;
display: flex;
flex-direction: column;
gap: 0.8rem;
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.5);
opacity: 0;
transform: scale(0.6);
transition: all 0.2s var(--spring);
}
.modal-overlay.show .modal {
opacity: 1;
transform: scale(1);
}
.modal.closing {
opacity: 0;
transform: scale(0.8);
transition: all 0.15s ease;
}
.modal h2 {
font-family: 'Nunito Black', 'Nunito', sans-serif;
font-size: 1.2rem;
}
.modal p { color: var(--darker-text-color); white-space: pre-line; }
.modal .modal-actions {
display: flex;
gap: 0.6rem;
justify-content: flex-end;
margin-top: 0.6rem;
}
.modal .mode-buttons { display: flex; flex-direction: column; gap: 0.6rem; }
.modal .mode-buttons .button { justify-content: flex-start; }
.toasts {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 200;
}
.toast {
background: var(--panel-color);
border: var(--border-width) solid var(--light-border-color);
border-radius: 0.8rem;
padding: 0.6rem 1rem;
max-width: 24rem;
box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.4);
opacity: 0;
transform: translateY(-1rem);
transition:
opacity 0.2s ease,
transform 0.25s var(--spring),
background-color 0.15s ease;
}
.toast.error { border-color: var(--big-red); }
.toast.success { border-color: var(--big-green); }
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast.hide {
opacity: 0;
transform: translateY(-0.5rem);
}
.progress-row {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
color: var(--darker-text-color);
}
progress {
width: 100%;
height: 0.5rem;
border: none;
border-radius: 0.4rem;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
-webkit-appearance: none;
}
progress::-webkit-progress-bar { background: rgba(255, 255, 255, 0.08); border-radius: 0.4rem; }
progress::-webkit-progress-value { background: var(--text-color); border-radius: 0.4rem; }