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>
|
</select>
|
||||||
<span id="vodFilterCount" style="color: var(--text-secondary,#888); font-size:12px; min-width:80px;"></span>
|
<span id="vodFilterCount" style="color: var(--text-secondary,#888); font-size:12px; min-width:80px;"></span>
|
||||||
</div>
|
</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="vod-grid" id="vodGrid">
|
||||||
<div class="empty-state">
|
<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>
|
<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',
|
sortDateAsc: 'Aelteste zuerst',
|
||||||
sortViewsDesc: 'Meiste Aufrufe',
|
sortViewsDesc: 'Meiste Aufrufe',
|
||||||
sortDurationDesc: 'Laengste zuerst',
|
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: {
|
clips: {
|
||||||
dialogTitle: 'VOD zuschneiden',
|
dialogTitle: 'VOD zuschneiden',
|
||||||
|
|||||||
@ -180,7 +180,13 @@ const UI_TEXT_EN = {
|
|||||||
sortDateAsc: 'Oldest first',
|
sortDateAsc: 'Oldest first',
|
||||||
sortViewsDesc: 'Most viewed',
|
sortViewsDesc: 'Most viewed',
|
||||||
sortDurationDesc: 'Longest first',
|
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: {
|
clips: {
|
||||||
dialogTitle: 'Trim VOD',
|
dialogTitle: 'Trim VOD',
|
||||||
|
|||||||
@ -10,6 +10,12 @@ let lastLoadedStreamer: string | null = null;
|
|||||||
let vodFilterQuery = '';
|
let vodFilterQuery = '';
|
||||||
const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter';
|
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';
|
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 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';
|
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 date = formatUiDate(vod.created_at);
|
||||||
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '"');
|
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '"');
|
||||||
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
|
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
|
||||||
|
const safeUrlAttr = escapeHtml(vod.url);
|
||||||
|
const isChecked = selectedVodUrls.has(vod.url);
|
||||||
|
|
||||||
return `
|
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>'">
|
<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-info">
|
||||||
<div class="vod-title">${safeDisplayTitle}</div>
|
<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 {
|
function renderStreamers(): void {
|
||||||
const list = byId('streamerList');
|
const list = byId('streamerList');
|
||||||
list.innerHTML = '';
|
list.replaceChildren();
|
||||||
|
|
||||||
(config.streamers ?? []).forEach((streamer: string) => {
|
(config.streamers ?? []).forEach((streamer: string) => {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : '');
|
item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : '');
|
||||||
item.innerHTML = `
|
item.setAttribute('draggable', 'true');
|
||||||
<span>${streamer}</span>
|
item.dataset.streamerName = streamer;
|
||||||
<span class="remove" onclick="event.stopPropagation(); removeStreamer('${streamer}')">x</span>
|
|
||||||
`;
|
const nameSpan = document.createElement('span');
|
||||||
item.onclick = () => {
|
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);
|
void selectStreamer(streamer);
|
||||||
};
|
});
|
||||||
list.appendChild(item);
|
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> {
|
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 {
|
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 : [];
|
lastLoadedVods = Array.isArray(vods) ? vods : [];
|
||||||
lastLoadedStreamer = streamer;
|
lastLoadedStreamer = streamer;
|
||||||
|
initVodGridSelectionDelegation();
|
||||||
renderVodGridFromCurrentState();
|
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 {
|
function renderVodGridFromCurrentState(): void {
|
||||||
if (!lastLoadedStreamer) return;
|
if (!lastLoadedStreamer) return;
|
||||||
|
|
||||||
|
|||||||
@ -152,6 +152,12 @@ function applyLanguageToStaticUI(): void {
|
|||||||
if (typeof refreshVodSortSelectLabels === 'function') {
|
if (typeof refreshVodSortSelectLabels === 'function') {
|
||||||
refreshVodSortSelectLabels();
|
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() || '';
|
const status = document.getElementById('statusText')?.textContent?.trim() || '';
|
||||||
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {
|
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {
|
||||||
|
|||||||
@ -576,6 +576,7 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vod-card:hover {
|
.vod-card:hover {
|
||||||
@ -583,6 +584,14 @@ body {
|
|||||||
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
|
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 {
|
.vod-thumbnail {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16/9;
|
aspect-ratio: 16/9;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user