added library reordering & prefix rename
This commit is contained in:
parent
f8ce9a9945
commit
aca9f839e3
13 changed files with 374 additions and 20 deletions
194
renderer/app.js
194
renderer/app.js
|
|
@ -123,8 +123,20 @@ let modalSwapTimer = null
|
|||
// operation) close it.
|
||||
let modalLocked = false
|
||||
|
||||
// Resolver of the current promise-based modal (confirm/prompt). Escape, the
|
||||
// backdrop or another modal opening must settle it, or the awaiting caller
|
||||
// would never continue.
|
||||
let modalSettle = null
|
||||
|
||||
function settleModal(value) {
|
||||
const settle = modalSettle
|
||||
modalSettle = null
|
||||
if (settle) settle(value)
|
||||
}
|
||||
|
||||
function showModal(html, opts) {
|
||||
modalLocked = !!(opts && opts.locked)
|
||||
settleModal(null)
|
||||
if (modalTeardownTimer) {
|
||||
clearTimeout(modalTeardownTimer)
|
||||
modalTeardownTimer = null
|
||||
|
|
@ -185,6 +197,7 @@ function closeModal() {
|
|||
const overlay = qs('#modal-overlay')
|
||||
const modal = qs('#modal')
|
||||
if (overlay.style.display === 'none') return
|
||||
settleModal(null)
|
||||
modal.classList.add('closing')
|
||||
overlay.classList.remove('show')
|
||||
if (modalTeardownTimer) clearTimeout(modalTeardownTimer)
|
||||
|
|
@ -212,14 +225,48 @@ function confirmModal(message, okLabel) {
|
|||
<button class="button primary" id="modal-ok">${okLabel || t('dialog.ok')}</button>
|
||||
</div>
|
||||
`)
|
||||
modal.querySelector('#modal-cancel').onclick = () => {
|
||||
const finish = ok => {
|
||||
settleModal(ok)
|
||||
closeModal()
|
||||
resolve(false)
|
||||
}
|
||||
modal.querySelector('#modal-ok').onclick = () => {
|
||||
modal.querySelector('#modal-cancel').onclick = () => finish(false)
|
||||
modal.querySelector('#modal-ok').onclick = () => finish(true)
|
||||
modalSettle = v => resolve(!!v)
|
||||
})
|
||||
}
|
||||
|
||||
// Shared "type a name" modal (prefix rename, wine display name): Enter
|
||||
// submits, Cancel/Escape/backdrop resolve null.
|
||||
function promptModal({ title, label, value, confirm, hint }) {
|
||||
return new Promise(resolve => {
|
||||
const modal = showModal(`
|
||||
<h2>${title}</h2>
|
||||
${hint ? `<p class="subtitle">${hint}</p>` : ''}
|
||||
<label class="field-label">${label}</label>
|
||||
<input type="text" id="prompt-input">
|
||||
<div class="modal-actions">
|
||||
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
|
||||
<button class="button primary" id="modal-ok">${confirm || t('dialog.ok')}</button>
|
||||
</div>
|
||||
`)
|
||||
const input = modal.querySelector('#prompt-input')
|
||||
input.value = value || ''
|
||||
input.focus()
|
||||
input.select()
|
||||
const finish = v => {
|
||||
settleModal(v)
|
||||
closeModal()
|
||||
resolve(true)
|
||||
}
|
||||
const submit = () => finish(input.value.trim() || null)
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
})
|
||||
modal.querySelector('#modal-cancel').onclick = () => finish(null)
|
||||
modal.querySelector('#modal-ok').onclick = submit
|
||||
modalSettle = v => resolve(v)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -427,6 +474,80 @@ function fmtPlaytime(seconds) {
|
|||
return t('library.playtimeMin', m)
|
||||
}
|
||||
|
||||
// --- reordering the library (drag & drop) ----------------------------------
|
||||
// The registry array IS the display order, so a drop reorders state.games and
|
||||
// persists the whole id list; the main process appends any id the renderer did
|
||||
// not know about, so a stale card list can never drop an entry. Cards are
|
||||
// plain flex items, so the insertion point is the half of the hovered card the
|
||||
// pointer is over.
|
||||
let dragId = null
|
||||
|
||||
function clearDropMarks(except) {
|
||||
qsa('.game-card.drop-before, .game-card.drop-after').forEach(card => {
|
||||
if (card !== except) card.classList.remove('drop-before', 'drop-after')
|
||||
})
|
||||
}
|
||||
|
||||
function dropSide(card, x) {
|
||||
const rect = card.getBoundingClientRect()
|
||||
return x < rect.left + rect.width / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
function commitOrder(ids) {
|
||||
const byId = new Map(state.games.map(g => [g.id, g]))
|
||||
state.games = ids.map(id => byId.get(id)).filter(Boolean)
|
||||
renderLibrary()
|
||||
window.plwl.gamesReorder(ids).then(games => {
|
||||
if (!Array.isArray(games)) return
|
||||
const same = games.length === state.games.length && games.every((g, i) => g.id === state.games[i].id)
|
||||
if (same) return
|
||||
// the registry also held entries this render did not know about - show them
|
||||
state.games = games
|
||||
renderLibrary()
|
||||
})
|
||||
}
|
||||
|
||||
function wireCardDrag(card, game) {
|
||||
card.draggable = true
|
||||
card.title = t('library.reorderHint')
|
||||
card.addEventListener('dragstart', e => {
|
||||
dragId = game.id
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', game.id) // a drag needs data to start
|
||||
card.classList.add('dragging')
|
||||
})
|
||||
card.addEventListener('dragend', () => {
|
||||
dragId = null
|
||||
clearDropMarks()
|
||||
})
|
||||
card.addEventListener('dragover', e => {
|
||||
if (!dragId || dragId === game.id) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const side = dropSide(card, e.clientX)
|
||||
clearDropMarks(card)
|
||||
card.classList.toggle('drop-before', side === 'before')
|
||||
card.classList.toggle('drop-after', side === 'after')
|
||||
})
|
||||
card.addEventListener('dragleave', e => {
|
||||
// dragleave also fires when the pointer crosses a child element
|
||||
if (!card.contains(e.relatedTarget)) card.classList.remove('drop-before', 'drop-after')
|
||||
})
|
||||
card.addEventListener('drop', e => {
|
||||
if (!dragId || dragId === game.id) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation() // the list's own drop handler moves cards to the end
|
||||
const side = dropSide(card, e.clientX)
|
||||
const id = dragId
|
||||
dragId = null
|
||||
const ids = state.games.map(g => g.id).filter(x => x !== id)
|
||||
const at = ids.indexOf(game.id)
|
||||
if (at === -1) return
|
||||
ids.splice(side === 'before' ? at : at + 1, 0, id)
|
||||
commitOrder(ids)
|
||||
})
|
||||
}
|
||||
|
||||
async function attachCardIcon(card, game) {
|
||||
// placeholder first so every card has a stable icon slot; swapped for the
|
||||
// real icon once it loads - games without one just keep the placeholder
|
||||
|
|
@ -513,6 +634,7 @@ function renderLibrary() {
|
|||
renderLibrary()
|
||||
}
|
||||
list.appendChild(card)
|
||||
wireCardDrag(card, game)
|
||||
attachCardIcon(card, game)
|
||||
})
|
||||
}
|
||||
|
|
@ -820,11 +942,38 @@ function renderPrefixes() {
|
|||
</div>
|
||||
${p.name === defs.default ? `<span class="badge primary">${t('prefixes.default')}</span>` : ''}
|
||||
<div class="actions">
|
||||
${locked ? '' : '<button class="button small" data-act="rename"></button>'}
|
||||
<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 renBtn = row.querySelector('[data-act=rename]')
|
||||
if (renBtn) {
|
||||
renBtn.textContent = t('prefixes.rename')
|
||||
renBtn.onclick = async () => {
|
||||
const name = await promptModal({
|
||||
title: t('prefixes.renameTitle'),
|
||||
label: t('prefixes.namePlaceholder'),
|
||||
hint: t('prefixes.renameHint'),
|
||||
value: p.name,
|
||||
confirm: t('prefixes.rename')
|
||||
})
|
||||
if (!name || name === p.name) return
|
||||
const res = await window.plwl.prefixesRename(p.name, name)
|
||||
if (!res || !res.ok) {
|
||||
const err = (res && res.error) || ''
|
||||
const known = { alreadyExists: 'prefixes.exists', invalidName: 'prefixes.invalidName', isMain: 'prefixes.mainLocked' }
|
||||
toast(known[err] ? t(known[err]) : t('prefixes.renameFailed', err), 'error')
|
||||
return
|
||||
}
|
||||
toast(t('prefixes.renamed', p.name, res.name), 'success')
|
||||
state.prefixes = await window.plwl.prefixesList()
|
||||
// the apps that run in this prefix point at the new name now
|
||||
state.games = await window.plwl.gamesList()
|
||||
renderPrefixes()
|
||||
}
|
||||
}
|
||||
const defBtn = row.querySelector('[data-act=default]')
|
||||
defBtn.textContent = t('prefixes.makeDefault')
|
||||
defBtn.onclick = async () => {
|
||||
|
|
@ -874,12 +1023,36 @@ function renderWines() {
|
|||
${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" data-act="rename"></button>` : ''}
|
||||
${w.id !== 'system' ? `<button class="button small danger" data-act="delete"></button>` : ''}
|
||||
</div>
|
||||
`
|
||||
row.querySelector('.title').textContent = w.label
|
||||
row.querySelector('.sub').textContent = displayPath(w.path) || t('wines.systemHint')
|
||||
row.querySelector('.sub').title = row.querySelector('.sub').textContent
|
||||
const renBtn = row.querySelector('[data-act=rename]')
|
||||
if (renBtn) {
|
||||
renBtn.textContent = t('wines.rename')
|
||||
renBtn.onclick = async () => {
|
||||
const name = await promptModal({
|
||||
title: t('wines.renameTitle'),
|
||||
label: t('common.name'),
|
||||
hint: t('wines.renameHint'),
|
||||
value: w.displayName || w.label,
|
||||
confirm: t('wines.rename')
|
||||
})
|
||||
if (!name || name === (w.displayName || w.label)) return
|
||||
const res = await window.plwl.winesSetDisplayName(w.id, name)
|
||||
if (!res || !res.ok) {
|
||||
const err = (res && res.error) || ''
|
||||
toast(err === 'invalidName' ? t('wines.invalidName') : t('wines.renameFailed', err), 'error')
|
||||
return
|
||||
}
|
||||
toast(t('wines.renamed', w.label, res.displayName), 'success')
|
||||
state.wines = await window.plwl.winesList()
|
||||
renderWines()
|
||||
}
|
||||
}
|
||||
const defBtn = row.querySelector('[data-act=default]')
|
||||
defBtn.textContent = t('wines.makeDefault')
|
||||
defBtn.onclick = async () => {
|
||||
|
|
@ -1616,6 +1789,19 @@ function wireEvents() {
|
|||
})
|
||||
qs('#btn-import-top').addEventListener('click', startImportFlow)
|
||||
qs('#btn-import-empty').addEventListener('click', startImportFlow)
|
||||
// a drop on the empty area of the grid moves the card to the end; drops on a
|
||||
// card are handled by the card itself (and stop there)
|
||||
const gameList = qs('#game-list')
|
||||
gameList.addEventListener('dragover', e => {
|
||||
if (dragId) e.preventDefault()
|
||||
})
|
||||
gameList.addEventListener('drop', e => {
|
||||
if (!dragId || e.target !== gameList) return
|
||||
e.preventDefault()
|
||||
const id = dragId
|
||||
dragId = null
|
||||
commitOrder([...state.games.map(g => g.id).filter(x => x !== id), id])
|
||||
})
|
||||
qs('#btn-prefix-create').addEventListener('click', async () => {
|
||||
const input = qs('#prefix-name-input')
|
||||
const name = input.value.trim()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue