added library reordering & prefix rename

This commit is contained in:
olcxja 2026-10-02 14:05:49 +02:00
commit aca9f839e3
13 changed files with 374 additions and 20 deletions

View file

@ -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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Before After
Before After

View file

@ -490,6 +490,8 @@ select {
margin: 1rem 0;
}
.game-card {
position: relative;
cursor: grab;
flex: 1 1 17rem;
min-width: 17rem;
max-width: 24rem;
@ -526,6 +528,21 @@ select {
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'><rect x='3' y='3' width='18' height='18' rx='2'/><circle cx='8.5' cy='8.5' r='1.5'/><path d='M21 15l-5-5L5 21'/></svg>") center / contain no-repeat;
}
.game-card:hover { border-color: rgba(255, 255, 255, 0.2); }
/* drag & drop reordering: the dragged card fades, the hovered one shows on
which side the dragged card will land */
.game-card.dragging { opacity: 0.4; }
.game-card.drop-before::after,
.game-card.drop-after::after {
content: '';
position: absolute;
top: 0.5rem;
bottom: 0.5rem;
width: 0.22rem;
border-radius: 0.11rem;
background: var(--text-color);
}
.game-card.drop-before::after { left: -0.5rem; }
.game-card.drop-after::after { right: -0.5rem; }
.game-card h3 {
font-size: 1.05rem;
font-family: 'Nunito Black', 'Nunito', sans-serif;