[TEST] selective sync
This commit is contained in:
parent
be4f67fb60
commit
aee4d3d1f2
15 changed files with 1228 additions and 182 deletions
420
renderer/app.js
420
renderer/app.js
|
|
@ -138,6 +138,8 @@ function settleModal(value) {
|
|||
|
||||
function showModal(html, opts) {
|
||||
modalLocked = !!(opts && opts.locked)
|
||||
// a review list needs the room a narrow form does not
|
||||
qs('#modal').classList.toggle('wide', !!(opts && opts.wide))
|
||||
settleModal(null)
|
||||
if (modalTeardownTimer) {
|
||||
clearTimeout(modalTeardownTimer)
|
||||
|
|
@ -206,7 +208,7 @@ function closeModal() {
|
|||
modalSwapTimer = null
|
||||
}
|
||||
overlay.style.display = 'none'
|
||||
modal.classList.remove('closing', 'swapping', 'resizing')
|
||||
modal.classList.remove('closing', 'swapping', 'resizing', 'wide')
|
||||
modal.style.height = ''
|
||||
modal.innerHTML = ''
|
||||
modalLocked = false
|
||||
|
|
@ -656,7 +658,8 @@ function renderLibrary() {
|
|||
applyRunning(false)
|
||||
}
|
||||
card.querySelector('[data-act=config]').onclick = () => openGameConfig(game.id)
|
||||
card.querySelector('[data-act=folder]').onclick = () => window.plwl.shellOpenPath(game.workdir)
|
||||
// the app's own folder, not its working directory
|
||||
card.querySelector('[data-act=folder]').onclick = () => window.plwl.shellOpenPath(game.path || game.workdir)
|
||||
card.querySelector('[data-act=remove]').onclick = async () => {
|
||||
const note = game.mode === 'copy' || game.mode === 'move'
|
||||
? t('library.removeFilesNote')
|
||||
|
|
@ -1644,6 +1647,7 @@ function showSyncSummary({ from, received, categories, summary }) {
|
|||
const files = Math.max(0, Number(summary && summary.uploaded) || 0)
|
||||
const skipped = Math.max(0, Number(summary && summary.skipped) || 0)
|
||||
const deleted = Math.max(0, Number(summary && summary.deleted) || 0)
|
||||
const kept = Math.max(0, Number(summary && summary.kept) || 0)
|
||||
const bytes = Math.max(0, Number(summary && summary.bytes) || 0)
|
||||
const sizeBytes = Math.max(0, Number(summary && summary.sizeBytes) || 0)
|
||||
const rows = []
|
||||
|
|
@ -1655,10 +1659,11 @@ function showSyncSummary({ from, received, categories, summary }) {
|
|||
if (wines || cats.has('wines')) row(t('lansync.wines'), wines)
|
||||
if (prefixes || cats.has('prefixes')) row(t('lansync.prefixes'), prefixes)
|
||||
if (cats.has('settings')) row(t('lansync.summarySettings'), t('lansync.summaryYes'))
|
||||
if (files || skipped || deleted) {
|
||||
if (files || skipped || deleted || kept) {
|
||||
row(t(received ? 'lansync.summaryReceived' : 'lansync.summarySent'), files)
|
||||
if (skipped) row(t('lansync.summarySkipped'), skipped)
|
||||
if (deleted) row(t('lansync.summaryDeleted'), deleted)
|
||||
if (kept) row(t('lansync.summaryKept'), kept)
|
||||
}
|
||||
if (bytes) row(t('lansync.summaryTransferred'), formatBytes(bytes))
|
||||
if (sizeBytes) row(t('lansync.summarySize'), formatBytes(sizeBytes))
|
||||
|
|
@ -1757,15 +1762,7 @@ async function openLanSyncModal(device, errorKey) {
|
|||
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)
|
||||
}
|
||||
const pickers = await wireLanPickers(modal)
|
||||
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')
|
||||
|
|
@ -1780,16 +1777,172 @@ async function openLanSyncModal(device, errorKey) {
|
|||
code.focus()
|
||||
return
|
||||
}
|
||||
const categories = LAN_CATEGORIES.map(c => c.id).filter(id => modal.querySelector('#ls-cat-' + id).checked)
|
||||
if (!categories.length) {
|
||||
const picked = pickers.collect()
|
||||
if (!picked.categories.length) {
|
||||
toast(t('lansync.noneSelected'), 'error')
|
||||
return
|
||||
}
|
||||
await runLanSync(device, value, categories, modal.querySelector('#ls-force').checked)
|
||||
await runLanSync(device, value, picked.categories, modal.querySelector('#ls-force').checked, picked.selection)
|
||||
}
|
||||
}
|
||||
|
||||
async function runLanSync(device, code, categories, force) {
|
||||
// A category's checkbox says "this category travels"; the picker says which of
|
||||
// its items. Everything starts selected, and a selection only ever narrows what
|
||||
// is sent - nothing outside it may be deleted on the other side.
|
||||
async function wireLanPickers(modal) {
|
||||
const box = modal.querySelector('#ls-categories')
|
||||
box.innerHTML = ''
|
||||
let info = { apps: [], wines: [], prefixes: [] }
|
||||
try {
|
||||
info = await window.plwl.lansyncItems()
|
||||
} catch {}
|
||||
const groups = new Map([
|
||||
['apps-portable', info.apps.filter(app => app.category === 'apps-portable')],
|
||||
['apps-prefix', info.apps.filter(app => app.category === 'apps-prefix')],
|
||||
['wines', info.wines || []],
|
||||
['prefixes', info.prefixes || []],
|
||||
['settings', []]
|
||||
])
|
||||
const chosen = new Map()
|
||||
for (const [id, list] of groups) chosen.set(id, new Set(list.map(item => item.key)))
|
||||
const rows = new Map()
|
||||
|
||||
const refreshButton = id => {
|
||||
const row = rows.get(id)
|
||||
if (!row || !row.pick) return
|
||||
const list = groups.get(id) || []
|
||||
row.pick.textContent = chosen.get(id).size === list.length
|
||||
? t('lansync.pick')
|
||||
: t('lansync.pickCount', chosen.get(id).size, list.length)
|
||||
}
|
||||
|
||||
for (const category of LAN_CATEGORIES) {
|
||||
const list = groups.get(category.id) || []
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = 'lan-cat'
|
||||
const head = document.createElement('div')
|
||||
head.className = 'lan-cat-head'
|
||||
const label = document.createElement('label')
|
||||
label.className = 'check-label'
|
||||
label.innerHTML = '<input type="checkbox" checked><span></span>'
|
||||
const input = label.querySelector('input')
|
||||
input.id = 'ls-cat-' + category.id
|
||||
label.querySelector('span').textContent = t(category.label)
|
||||
head.appendChild(label)
|
||||
const row = { input, list: null, search: null, pick: null }
|
||||
rows.set(category.id, row)
|
||||
if (list.length) {
|
||||
const pick = document.createElement('button')
|
||||
pick.type = 'button'
|
||||
pick.className = 'button small'
|
||||
head.appendChild(pick)
|
||||
row.pick = pick
|
||||
const panel = document.createElement('div')
|
||||
panel.className = 'lan-pick'
|
||||
panel.hidden = true
|
||||
const tools = document.createElement('div')
|
||||
tools.className = 'lan-pick-tools'
|
||||
if (list.length > 6) {
|
||||
const search = document.createElement('input')
|
||||
search.type = 'search'
|
||||
search.className = 'lan-search'
|
||||
search.placeholder = t('lansync.pickSearch')
|
||||
tools.appendChild(search)
|
||||
row.search = search
|
||||
search.oninput = () => renderList(category.id)
|
||||
}
|
||||
const all = document.createElement('button')
|
||||
all.type = 'button'
|
||||
all.className = 'button small'
|
||||
all.textContent = t('lansync.pickAll')
|
||||
const none = document.createElement('button')
|
||||
none.type = 'button'
|
||||
none.className = 'button small'
|
||||
none.textContent = t('lansync.pickNone')
|
||||
tools.appendChild(all)
|
||||
tools.appendChild(none)
|
||||
const listEl = document.createElement('div')
|
||||
listEl.className = 'lan-pick-list'
|
||||
row.list = listEl
|
||||
panel.appendChild(tools)
|
||||
panel.appendChild(listEl)
|
||||
pick.onclick = () => {
|
||||
panel.hidden = !panel.hidden
|
||||
if (!panel.hidden) renderList(category.id)
|
||||
}
|
||||
all.onclick = () => {
|
||||
for (const item of list) chosen.get(category.id).add(item.key)
|
||||
renderList(category.id)
|
||||
}
|
||||
none.onclick = () => {
|
||||
chosen.get(category.id).clear()
|
||||
renderList(category.id)
|
||||
}
|
||||
wrapper.appendChild(panel)
|
||||
refreshButton(category.id)
|
||||
}
|
||||
wrapper.insertBefore(head, wrapper.firstChild)
|
||||
box.appendChild(wrapper)
|
||||
}
|
||||
|
||||
function renderList(id) {
|
||||
const row = rows.get(id)
|
||||
if (!row || !row.list) return
|
||||
const list = groups.get(id) || []
|
||||
const set = chosen.get(id)
|
||||
const query = row.search ? row.search.value.trim().toLowerCase() : ''
|
||||
row.list.innerHTML = ''
|
||||
for (const item of list) {
|
||||
if (query && !String(item.name || '').toLowerCase().includes(query)) continue
|
||||
const label = document.createElement('label')
|
||||
label.className = 'check-label lan-item'
|
||||
label.innerHTML = '<input type="checkbox"><span></span>'
|
||||
const input = label.querySelector('input')
|
||||
input.checked = set.has(item.key)
|
||||
label.querySelector('span').textContent = item.name
|
||||
input.onchange = () => {
|
||||
if (input.checked) set.add(item.key)
|
||||
else set.delete(item.key)
|
||||
refreshButton(id)
|
||||
// a prefix app's own folder travels with its prefix, not with the app
|
||||
if (id === 'apps-prefix') renderList('apps-prefix')
|
||||
}
|
||||
row.list.appendChild(label)
|
||||
if (id === 'apps-prefix' && input.checked && item.prefix) {
|
||||
const prefixKey = `prefix:${item.prefix}`
|
||||
const prefixesOn = rows.get('prefixes').input.checked
|
||||
if (prefixesOn && !chosen.get('prefixes').has(prefixKey)) {
|
||||
const hint = document.createElement('p')
|
||||
hint.className = 'subtitle lan-hint'
|
||||
hint.textContent = t('lansync.prefixLeftBehind', item.prefix)
|
||||
row.list.appendChild(hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
collect() {
|
||||
const categories = []
|
||||
const units = []
|
||||
let partial = false
|
||||
for (const category of LAN_CATEGORIES) {
|
||||
const input = rows.get(category.id).input
|
||||
if (!input.checked) continue
|
||||
const list = groups.get(category.id) || []
|
||||
const set = chosen.get(category.id)
|
||||
// a checked category with nothing ticked inside it sends nothing
|
||||
if (list.length && !set.size) continue
|
||||
categories.push(category.id)
|
||||
for (const item of list) if (set.has(item.key)) units.push(item.key)
|
||||
if (set.size !== list.length) partial = true
|
||||
}
|
||||
return { categories, selection: partial ? { units } : undefined }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runLanSync(device, code, categories, force, selection) {
|
||||
// locked: a running sync is not cancellable by a stray click on the backdrop
|
||||
const modal = showModal(`
|
||||
<h2 id="ls-run-title"></h2>
|
||||
|
|
@ -1823,7 +1976,7 @@ async function runLanSync(device, code, categories, force) {
|
|||
})
|
||||
let res = null
|
||||
try {
|
||||
res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force })
|
||||
res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force, selection })
|
||||
} catch (err) {
|
||||
res = { ok: false, error: (err && err.message) || '' }
|
||||
} finally {
|
||||
|
|
@ -1850,6 +2003,7 @@ async function runLanSync(device, code, categories, force) {
|
|||
: err === 'offline' || err === 'receiver-lost' ? t('lansync.offline')
|
||||
: err === 'rejected' ? t('lansync.rejected')
|
||||
: err === 'incoming-timeout' ? t('lansync.incomingTimedOut')
|
||||
: err === 'old-peer' ? t('lansync.oldPeer')
|
||||
: (err || t('lansync.unknownError'))
|
||||
toast(t('lansync.failed', message), 'error')
|
||||
}
|
||||
|
|
@ -1990,6 +2144,13 @@ function onLanReceiving(info) {
|
|||
}
|
||||
if (info.stage === 'done') {
|
||||
receiveEnded = true
|
||||
// the review's decision was the last thing on screen: this is its result
|
||||
if (reviewFlow) {
|
||||
reviewFlow = false
|
||||
closeModal()
|
||||
showReceiveSummary(info)
|
||||
return
|
||||
}
|
||||
// the popup may have been closed by the watchdog: still show the result
|
||||
// if nothing else owns the modal slot
|
||||
if (!receiving) {
|
||||
|
|
@ -2075,6 +2236,7 @@ function showReceiveSummary(info) {
|
|||
uploaded: applied.files ?? info.done,
|
||||
skipped: applied.skipped || 0,
|
||||
deleted: applied.deleted ?? info.deleted,
|
||||
kept: applied.kept ?? 0,
|
||||
bytes: applied.bytes ?? info.bytes,
|
||||
sizeBytes: applied.sizeBytes ?? info.sizeBytes,
|
||||
items: applied.items || info.items || {}
|
||||
|
|
@ -2082,6 +2244,227 @@ function showReceiveSummary(info) {
|
|||
})
|
||||
}
|
||||
|
||||
// --- the receiver's deletion review ----------------------------------------
|
||||
|
||||
// Nothing the sender left out is removed before this: the list is planned, the
|
||||
// user decides, and only then does anything get deleted.
|
||||
let reviewFlow = false
|
||||
// matches the receiver's own review timeout: an unanswered list deletes nothing
|
||||
const REVIEW_WATCHDOG_MS = 10 * 60 * 1000
|
||||
let reviewTimer = null
|
||||
|
||||
function clearReviewTimer() {
|
||||
if (!reviewTimer) return
|
||||
clearTimeout(reviewTimer)
|
||||
reviewTimer = null
|
||||
}
|
||||
|
||||
function onLanReview(info) {
|
||||
if (!info || !info.id) return
|
||||
clearReviewTimer()
|
||||
// the review replaces the receive progress popup and owns the modal slot
|
||||
if (receiveWatchdog) {
|
||||
clearTimeout(receiveWatchdog)
|
||||
receiveWatchdog = null
|
||||
}
|
||||
if (receiveStall) {
|
||||
clearTimeout(receiveStall)
|
||||
receiveStall = null
|
||||
}
|
||||
if (receiveCloseTimer) {
|
||||
clearTimeout(receiveCloseTimer)
|
||||
receiveCloseTimer = null
|
||||
}
|
||||
receiving = false
|
||||
// no progress popup may reopen between the decision and the sender's 'done'
|
||||
receiveEnded = true
|
||||
reviewFlow = true
|
||||
reviewTimer = setTimeout(() => {
|
||||
reviewTimer = null
|
||||
reviewFlow = false
|
||||
closeModal()
|
||||
toast(t('lansync.reviewExpired'), 'error')
|
||||
}, REVIEW_WATCHDOG_MS)
|
||||
renderReview(info)
|
||||
}
|
||||
|
||||
function renderReview(info) {
|
||||
const units = Array.isArray(info.units) ? info.units : []
|
||||
const files = Math.max(0, Number(info.files) || 0)
|
||||
const bytes = Math.max(0, Number(info.bytes) || 0)
|
||||
const kept = new Set()
|
||||
const keptUnits = new Set()
|
||||
const nodeInfo = new Map()
|
||||
const modal = showModal(`
|
||||
<h2>${t('lansync.reviewTitle')}</h2>
|
||||
<p class="subtitle" id="ls-review-from"></p>
|
||||
<p class="subtitle" id="ls-review-text"></p>
|
||||
<div class="lan-review" id="ls-review-list"></div>
|
||||
<p class="subtitle sync-warning" id="ls-review-warning"></p>
|
||||
<div class="lan-review-actions">
|
||||
<button class="button" id="ls-review-keep"></button>
|
||||
<button class="button primary" id="ls-review-go"></button>
|
||||
</div>
|
||||
`, { locked: true, wide: true })
|
||||
// the device name comes off the network: never interpolate it into HTML
|
||||
modal.querySelector('#ls-review-from').textContent = t('lansync.reviewFrom', info.name || info.deviceId || '')
|
||||
modal.querySelector('#ls-review-text').textContent = t('lansync.reviewText', files, formatBytes(bytes))
|
||||
modal.querySelector('#ls-review-warning').textContent = t('lansync.reviewOverwrite')
|
||||
const list = modal.querySelector('#ls-review-list')
|
||||
const go = modal.querySelector('#ls-review-go')
|
||||
const keepAll = modal.querySelector('#ls-review-keep')
|
||||
keepAll.textContent = t('lansync.reviewKeepAll')
|
||||
|
||||
const keptBy = path => [...kept].some(entry => path === entry || path.startsWith(entry + '/'))
|
||||
const keptChild = path => [...kept].some(entry => entry !== path && entry.startsWith(path + '/'))
|
||||
|
||||
const keptTotals = () => {
|
||||
let keptFiles = 0
|
||||
let keptBytes = 0
|
||||
for (const unit of units) {
|
||||
if (!keptUnits.has(unit.key)) continue
|
||||
keptFiles += Math.max(0, Number(unit.files) || 0)
|
||||
keptBytes += Math.max(0, Number(unit.bytes) || 0)
|
||||
}
|
||||
for (const path of kept) {
|
||||
if ([...kept].some(entry => entry !== path && path.startsWith(entry + '/'))) continue
|
||||
const unit = units.find(entry => (nodeInfo.get(path) || {}).unit === entry.key)
|
||||
if (unit && keptUnits.has(unit.key)) continue
|
||||
const node = nodeInfo.get(path)
|
||||
if (!node) continue
|
||||
keptFiles += node.files
|
||||
keptBytes += node.bytes
|
||||
}
|
||||
return { files: keptFiles, bytes: keptBytes }
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
for (const row of modal.querySelectorAll('.lan-node')) {
|
||||
const input = row.querySelector('input')
|
||||
const path = row.dataset.path
|
||||
const unitKept = keptUnits.has(row.dataset.unit)
|
||||
input.checked = !unitKept && !keptBy(path)
|
||||
input.indeterminate = input.checked && keptChild(path)
|
||||
// kept through an ancestor: re-enable that ancestor instead
|
||||
input.disabled = unitKept || (keptBy(path) && !kept.has(path))
|
||||
}
|
||||
for (const row of modal.querySelectorAll('.lan-review-unit')) {
|
||||
const input = row.querySelector('input')
|
||||
const key = row.dataset.key
|
||||
const unit = units.find(entry => entry.key === key)
|
||||
input.checked = !keptUnits.has(key)
|
||||
input.indeterminate = input.checked && [...kept].some(entry => (nodeInfo.get(entry) || {}).unit === key)
|
||||
row.querySelector('.lan-review-meta').textContent = t('lansync.reviewFiles', unit ? unit.files : 0, formatBytes(unit ? unit.bytes : 0))
|
||||
}
|
||||
const left = keptTotals()
|
||||
go.textContent = t('lansync.reviewDelete', Math.max(0, files - left.files), formatBytes(Math.max(0, bytes - left.bytes)))
|
||||
}
|
||||
|
||||
const nodeRow = (node, unitKey, depth) => {
|
||||
nodeInfo.set(node.path, { files: node.files || 0, bytes: node.bytes || 0, unit: unitKey })
|
||||
const row = document.createElement('div')
|
||||
row.className = 'lan-node'
|
||||
row.dataset.path = node.path
|
||||
row.dataset.unit = unitKey
|
||||
row.style.paddingLeft = `${depth * 18 + 6}px`
|
||||
row.innerHTML = `
|
||||
<label class="check-label"><input type="checkbox"><span class="lan-node-name"></span></label>
|
||||
<span class="lan-node-meta"></span>
|
||||
<button type="button" class="button small lan-node-open" hidden></button>
|
||||
`
|
||||
row.querySelector('.lan-node-name').textContent = node.name
|
||||
row.querySelector('.lan-node-meta').textContent = t('lansync.reviewFiles', node.files || 0, formatBytes(node.bytes || 0))
|
||||
const input = row.querySelector('input')
|
||||
input.onchange = () => {
|
||||
if (input.checked) {
|
||||
kept.delete(node.path)
|
||||
// keep the descendants' own entries from overriding an ancestor
|
||||
for (const entry of [...kept]) if (entry.startsWith(node.path + '/')) kept.delete(entry)
|
||||
} else {
|
||||
for (const entry of [...kept]) if (entry.startsWith(node.path + '/')) kept.delete(entry)
|
||||
kept.add(node.path)
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
const childBox = document.createElement('div')
|
||||
childBox.className = 'lan-nodes'
|
||||
childBox.hidden = true
|
||||
const open = row.querySelector('.lan-node-open')
|
||||
if (node.hasChildren) {
|
||||
open.hidden = false
|
||||
open.textContent = '+'
|
||||
open.onclick = async () => {
|
||||
if (!childBox.dataset.loaded) {
|
||||
childBox.dataset.loaded = '1'
|
||||
const res = await window.plwl.lansyncReviewTree(info.id, node.path)
|
||||
for (const child of (res && res.nodes) || []) childBox.appendChild(nodeRow(child, unitKey, depth + 1))
|
||||
}
|
||||
childBox.hidden = !childBox.hidden
|
||||
open.textContent = childBox.hidden ? '+' : '−'
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'lan-node-wrap'
|
||||
wrap.appendChild(row)
|
||||
wrap.appendChild(childBox)
|
||||
return wrap
|
||||
}
|
||||
|
||||
for (const unit of units) {
|
||||
const row = document.createElement('div')
|
||||
row.className = 'lan-review-unit'
|
||||
row.dataset.key = unit.key
|
||||
row.innerHTML = `
|
||||
<label class="check-label"><input type="checkbox" checked><span class="lan-review-name"></span></label>
|
||||
<span class="lan-review-meta"></span>
|
||||
`
|
||||
row.querySelector('.lan-review-name').textContent = unit.label || unit.key
|
||||
row.querySelector('input').onchange = async event => {
|
||||
if (event.target.checked) {
|
||||
keptUnits.delete(unit.key)
|
||||
for (const entry of [...kept]) if ((nodeInfo.get(entry) || {}).unit === unit.key) kept.delete(entry)
|
||||
} else {
|
||||
keptUnits.add(unit.key)
|
||||
for (const entry of [...kept]) if ((nodeInfo.get(entry) || {}).unit === unit.key) kept.delete(entry)
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
list.appendChild(row)
|
||||
const children = document.createElement('div')
|
||||
children.className = 'lan-nodes'
|
||||
list.appendChild(children)
|
||||
// top folders load on first paint; a unit's list is small (collapsed rows)
|
||||
window.plwl.lansyncReviewTree(info.id, unit.key).then(res => {
|
||||
for (const node of (res && res.nodes) || []) children.appendChild(nodeRow(node, unit.key, 1))
|
||||
refresh()
|
||||
})
|
||||
}
|
||||
|
||||
keepAll.onclick = () => {
|
||||
for (const unit of units) keptUnits.add(unit.key)
|
||||
kept.clear()
|
||||
refresh()
|
||||
decide()
|
||||
}
|
||||
|
||||
let deciding = false
|
||||
const decide = async () => {
|
||||
if (deciding) return
|
||||
deciding = true
|
||||
clearReviewTimer()
|
||||
go.disabled = true
|
||||
keepAll.disabled = true
|
||||
go.textContent = t('lansync.reviewDeleting')
|
||||
try {
|
||||
await window.plwl.lansyncReviewDecide(info.id, { keep: [...kept], keepUnits: [...keptUnits] })
|
||||
} catch {}
|
||||
closeModal()
|
||||
}
|
||||
go.onclick = decide
|
||||
refresh()
|
||||
}
|
||||
|
||||
// A received sync changed local files: reload every list (settings included).
|
||||
async function reloadLocalData() {
|
||||
const [wines, prefixes, games, settings] = await Promise.all([
|
||||
|
|
@ -2107,6 +2490,7 @@ function wireLanSync() {
|
|||
})
|
||||
window.plwl.onLanSyncReceiving(info => onLanReceiving(info))
|
||||
window.plwl.onLanSyncIncoming(info => onLanIncoming(info))
|
||||
window.plwl.onLanSyncReview(info => onLanReview(info))
|
||||
window.plwl.onLanSyncApplied(async info => {
|
||||
// the summary popup replaces the toast; reload behind it
|
||||
lastApplied = info
|
||||
|
|
@ -2220,7 +2604,7 @@ function wireEvents() {
|
|||
})
|
||||
qs('#gc-icon-browse').addEventListener('click', async () => {
|
||||
// start in the app's own folder, where the icon exe most likely lives
|
||||
const start = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
|
||||
const start = state.currentGame.path || absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
|
||||
const p = await window.plwl.dialogPickFile({
|
||||
properties: ['openFile'],
|
||||
defaultPath: start || window.plwl.FILES_DIR,
|
||||
|
|
|
|||
|
|
@ -209,6 +209,54 @@ content {
|
|||
.sync-summary-row span { color: var(--darker-text-color); }
|
||||
.sync-summary-row strong { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* --- LAN sync: per-category item pickers ---------------------------------- */
|
||||
.lan-cat { display: flex; flex-direction: column; }
|
||||
.lan-cat-head { display: flex; align-items: center; gap: 0.6rem; }
|
||||
.lan-cat-head .check-label { flex: 1; min-width: 0; margin: 0.35rem 0; }
|
||||
.lan-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin: 0 0 0.5rem 2.4rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
.lan-pick[hidden] { display: none; }
|
||||
.lan-pick-tools { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.lan-search { flex: 1; min-width: 0; height: 2.2rem; min-height: 0; }
|
||||
.lan-pick-list { display: flex; flex-direction: column; max-height: 16rem; overflow-y: auto; }
|
||||
.lan-pick-list .check-label { margin: 0.15rem 0; }
|
||||
.lan-pick-list .lan-hint { margin: 0 0 0.3rem 2.4rem; font-size: 0.85em; }
|
||||
.lan-item span { overflow-wrap: anywhere; }
|
||||
|
||||
/* --- LAN sync: the deletion review ---------------------------------------- */
|
||||
.lan-review { display: flex; flex-direction: column; max-height: 46vh; overflow-y: auto; padding-right: 0.3rem; }
|
||||
.lan-review-unit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.7rem;
|
||||
padding-top: 0.35rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.lan-review-unit .check-label { flex: 1; min-width: 0; margin: 0; }
|
||||
.lan-review-unit .lan-review-name { font-weight: 700; overflow-wrap: anywhere; }
|
||||
.lan-review-meta, .lan-node-meta {
|
||||
color: var(--darker-text-color);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.lan-nodes { display: flex; flex-direction: column; }
|
||||
.lan-nodes[hidden] { display: none; }
|
||||
.lan-node { display: flex; align-items: center; gap: 0.5rem; padding: 0.12rem 0.2rem; }
|
||||
.lan-node .check-label { flex: 1; min-width: 0; margin: 0; gap: 0.5rem; }
|
||||
.lan-node .lan-node-name { overflow-wrap: anywhere; }
|
||||
.lan-node input[type='checkbox'] { width: 1.4rem; height: 1.4rem; }
|
||||
.lan-node-open { padding: 0 0.5rem; min-height: 1.6rem; }
|
||||
.lan-review-actions { display: flex; gap: 0.6rem; justify-content: flex-end; margin-top: 0.6rem; }
|
||||
|
||||
.button {
|
||||
height: var(--button-height);
|
||||
padding: 0 1rem;
|
||||
|
|
@ -711,6 +759,7 @@ select {
|
|||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.modal.wide { max-width: 44rem; }
|
||||
.modal h2 {
|
||||
font-family: 'Nunito Black', 'Nunito', sans-serif;
|
||||
font-size: 1.2rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue