First build
This commit is contained in:
parent
511556737e
commit
fd773f5832
954 changed files with 305115 additions and 2 deletions
BIN
renderer/Nunito-Black.ttf
Normal file
BIN
renderer/Nunito-Black.ttf
Normal file
Binary file not shown.
BIN
renderer/Nunito-Regular.ttf
Normal file
BIN
renderer/Nunito-Regular.ttf
Normal file
Binary file not shown.
766
renderer/app.js
Normal file
766
renderer/app.js
Normal 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
175
renderer/index.html
Normal 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
582
renderer/style.css
Normal 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; }
|
||||
Loading…
Add table
Add a link
Reference in a new issue