[TEST] add data sync

This commit is contained in:
olcxja 2026-10-01 14:18:15 +02:00
commit 8e3512b5cd
16 changed files with 1935 additions and 5 deletions

View file

@ -14,6 +14,7 @@ const state = {
prefixes: { default: '', prefixes: [] },
games: [],
currentGame: null,
lansync: null,
currentView: 'library'
}
@ -33,6 +34,8 @@ const ICONS = {
// wine glass - wine/proton runners
wines: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 22h8"/><path d="M12 15v7"/><path d="M7 10h10"/><path d="M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z"/></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>',
// nodes connected to each other - devices on the local network
lansync: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="4.5" r="2.5"/><circle cx="5" cy="19" r="2.5"/><circle cx="19" cy="19" r="2.5"/><path d="M10.6 6.8 6.5 16.7M13.4 6.8l4.1 9.9M7.5 19h9"/></svg>',
// arrow into a tray - import
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>',
@ -62,7 +65,8 @@ function setButtonIcons() {
'nav-library': ICONS.library(),
'nav-prefixes': ICONS.prefixes(),
'nav-wines': ICONS.wines(),
'nav-settings': ICONS.settings()
'nav-settings': ICONS.settings(),
'nav-lansync': ICONS.lansync()
}
for (const [id, svg] of Object.entries(map)) {
const el = document.getElementById(id)
@ -322,6 +326,7 @@ function showView(name) {
if (name === 'prefixes') renderPrefixes()
if (name === 'wines') renderWines()
if (name === 'settings') renderSettings()
if (name === 'lansync') renderLanSync()
}
async function loadAll() {
@ -1123,6 +1128,204 @@ async function runCustomTool() {
else toast(t('toast.startFailed', tool, res.error || ''), 'error')
}
// --- LAN syncing ------------------------------------------------------------
const LAN_CATEGORIES = [
{ id: 'apps-portable', label: 'lansync.appsPortable' },
{ id: 'apps-prefix', label: 'lansync.appsPrefix' },
{ id: 'wines', label: 'lansync.wines' },
{ id: 'prefixes', label: 'lansync.prefixes' }
]
async function renderLanSync() {
const info = await window.plwl.lansyncGet()
state.lansync = info
qs('#ls-device-id').textContent = info.deviceId
qs('#ls-sync-code').textContent = info.syncCode
renderLanDevices(info.devices || [])
}
function renderLanDevices(devices) {
const list = qs('#ls-device-list')
list.innerHTML = ''
if (!devices.length) {
const empty = document.createElement('p')
empty.className = 'subtitle'
empty.textContent = t('lansync.empty')
list.appendChild(empty)
return
}
for (const device of devices) {
const row = document.createElement('div')
row.className = 'list-row'
row.innerHTML = `
<div class="grow">
<div class="title"></div>
<div class="sub"></div>
</div>
${device.paired ? `<span class="badge primary">${t('lansync.paired')}</span>` : ''}
<div class="actions">
<button class="button small primary" data-act="sync"></button>
</div>
`
row.querySelector('.title').textContent = device.name
const sub = row.querySelector('.sub')
sub.textContent = device.id
sub.title = device.id
const sync = row.querySelector('[data-act=sync]')
sync.textContent = t('lansync.sync')
sync.onclick = () => openLanSyncModal(device, '')
list.appendChild(row)
}
}
// Modal that collects the other device's sync code plus what to push. Shown
// again with a cleared code after a rejected one, so the user can retype it.
async function openLanSyncModal(device, errorKey) {
const modal = showModal(`
<h2 id="ls-modal-title"></h2>
<label class="field-label" id="ls-code-label"></label>
<input type="text" id="ls-code" maxlength="8" autocomplete="off" spellcheck="false">
<p class="subtitle" id="ls-code-error" style="display:none"></p>
<label class="field-label" id="ls-what-label"></label>
<div class="check-group" id="ls-categories"></div>
<label class="check-label">
<input type="checkbox" id="ls-force">
<span id="ls-force-label"></span>
</label>
<p class="subtitle" id="ls-warning"></p>
<div class="modal-actions">
<button class="button" id="ls-cancel"></button>
<button class="button primary" id="ls-start"></button>
</div>
`)
modal.querySelector('#ls-modal-title').textContent = t('lansync.syncTitle', device.name)
modal.querySelector('#ls-code-label').textContent = t('lansync.enterCode', device.name)
const code = modal.querySelector('#ls-code')
code.value = device.code || ''
const error = modal.querySelector('#ls-code-error')
if (errorKey) {
error.textContent = t(errorKey)
error.style.display = ''
code.focus()
}
modal.querySelector('#ls-what-label').textContent = t('lansync.what')
const box = modal.querySelector('#ls-categories')
for (const category of LAN_CATEGORIES) {
const label = document.createElement('label')
label.className = 'check-label'
label.innerHTML = '<input type="checkbox" checked><span></span>'
label.querySelector('input').id = 'ls-cat-' + category.id
label.querySelector('span').textContent = t(category.label)
box.appendChild(label)
}
modal.querySelector('#ls-force-label').textContent = t('lansync.force')
modal.querySelector('#ls-warning').textContent = t('lansync.mirrorWarning')
modal.querySelector('#ls-cancel').textContent = t('dialog.cancel')
modal.querySelector('#ls-cancel').onclick = closeModal
const start = modal.querySelector('#ls-start')
start.textContent = t('lansync.start')
start.onclick = async () => {
const value = code.value.trim().toUpperCase()
if (value.length !== 8) {
error.textContent = t('lansync.badCode')
error.style.display = ''
code.focus()
return
}
const categories = LAN_CATEGORIES.map(c => c.id).filter(id => modal.querySelector('#ls-cat-' + id).checked)
if (!categories.length) {
toast(t('lansync.noneSelected'), 'error')
return
}
await runLanSync(device, value, categories, modal.querySelector('#ls-force').checked)
}
}
async function runLanSync(device, code, categories, force) {
const modal = showModal(`
<h2 id="ls-run-title"></h2>
<div class="progress-row"><progress></progress></div>
<p class="subtitle" id="ls-run-phase"></p>
<p class="subtitle" id="ls-run-count"></p>
`)
modal.querySelector('#ls-run-title').textContent = t('lansync.progress', device.name)
const bar = modal.querySelector('progress')
const phase = modal.querySelector('#ls-run-phase')
const count = modal.querySelector('#ls-run-count')
let active = true
window.plwl.onLanSyncProgress(data => {
if (!active) return
const key = 'lansync.phase.' + data.phase
phase.textContent = t(key, data.label || '')
if (data.total > 0) {
bar.max = data.total
bar.value = data.done
count.textContent = t('lansync.progressFiles', data.done, data.total)
} else {
bar.removeAttribute('value')
count.textContent = ''
}
})
const res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force })
active = false
closeModal()
if (res && res.ok) {
const summary = res.summary || {}
toast(t('lansync.done', device.name, summary.uploaded || 0, summary.skipped || 0, summary.deleted || 0), 'success')
await renderLanSync()
return
}
const err = (res && res.error) || ''
if (err === 'bad-code') {
await window.plwl.lansyncForget(device.id)
toast(t('lansync.badCode'), 'error')
openLanSyncModal({ ...device, code: '' }, 'lansync.badCode')
return
}
const message = err === 'busy' ? t('lansync.busy') : err === 'offline' ? t('lansync.offline') : (err || t('lansync.unknownError'))
toast(t('lansync.failed', message), 'error')
}
// Data received from another device changed the local files: reload every
// list so the views show the merged library instead of stale state.
async function reloadLocalData() {
const [wines, prefixes, games] = await Promise.all([
window.plwl.winesList(),
window.plwl.prefixesList(),
window.plwl.gamesList()
])
state.wines = wines
state.prefixes = prefixes
state.games = games
if (state.currentView === 'library') renderLibrary()
else if (state.currentView === 'wines') renderWines()
else if (state.currentView === 'prefixes') renderPrefixes()
}
function wireLanSync() {
window.plwl.onLanSyncDevices(devices => {
if (state.lansync) state.lansync.devices = devices
if (state.currentView === 'lansync') renderLanDevices(devices)
})
window.plwl.onLanSyncApplied(async info => {
toast(t('lansync.applied', info.from || '', info.files || 0), 'success')
await reloadLocalData()
})
qs('#ls-reroll-id').addEventListener('click', async () => {
const res = await window.plwl.lansyncRerollDeviceId()
qs('#ls-device-id').textContent = res.deviceId
if (state.lansync) state.lansync.deviceId = res.deviceId
toast(t('lansync.idRerolled'), 'success')
})
qs('#ls-reroll-code').addEventListener('click', async () => {
const res = await window.plwl.lansyncRerollSyncCode()
qs('#ls-sync-code').textContent = res.syncCode
if (state.lansync) state.lansync.syncCode = res.syncCode
toast(t('lansync.codeRerolled'), 'success')
})
}
function wireEvents() {
qsa('[data-nav]').forEach(btn => {
btn.addEventListener('click', () => showView(btn.dataset.nav))
@ -1233,6 +1436,7 @@ async function boot() {
wireEvents()
wireRealtime()
wireOpenWith()
wireLanSync()
qs('#loading').remove()
qs('#main').style.display = 'flex'
showView('library')

View file

@ -29,6 +29,10 @@
<indicator></indicator>
<button class="icon-button" data-nav="settings"></button>
</sidebarelement>
<sidebarelement id="nav-lansync">
<indicator></indicator>
<button class="icon-button" data-nav="lansync"></button>
</sidebarelement>
</div>
</sidebar>
@ -101,6 +105,34 @@
</div>
</section>
<section id="view-lansync" class="view">
<div class="view-header">
<div>
<h1 data-t="lansync.title"></h1>
<p class="subtitle" data-t="lansync.subtitle"></p>
</div>
</div>
<div class="form-card">
<h2 data-t="lansync.thisDevice"></h2>
<label class="field-label" data-t="lansync.deviceId"></label>
<div class="code-row">
<code id="ls-device-id"></code>
<button class="button small" id="ls-reroll-id" data-t="lansync.reroll"></button>
</div>
<label class="field-label" data-t="lansync.syncCode"></label>
<div class="code-row">
<code id="ls-sync-code"></code>
<button class="button small" id="ls-reroll-code" data-t="lansync.reroll"></button>
</div>
<p class="subtitle" data-t="lansync.codeHint"></p>
</div>
<div class="form-card">
<h2 data-t="lansync.devices"></h2>
<p class="subtitle" data-t="lansync.devicesHint"></p>
<div id="ls-device-list" class="list"></div>
</div>
</section>
<section id="view-gameconfig" class="view">
<div class="view-header">
<div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After

View file

@ -445,6 +445,29 @@ select {
color: var(--darker-text-color);
margin-top: 0.9rem;
}
/* LAN syncing: generated id/code, shown as read-only machine values */
.code-row { display: flex; gap: 0.6rem; align-items: center; margin: var(--button-margin) 0; }
.code-row code {
flex: 1;
min-width: 0;
height: var(--button-height);
display: flex;
align-items: center;
padding: 0 0.8rem;
border-radius: 0.8rem;
background-color: rgba(255, 255, 255, 0.05);
border: var(--border-width) solid var(--light-border-color);
font-family: ui-monospace, 'DejaVu Sans Mono', monospace;
letter-spacing: 0.08em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: text;
}
.code-row #ls-sync-code { font-size: 1.25rem; font-weight: 700; letter-spacing: 0.3em; }
.check-group { display: flex; flex-direction: column; }
.check-group .check-label { margin-top: 0.2rem; margin-bottom: 0.2rem; }
.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; }