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:
parent
6c3dc3d1b6
commit
386998deaf
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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, '"');
|
||||
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;
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user