feat: streamer search/bulk-remove + cutter drag-drop + per-streamer scroll
Three Phase-10 wins. 1. Streamer-list filter + bulk-remove. Above 6 streamers (the magic number where the list starts to feel cluttered) a search input appears below the section title and a small bulk-remove "x" button next to it. Filter is title-substring, case-insensitive. Bulk-remove honours the active filter — when the input is empty it confirms removing the entire list, when filled it confirms removing only the matching subset. Used a confirm() dialog with the matching count interpolated into the locale string. 2. Cutter drag-and-drop. Dragging a video file from Explorer onto the cutter tab now loads it directly — no separate Browse click. Uses Electron's File.path extension on the dropped File object (works through contextIsolation:true). selectCutterVideo was refactored into loadCutterFromPath + a thin wrapper so the drop handler reuses the same loading logic. dragenter/dragleave count adds visual outline on #cutterPreview while a Files drag is over the tab. Falls back gracefully if the dropped file lacks .path. 3. Per-streamer VOD scroll position. Switching streamers used to reset scroll-to-top, painful when cycling between archives. vodScrollPositions Record<streamer, scrollY> persisted to localStorage, capped to 32 entries to bound storage. Save fires on a 250ms scroll-debounce timer + on every selectStreamer transition. Restore happens 80ms after renderVODs paints (lets the first chunk settle) so scrollTop has somewhere to land. Plus: bounded the persistence table at 32 entries, locale strings DE/EN, all wired through applyLanguageToStaticUI for live language switch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e5decfd851
commit
b959a930af
@ -208,7 +208,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="section-title">Streamer</div>
|
<div class="section-title" id="streamerSectionTitle" style="display:flex; align-items:center; gap:6px; justify-content:space-between;">
|
||||||
|
<span id="streamerSectionTitleText">Streamer</span>
|
||||||
|
<button id="btnStreamerBulkRemove" type="button" onclick="bulkRemoveStreamers()" title="Bulk remove" style="display:none; background:transparent; border:1px solid var(--border-soft); border-radius:4px; padding:2px 8px; color:var(--text-secondary); font-size:11px; cursor:pointer;">x</button>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="streamerListFilter" placeholder="Filter..." oninput="onStreamerListFilterChange()" style="display:none; width:calc(100% - 16px); margin:0 8px 8px; background:var(--bg-card); border:1px solid var(--border-soft); border-radius:4px; padding:4px 8px; color:var(--text); font-size:12px;">
|
||||||
<div class="streamers" id="streamerList"></div>
|
<div class="streamers" id="streamerList"></div>
|
||||||
|
|
||||||
<div class="queue-section">
|
<div class="queue-section">
|
||||||
|
|||||||
@ -69,6 +69,12 @@ const UI_TEXT_DE = {
|
|||||||
persistQueueLabel: 'Queue zwischen App-Starts speichern',
|
persistQueueLabel: 'Queue zwischen App-Starts speichern',
|
||||||
autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen',
|
autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen',
|
||||||
autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.',
|
autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.',
|
||||||
|
streamerSectionTitle: 'Streamer',
|
||||||
|
streamerListFilterPlaceholder: 'Filtern...',
|
||||||
|
streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)',
|
||||||
|
streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?',
|
||||||
|
streamerBulkRemoveFiltered: 'Die {count} passenden Streamer aus der Liste entfernen?',
|
||||||
|
cutterDropHint: 'Video-Datei hierher ziehen zum Laden.',
|
||||||
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
|
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
|
||||||
filenameTemplatesTitle: 'Dateinamen-Templates',
|
filenameTemplatesTitle: 'Dateinamen-Templates',
|
||||||
vodTemplateLabel: 'VOD-Template',
|
vodTemplateLabel: 'VOD-Template',
|
||||||
|
|||||||
@ -69,6 +69,12 @@ const UI_TEXT_EN = {
|
|||||||
persistQueueLabel: 'Keep queue between app restarts',
|
persistQueueLabel: 'Keep queue between app restarts',
|
||||||
autoResumeQueueLabel: 'Auto-resume the queue on startup',
|
autoResumeQueueLabel: 'Auto-resume the queue on startup',
|
||||||
autoResumeQueueHint: 'When enabled and the persisted queue has pending entries, downloads kick off ~5 seconds after the window opens. Disable to require an explicit Start click.',
|
autoResumeQueueHint: 'When enabled and the persisted queue has pending entries, downloads kick off ~5 seconds after the window opens. Disable to require an explicit Start click.',
|
||||||
|
streamerSectionTitle: 'Streamer',
|
||||||
|
streamerListFilterPlaceholder: 'Filter...',
|
||||||
|
streamerBulkRemoveTitle: 'Remove all (or filtered)',
|
||||||
|
streamerBulkRemoveAll: 'Remove all {count} streamers from the list?',
|
||||||
|
streamerBulkRemoveFiltered: 'Remove the {count} matching streamer(s) from the list?',
|
||||||
|
cutterDropHint: 'Drop a video file here to load it.',
|
||||||
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
|
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
|
||||||
filenameTemplatesTitle: 'Filename Templates',
|
filenameTemplatesTitle: 'Filename Templates',
|
||||||
vodTemplateLabel: 'VOD Template',
|
vodTemplateLabel: 'VOD Template',
|
||||||
|
|||||||
@ -269,11 +269,136 @@ function readVodCardContext(card: HTMLElement | null): VodCardContext | null {
|
|||||||
let streamerDragInitialized = false;
|
let streamerDragInitialized = false;
|
||||||
let draggedStreamerName: string | null = null;
|
let draggedStreamerName: string | null = null;
|
||||||
|
|
||||||
|
// Streamer list filter — only kicks in once the user has more than a handful
|
||||||
|
// of streamers. The input stays display:none below the threshold to avoid
|
||||||
|
// visual clutter for normal users with 1-3 streamers.
|
||||||
|
const STREAMER_FILTER_THRESHOLD = 6;
|
||||||
|
let streamerListFilterQuery = '';
|
||||||
|
|
||||||
|
// Per-streamer VOD scroll position. When the user clicks back to a streamer
|
||||||
|
// they've already viewed, restore where they were instead of jumping to top.
|
||||||
|
// Lives in localStorage so it survives reloads.
|
||||||
|
const VOD_SCROLL_POSITIONS_KEY = 'twitch-vod-manager:vod-scroll-positions';
|
||||||
|
let vodScrollPositions: Record<string, number> = {};
|
||||||
|
let pendingScrollRestore: { streamer: string; y: number } | null = null;
|
||||||
|
|
||||||
|
function loadVodScrollPositions(): void {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(VOD_SCROLL_POSITIONS_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
const cleaned: Record<string, number> = {};
|
||||||
|
for (const [k, v] of Object.entries(parsed)) {
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) cleaned[k] = v;
|
||||||
|
}
|
||||||
|
vodScrollPositions = cleaned;
|
||||||
|
}
|
||||||
|
} catch { /* localStorage unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistVodScrollPositions(): void {
|
||||||
|
try {
|
||||||
|
// Cap to last 32 entries to bound storage size.
|
||||||
|
const entries = Object.entries(vodScrollPositions);
|
||||||
|
if (entries.length > 32) {
|
||||||
|
vodScrollPositions = Object.fromEntries(entries.slice(entries.length - 32));
|
||||||
|
}
|
||||||
|
localStorage.setItem(VOD_SCROLL_POSITIONS_KEY, JSON.stringify(vodScrollPositions));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberCurrentVodScroll(): void {
|
||||||
|
if (!lastLoadedStreamer) return;
|
||||||
|
const grid = document.getElementById('vodGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
// Find the scroll container — vodGrid sits inside a scrollable .content
|
||||||
|
const scrollable = (grid.closest('.content') as HTMLElement | null) || grid;
|
||||||
|
const y = scrollable.scrollTop;
|
||||||
|
if (Number.isFinite(y) && y >= 0) {
|
||||||
|
vodScrollPositions[lastLoadedStreamer] = y;
|
||||||
|
persistVodScrollPositions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let vodScrollSaveTimer: number | null = null;
|
||||||
|
|
||||||
|
function initVodScrollTracking(): void {
|
||||||
|
const grid = document.getElementById('vodGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
const scrollable = (grid.closest('.content') as HTMLElement | null) || grid;
|
||||||
|
scrollable.addEventListener('scroll', () => {
|
||||||
|
if (vodScrollSaveTimer) window.clearTimeout(vodScrollSaveTimer);
|
||||||
|
vodScrollSaveTimer = window.setTimeout(() => {
|
||||||
|
vodScrollSaveTimer = null;
|
||||||
|
rememberCurrentVodScroll();
|
||||||
|
}, 250);
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCutterDragDrop(): void {
|
||||||
|
const tab = document.getElementById('cutterTab');
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
let dragOverCount = 0;
|
||||||
|
const setDragVisual = (active: boolean): void => {
|
||||||
|
const preview = document.getElementById('cutterPreview');
|
||||||
|
if (preview) preview.classList.toggle('drag-over', active);
|
||||||
|
};
|
||||||
|
|
||||||
|
tab.addEventListener('dragenter', (e) => {
|
||||||
|
if (!e.dataTransfer || !Array.from(e.dataTransfer.types).includes('Files')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragOverCount++;
|
||||||
|
setDragVisual(true);
|
||||||
|
});
|
||||||
|
tab.addEventListener('dragover', (e) => {
|
||||||
|
if (!e.dataTransfer || !Array.from(e.dataTransfer.types).includes('Files')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
});
|
||||||
|
tab.addEventListener('dragleave', () => {
|
||||||
|
dragOverCount = Math.max(0, dragOverCount - 1);
|
||||||
|
if (dragOverCount === 0) setDragVisual(false);
|
||||||
|
});
|
||||||
|
tab.addEventListener('drop', async (e) => {
|
||||||
|
if (!e.dataTransfer) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragOverCount = 0;
|
||||||
|
setDragVisual(false);
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files || []);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
// First video-ish file wins
|
||||||
|
const allowed = /\.(mp4|mkv|ts|mov|avi)$/i;
|
||||||
|
const file = files.find((f) => allowed.test(f.name)) || files[0];
|
||||||
|
// Electron extends File with .path even with contextIsolation:true
|
||||||
|
const filePath = (file as unknown as { path?: string }).path || '';
|
||||||
|
if (!filePath) return;
|
||||||
|
|
||||||
|
const loader = (window as unknown as { loadCutterFromPath?: (p: string) => Promise<void> }).loadCutterFromPath;
|
||||||
|
if (typeof loader === 'function') {
|
||||||
|
await loader(filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderStreamers(): void {
|
function renderStreamers(): void {
|
||||||
const list = byId('streamerList');
|
const list = byId('streamerList');
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
|
|
||||||
(config.streamers ?? []).forEach((streamer: string) => {
|
const all = (config.streamers ?? []) as string[];
|
||||||
|
const filterInput = document.getElementById('streamerListFilter') as HTMLInputElement | null;
|
||||||
|
const sectionTitle = document.getElementById('streamerSectionTitle');
|
||||||
|
const showFilter = all.length >= STREAMER_FILTER_THRESHOLD;
|
||||||
|
if (filterInput) filterInput.style.display = showFilter ? '' : 'none';
|
||||||
|
// Compact title margin when filter is shown — avoids double gap.
|
||||||
|
if (sectionTitle) sectionTitle.style.marginBottom = showFilter ? '4px' : '';
|
||||||
|
|
||||||
|
const q = (streamerListFilterQuery || '').trim().toLowerCase();
|
||||||
|
const visible = q ? all.filter((s) => s.toLowerCase().includes(q)) : all;
|
||||||
|
|
||||||
|
visible.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.setAttribute('draggable', 'true');
|
item.setAttribute('draggable', 'true');
|
||||||
@ -298,9 +423,43 @@ function renderStreamers(): void {
|
|||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reveal bulk-remove button only above the filter threshold.
|
||||||
|
const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null;
|
||||||
|
if (bulkBtn) bulkBtn.style.display = all.length >= STREAMER_FILTER_THRESHOLD ? '' : 'none';
|
||||||
|
|
||||||
initStreamerDragDrop();
|
initStreamerDragDrop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onStreamerListFilterChange(): void {
|
||||||
|
const input = byId<HTMLInputElement>('streamerListFilter');
|
||||||
|
streamerListFilterQuery = input.value;
|
||||||
|
renderStreamers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkRemoveStreamers(): Promise<void> {
|
||||||
|
const all = (config.streamers ?? []) as string[];
|
||||||
|
if (all.length === 0) return;
|
||||||
|
const q = (streamerListFilterQuery || '').trim().toLowerCase();
|
||||||
|
// If a filter is active, target only the matching streamers; else
|
||||||
|
// require explicit confirmation to clear the entire list.
|
||||||
|
const targets = q ? all.filter((s) => s.toLowerCase().includes(q)) : all;
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
|
const messageTemplate = q ? UI_TEXT.static.streamerBulkRemoveFiltered : UI_TEXT.static.streamerBulkRemoveAll;
|
||||||
|
if (!confirm(messageTemplate.replace('{count}', String(targets.length)))) return;
|
||||||
|
|
||||||
|
const remaining = all.filter((s) => !targets.includes(s));
|
||||||
|
config.streamers = remaining;
|
||||||
|
config = await window.api.saveConfig({ streamers: remaining });
|
||||||
|
if (currentStreamer && targets.includes(currentStreamer)) {
|
||||||
|
currentStreamer = null;
|
||||||
|
}
|
||||||
|
streamerListFilterQuery = '';
|
||||||
|
const input = document.getElementById('streamerListFilter') as HTMLInputElement | null;
|
||||||
|
if (input) input.value = '';
|
||||||
|
renderStreamers();
|
||||||
|
}
|
||||||
|
|
||||||
function initStreamerDragDrop(): void {
|
function initStreamerDragDrop(): void {
|
||||||
if (streamerDragInitialized) return;
|
if (streamerDragInitialized) return;
|
||||||
streamerDragInitialized = true;
|
streamerDragInitialized = true;
|
||||||
@ -402,10 +561,17 @@ async function removeStreamer(name: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectStreamer(name: string, forceRefresh = false): Promise<void> {
|
async function selectStreamer(name: string, forceRefresh = false): Promise<void> {
|
||||||
|
// Save where we were on the OLD streamer before navigating away.
|
||||||
|
rememberCurrentVodScroll();
|
||||||
|
|
||||||
const requestId = ++selectStreamerRequestId;
|
const requestId = ++selectStreamerRequestId;
|
||||||
const isStaleRequest = () => requestId !== selectStreamerRequestId || currentStreamer !== name;
|
const isStaleRequest = () => requestId !== selectStreamerRequestId || currentStreamer !== name;
|
||||||
|
|
||||||
currentStreamer = name;
|
currentStreamer = name;
|
||||||
|
// Schedule a scroll-restore once the VOD grid renders. The actual
|
||||||
|
// restore runs after renderVODs replaces the grid.
|
||||||
|
const savedY = vodScrollPositions[name];
|
||||||
|
pendingScrollRestore = (typeof savedY === 'number' && savedY > 0) ? { streamer: name, y: savedY } : null;
|
||||||
renderStreamers();
|
renderStreamers();
|
||||||
byId('pageTitle').textContent = name;
|
byId('pageTitle').textContent = name;
|
||||||
|
|
||||||
@ -463,6 +629,19 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
|
|||||||
lastLoadedStreamer = streamer;
|
lastLoadedStreamer = streamer;
|
||||||
initVodGridSelectionDelegation();
|
initVodGridSelectionDelegation();
|
||||||
renderVodGridFromCurrentState();
|
renderVodGridFromCurrentState();
|
||||||
|
|
||||||
|
// After the first chunk lands the grid has size, so scroll-restore can
|
||||||
|
// succeed. Use a small delay to let chunked rendering paint.
|
||||||
|
if (pendingScrollRestore && pendingScrollRestore.streamer === streamer) {
|
||||||
|
const target = pendingScrollRestore;
|
||||||
|
pendingScrollRestore = null;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const grid = document.getElementById('vodGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
const scrollable = (grid.closest('.content') as HTMLElement | null) || grid;
|
||||||
|
scrollable.scrollTop = target.y;
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initVodGridSelectionDelegation(): void {
|
function initVodGridSelectionDelegation(): void {
|
||||||
|
|||||||
@ -117,6 +117,9 @@ function applyLanguageToStaticUI(): void {
|
|||||||
setText('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueLabel);
|
setText('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueLabel);
|
||||||
setTitle('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueHint);
|
setTitle('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueHint);
|
||||||
setTitle('autoResumeQueueToggle', UI_TEXT.static.autoResumeQueueHint);
|
setTitle('autoResumeQueueToggle', UI_TEXT.static.autoResumeQueueHint);
|
||||||
|
setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle);
|
||||||
|
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
|
||||||
|
setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
|
||||||
setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
|
setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
|
||||||
setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
|
setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
|
||||||
setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);
|
setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);
|
||||||
|
|||||||
@ -64,6 +64,11 @@ async function init(): Promise<void> {
|
|||||||
vodHideDownloaded = loadPersistedHideDownloaded();
|
vodHideDownloaded = loadPersistedHideDownloaded();
|
||||||
syncVodHideDownloadedToggle();
|
syncVodHideDownloadedToggle();
|
||||||
|
|
||||||
|
// Restore per-streamer VOD scroll positions from prior sessions.
|
||||||
|
loadVodScrollPositions();
|
||||||
|
initVodScrollTracking();
|
||||||
|
initCutterDragDrop();
|
||||||
|
|
||||||
// Restore last active tab from previous session (default 'vods')
|
// Restore last active tab from previous session (default 'vods')
|
||||||
showTab(loadPersistedActiveTab());
|
showTab(loadPersistedActiveTab());
|
||||||
|
|
||||||
@ -1079,11 +1084,8 @@ async function downloadClip(): Promise<void> {
|
|||||||
status.className = 'clip-status error';
|
status.className = 'clip-status error';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectCutterVideo(): Promise<void> {
|
async function loadCutterFromPath(filePath: string): Promise<void> {
|
||||||
const filePath = await window.api.selectVideoFile();
|
if (!filePath) return;
|
||||||
if (!filePath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cutterFile = filePath;
|
cutterFile = filePath;
|
||||||
byId<HTMLInputElement>('cutterFilePath').value = filePath;
|
byId<HTMLInputElement>('cutterFilePath').value = filePath;
|
||||||
@ -1114,6 +1116,12 @@ async function selectCutterVideo(): Promise<void> {
|
|||||||
await updatePreview(0);
|
await updatePreview(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectCutterVideo(): Promise<void> {
|
||||||
|
const filePath = await window.api.selectVideoFile();
|
||||||
|
if (!filePath) return;
|
||||||
|
await loadCutterFromPath(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
|||||||
@ -615,6 +615,12 @@ body {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#cutterPreview.drag-over {
|
||||||
|
outline: 2px dashed var(--accent);
|
||||||
|
outline-offset: -8px;
|
||||||
|
background: rgba(145, 70, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.streamer-item.dragging {
|
.streamer-item.dragging {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user