feat: streamer drag-reorder + bulk-queue checkboxes on VOD cards

Two complete UX features.

1. Streamer list is now drag-and-drop reorderable. The order is
   persisted via the existing config.streamers save path, so it
   survives a restart. The dragstart-then-click race that would
   normally fire selectStreamer when the drag is released is
   suppressed via a 50ms post-dragend window.

2. VOD cards each get a top-left checkbox. Selecting >=1 card opens
   a sticky action bar above the grid with "+ Queue" and "Clear"
   buttons. Bulk-add iterates the selected URLs and calls addToQueue
   for each, with a single per-batch toast summarizing the outcome.
   Selection is cleared on streamer switch (per-streamer mental
   model) but not persisted across reloads (stale selection across
   restarts is more confusing than helpful).

Implementation notes:
- Click-on-checkbox is handled by a single delegated listener on
  vodGrid (initVodGridSelectionDelegation), not per-card inline
  handlers. The card .selected class is toggled in place to avoid
  re-rendering the entire grid on every check.
- Streamer items are rebuilt from createElement so the existing
  `event.stopPropagation(); removeStreamer(...)` inline pattern
  is replaced with a real listener; defends against unusual
  characters in streamer names even though Cycle 4 added the
  4-25-char alphanumeric regex.
- styles.css: position: relative on .vod-card for the absolute-
  positioned checkbox; .selected ring highlight; .dragging
  opacity for streamer drag.
- DE / EN locale strings for the bulk-bar; setText / updateBar
  hook into applyLanguageToStaticUI so the bar count updates on
  language switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 12:24:29 +02:00
parent 6c3dc3d1b6
commit 386998deaf
6 changed files with 217 additions and 10 deletions

View File

@ -258,6 +258,12 @@
</select>
<span id="vodFilterCount" style="color: var(--text-secondary,#888); font-size:12px; min-width:80px;"></span>
</div>
<div id="vodBulkBar" class="vod-bulk-bar" style="display:none; align-items:center; gap:10px; padding:8px 12px; background: rgba(145, 70, 255, 0.12); border:1px solid rgba(145, 70, 255, 0.4); border-radius:6px; margin-bottom:12px;">
<span id="vodBulkCount" style="color: var(--text-primary,#fff); font-size:13px; font-weight:600;">0 selected</span>
<span style="flex:1;"></span>
<button id="vodBulkAddBtn" type="button" onclick="bulkAddSelectedVodsToQueue()" style="background:#9146FF; border:none; border-radius:6px; padding:6px 14px; color:white; font-size:13px; font-weight:600; cursor:pointer;">+ Queue</button>
<button id="vodBulkClearBtn" type="button" onclick="clearVodSelection()" style="background:transparent; border:1px solid var(--border-color,#444); border-radius:6px; padding:6px 12px; color:var(--text-secondary,#888); font-size:13px; cursor:pointer;">Clear</button>
</div>
<div class="vod-grid" id="vodGrid">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>

View File

@ -180,7 +180,13 @@ const UI_TEXT_DE = {
sortDateAsc: 'Aelteste zuerst',
sortViewsDesc: 'Meiste Aufrufe',
sortDurationDesc: 'Laengste zuerst',
sortDurationAsc: 'Kuerzeste zuerst'
sortDurationAsc: 'Kuerzeste zuerst',
bulkSelectedCount: '{count} ausgewaehlt',
bulkAddToQueue: '+ Warteschlange',
bulkAdding: 'Fuege hinzu...',
bulkClear: 'Loeschen',
bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.',
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).'
},
clips: {
dialogTitle: 'VOD zuschneiden',

View File

@ -180,7 +180,13 @@ const UI_TEXT_EN = {
sortDateAsc: 'Oldest first',
sortViewsDesc: 'Most viewed',
sortDurationDesc: 'Longest first',
sortDurationAsc: 'Shortest first'
sortDurationAsc: 'Shortest first',
bulkSelectedCount: '{count} selected',
bulkAddToQueue: '+ Queue',
bulkAdding: 'Adding...',
bulkClear: 'Clear',
bulkAddedToQueue: 'Added {count} VODs to the queue.',
bulkAddSkipped: 'No VODs were added (already in queue or invalid).'
},
clips: {
dialogTitle: 'Trim VOD',

View File

@ -10,6 +10,12 @@ let lastLoadedStreamer: string | null = null;
let vodFilterQuery = '';
const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter';
// Bulk-select state — keyed by VOD URL since URL is unique per VOD. Cleared
// on streamer switch (selection is per-streamer mental model). NOT persisted
// because a stale selection across reloads is more confusing than helpful.
const selectedVodUrls = new Set<string>();
let vodGridDelegationInitialized = false;
type VodSortKey = 'date_desc' | 'date_asc' | 'views_desc' | 'duration_desc' | 'duration_asc';
const VALID_VOD_SORTS: ReadonlyArray<VodSortKey> = ['date_desc', 'date_asc', 'views_desc', 'duration_desc', 'duration_asc'];
const VOD_SORT_STORAGE_KEY = 'twitch-vod-manager:vod-sort';
@ -169,9 +175,12 @@ function buildVodCardHtml(vod: VOD, streamer: string): string {
const date = formatUiDate(vod.created_at);
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '&quot;');
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
const safeUrlAttr = escapeHtml(vod.url);
const isChecked = selectedVodUrls.has(vod.url);
return `
<div class="vod-card">
<div class="vod-card${isChecked ? ' selected' : ''}">
<input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} title="Select for bulk action" style="position:absolute; top:8px; left:8px; width:18px; height:18px; accent-color:#9146FF; cursor:pointer; z-index:2;">
<img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'">
<div class="vod-info">
<div class="vod-title">${safeDisplayTitle}</div>
@ -189,22 +198,94 @@ function buildVodCardHtml(vod: VOD, streamer: string): string {
`;
}
let streamerDragInitialized = false;
let draggedStreamerName: string | null = null;
function renderStreamers(): void {
const list = byId('streamerList');
list.innerHTML = '';
list.replaceChildren();
(config.streamers ?? []).forEach((streamer: string) => {
const item = document.createElement('div');
item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : '');
item.innerHTML = `
<span>${streamer}</span>
<span class="remove" onclick="event.stopPropagation(); removeStreamer('${streamer}')">x</span>
`;
item.onclick = () => {
item.setAttribute('draggable', 'true');
item.dataset.streamerName = streamer;
const nameSpan = document.createElement('span');
nameSpan.textContent = streamer;
const removeSpan = document.createElement('span');
removeSpan.className = 'remove';
removeSpan.textContent = 'x';
removeSpan.addEventListener('click', (e) => {
e.stopPropagation();
void removeStreamer(streamer);
});
item.append(nameSpan, removeSpan);
item.addEventListener('click', () => {
// Skip click if drag was just released — drop fires after dragend
if (draggedStreamerName === streamer) return;
void selectStreamer(streamer);
};
});
list.appendChild(item);
});
initStreamerDragDrop();
}
function initStreamerDragDrop(): void {
if (streamerDragInitialized) return;
streamerDragInitialized = true;
const list = byId('streamerList');
list.addEventListener('dragstart', (e: DragEvent) => {
const target = e.target as HTMLElement;
const item = target.closest('.streamer-item') as HTMLElement | null;
if (!item || !item.dataset.streamerName) return;
draggedStreamerName = item.dataset.streamerName;
item.classList.add('dragging');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
// Some browsers refuse the drag without setData
e.dataTransfer.setData('text/plain', draggedStreamerName);
}
});
list.addEventListener('dragover', (e: DragEvent) => {
if (!draggedStreamerName) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
});
list.addEventListener('drop', async (e: DragEvent) => {
e.preventDefault();
const target = (e.target as HTMLElement).closest('.streamer-item') as HTMLElement | null;
if (!target || !draggedStreamerName) return;
const targetName = target.dataset.streamerName;
if (!targetName || targetName === draggedStreamerName) return;
const streamers = [...(config.streamers ?? [])];
const fromIdx = streamers.indexOf(draggedStreamerName);
const toIdx = streamers.indexOf(targetName);
if (fromIdx < 0 || toIdx < 0) return;
const [moved] = streamers.splice(fromIdx, 1);
streamers.splice(toIdx, 0, moved);
config.streamers = streamers;
renderStreamers();
config = await window.api.saveConfig({ streamers });
});
list.addEventListener('dragend', () => {
document.querySelectorAll('.streamer-item.dragging').forEach((el) => el.classList.remove('dragging'));
// Defer clearing draggedStreamerName so the click handler that fires
// after dragend can suppress the spurious select.
const wasDragging = draggedStreamerName;
window.setTimeout(() => {
if (draggedStreamerName === wasDragging) draggedStreamerName = null;
}, 50);
});
}
async function addStreamer(): Promise<void> {
@ -305,11 +386,104 @@ function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): v
}
function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
// Clear bulk-selection on streamer switch — selection is per-streamer
if (lastLoadedStreamer && lastLoadedStreamer !== streamer && selectedVodUrls.size > 0) {
selectedVodUrls.clear();
updateVodBulkBar();
}
lastLoadedVods = Array.isArray(vods) ? vods : [];
lastLoadedStreamer = streamer;
initVodGridSelectionDelegation();
renderVodGridFromCurrentState();
}
function initVodGridSelectionDelegation(): void {
if (vodGridDelegationInitialized) return;
vodGridDelegationInitialized = true;
const grid = document.getElementById('vodGrid');
if (!grid) return;
grid.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (!(target instanceof HTMLInputElement)) return;
if (!target.classList.contains('vod-select-checkbox')) return;
const url = target.dataset.vodUrl || '';
if (!url) return;
if (target.checked) selectedVodUrls.add(url);
else selectedVodUrls.delete(url);
// Keep card visual + bar in sync without a full re-render of all cards
const card = target.closest('.vod-card') as HTMLElement | null;
if (card) card.classList.toggle('selected', target.checked);
updateVodBulkBar();
});
}
function updateVodBulkBar(): void {
const bar = document.getElementById('vodBulkBar');
if (!bar) return;
const count = selectedVodUrls.size;
bar.style.display = count > 0 ? 'flex' : 'none';
const countEl = document.getElementById('vodBulkCount');
if (countEl) {
countEl.textContent = UI_TEXT.vods.bulkSelectedCount.replace('{count}', String(count));
}
}
function clearVodSelection(): void {
if (selectedVodUrls.size === 0) return;
selectedVodUrls.clear();
updateVodBulkBar();
if (lastLoadedStreamer) renderVodGridFromCurrentState();
}
async function bulkAddSelectedVodsToQueue(): Promise<void> {
const urls = Array.from(selectedVodUrls);
if (urls.length === 0 || !lastLoadedStreamer) return;
const streamer = lastLoadedStreamer;
const btn = document.getElementById('vodBulkAddBtn') as HTMLButtonElement | null;
const originalText = btn?.textContent || '';
if (btn) {
btn.disabled = true;
btn.textContent = UI_TEXT.vods.bulkAdding;
}
let added = 0;
let skipped = 0;
for (const url of urls) {
const vod = lastLoadedVods.find((v) => v.url === url);
if (!vod) { skipped++; continue; }
try {
queue = await window.api.addToQueue({
url: vod.url,
title: vod.title,
date: vod.created_at,
streamer,
duration_str: vod.duration
});
added++;
} catch {
skipped++;
}
}
selectedVodUrls.clear();
if (btn) {
btn.disabled = false;
btn.textContent = originalText;
}
updateVodBulkBar();
renderQueue();
renderVodGridFromCurrentState();
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast && added > 0) {
toast(UI_TEXT.vods.bulkAddedToQueue.replace('{count}', String(added)), 'info');
} else if (toast && skipped > 0) {
toast(UI_TEXT.vods.bulkAddSkipped, 'warn');
}
}
function renderVodGridFromCurrentState(): void {
if (!lastLoadedStreamer) return;

View File

@ -152,6 +152,12 @@ function applyLanguageToStaticUI(): void {
if (typeof refreshVodSortSelectLabels === 'function') {
refreshVodSortSelectLabels();
}
setText('vodBulkAddBtn', UI_TEXT.vods.bulkAddToQueue);
setText('vodBulkClearBtn', UI_TEXT.vods.bulkClear);
if (typeof updateVodBulkBar === 'function') {
// Repopulate the count text in the new locale
updateVodBulkBar();
}
const status = document.getElementById('statusText')?.textContent?.trim() || '';
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {

View File

@ -576,6 +576,7 @@ body {
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
position: relative;
}
.vod-card:hover {
@ -583,6 +584,14 @@ body {
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
}
.vod-card.selected {
box-shadow: 0 0 0 2px #9146FF, 0 8px 25px rgba(145, 70, 255, 0.25);
}
.streamer-item.dragging {
opacity: 0.4;
}
.vod-thumbnail {
width: 100%;
aspect-ratio: 16/9;