feat: global speed limit, settings cleanup, abort reset, resize panel
- Global speed throttle (shared across all uploads) - Settings grouped into sections (Uploads, Verhalten, Log) - Abort all resets jobs to queued (restartable without reupload) - fileuploader.log writes immediately per upload - Staggered interval per hoster (not parallel sleep) - Recent files panel resizable via drag handle - History hides aborted entries - Done jobs removed from queue immediately when setting active Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bfe98eac0c
commit
3d858b1ffd
@ -31,6 +31,7 @@ const DEFAULTS = {
|
|||||||
parallelUploadCount: 0, // 0 = use per-hoster limits only
|
parallelUploadCount: 0, // 0 = use per-hoster limits only
|
||||||
scaleParallelUploads: false,
|
scaleParallelUploads: false,
|
||||||
removeFromQueueOnDone: false,
|
removeFromQueueOnDone: false,
|
||||||
|
globalMaxSpeedKbs: 0, // 0 = unlimited global speed
|
||||||
pendingQueue: null,
|
pendingQueue: null,
|
||||||
scramble: {
|
scramble: {
|
||||||
active: false,
|
active: false,
|
||||||
|
|||||||
@ -34,6 +34,8 @@ class UploadManager extends EventEmitter {
|
|||||||
this.jobAbortControllers = new Map(); // jobId -> AbortController
|
this.jobAbortControllers = new Map(); // jobId -> AbortController
|
||||||
this.cancelledJobIds = new Set();
|
this.cancelledJobIds = new Set();
|
||||||
this.sessionBytes = 0;
|
this.sessionBytes = 0;
|
||||||
|
this.lastStartTime = {}; // hoster -> timestamp of last upload start
|
||||||
|
this.globalThrottle = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getSettings(hoster) {
|
_getSettings(hoster) {
|
||||||
@ -62,6 +64,17 @@ class UploadManager extends EventEmitter {
|
|||||||
return this.globalSemaphore;
|
return this.globalSemaphore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getGlobalThrottle() {
|
||||||
|
const kbs = Number(this.globalSettings.globalMaxSpeedKbs || 0);
|
||||||
|
if (!Number.isFinite(kbs) || kbs <= 0) return null;
|
||||||
|
if (!this.globalThrottle) {
|
||||||
|
this.globalThrottle = new Throttle(kbs * 1024);
|
||||||
|
} else {
|
||||||
|
this.globalThrottle.updateRate(kbs * 1024);
|
||||||
|
}
|
||||||
|
return this.globalThrottle;
|
||||||
|
}
|
||||||
|
|
||||||
_getSemaphore(hoster) {
|
_getSemaphore(hoster) {
|
||||||
if (!this.semaphores[hoster]) {
|
if (!this.semaphores[hoster]) {
|
||||||
const settings = this._getSettings(hoster);
|
const settings = this._getSettings(hoster);
|
||||||
@ -83,6 +96,8 @@ class UploadManager extends EventEmitter {
|
|||||||
this.cancelledJobIds.clear();
|
this.cancelledJobIds.clear();
|
||||||
this.semaphores = {};
|
this.semaphores = {};
|
||||||
this.globalSemaphore = null;
|
this.globalSemaphore = null;
|
||||||
|
this.globalThrottle = null;
|
||||||
|
this.lastStartTime = {};
|
||||||
|
|
||||||
const { signal } = this.abortController;
|
const { signal } = this.abortController;
|
||||||
const batchId = `batch-${Date.now()}`;
|
const batchId = `batch-${Date.now()}`;
|
||||||
@ -206,7 +221,7 @@ class UploadManager extends EventEmitter {
|
|||||||
hosterSlotAcquired = true;
|
hosterSlotAcquired = true;
|
||||||
|
|
||||||
if (settings.timeIntervalSec > 0) {
|
if (settings.timeIntervalSec > 0) {
|
||||||
await this._sleep(settings.timeIntervalSec * 1000, signal);
|
await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
@ -255,9 +270,13 @@ class UploadManager extends EventEmitter {
|
|||||||
maxAttempts
|
maxAttempts
|
||||||
});
|
});
|
||||||
|
|
||||||
const throttle = settings.maxSpeedKbs > 0
|
const hosterThrottle = settings.maxSpeedKbs > 0
|
||||||
? new Throttle(settings.maxSpeedKbs * 1024)
|
? new Throttle(settings.maxSpeedKbs * 1024)
|
||||||
: null;
|
: null;
|
||||||
|
const globalThrottle = this._getGlobalThrottle();
|
||||||
|
const throttle = hosterThrottle && globalThrottle
|
||||||
|
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
|
||||||
|
: hosterThrottle || globalThrottle;
|
||||||
|
|
||||||
if (settings.restartBelowKbs > 0) {
|
if (settings.restartBelowKbs > 0) {
|
||||||
speedAbort = new AbortController();
|
speedAbort = new AbortController();
|
||||||
@ -503,6 +522,16 @@ class UploadManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _waitForInterval(hoster, intervalMs, signal) {
|
||||||
|
const now = Date.now();
|
||||||
|
const last = this.lastStartTime[hoster] || 0;
|
||||||
|
const elapsed = now - last;
|
||||||
|
if (elapsed < intervalMs) {
|
||||||
|
await this._sleep(intervalMs - elapsed, signal);
|
||||||
|
}
|
||||||
|
this.lastStartTime[hoster] = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
cancelJobs(jobIds) {
|
cancelJobs(jobIds) {
|
||||||
for (const jobId of jobIds || []) {
|
for (const jobId of jobIds || []) {
|
||||||
if (!jobId) continue;
|
if (!jobId) continue;
|
||||||
|
|||||||
19
main.js
19
main.js
@ -540,6 +540,13 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
if (data.status !== 'uploading') {
|
if (data.status !== 'uploading') {
|
||||||
debugLog(`progress: ${data.fileName} ${data.hoster} ${data.status} ${data.error || ''}`);
|
debugLog(`progress: ${data.fileName} ${data.hoster} ${data.status} ${data.error || ''}`);
|
||||||
}
|
}
|
||||||
|
// Write to fileuploader.log immediately when a single upload finishes
|
||||||
|
if (data.status === 'done' && data.result) {
|
||||||
|
const link = data.result.download_url || data.result.embed_url || '';
|
||||||
|
if (link) {
|
||||||
|
appendUploadLog(data.hoster || '', link, data.fileName || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('upload-progress', data);
|
mainWindow.webContents.send('upload-progress', data);
|
||||||
}
|
}
|
||||||
@ -554,18 +561,6 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
uploadManager.on('batch-done', async (summary) => {
|
uploadManager.on('batch-done', async (summary) => {
|
||||||
debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`);
|
debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`);
|
||||||
await configStore.appendHistory(summary);
|
await configStore.appendHistory(summary);
|
||||||
// Write successful uploads to fileuploader.log
|
|
||||||
for (const file of summary.files || []) {
|
|
||||||
for (const result of file.results || []) {
|
|
||||||
if (result.status === 'done' && (result.download_url || result.embed_url)) {
|
|
||||||
appendUploadLog(
|
|
||||||
result.hoster || '',
|
|
||||||
result.download_url || result.embed_url || '',
|
|
||||||
file.name || ''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('upload-batch-done', summary);
|
mainWindow.webContents.send('upload-batch-done', summary);
|
||||||
}
|
}
|
||||||
|
|||||||
130
renderer/app.js
130
renderer/app.js
@ -923,6 +923,19 @@ async function startUpload() {
|
|||||||
async function cancelUpload() {
|
async function cancelUpload() {
|
||||||
await window.api.cancelUpload();
|
await window.api.cancelUpload();
|
||||||
uploading = false;
|
uploading = false;
|
||||||
|
// Reset all non-finished jobs back to queued state
|
||||||
|
for (const job of queueJobs) {
|
||||||
|
if (!['done', 'error', 'skipped'].includes(job.status)) {
|
||||||
|
job.status = 'queued';
|
||||||
|
job.progress = 0;
|
||||||
|
job.bytesUploaded = 0;
|
||||||
|
job.speedKbs = 0;
|
||||||
|
job.elapsed = 0;
|
||||||
|
job.remaining = 0;
|
||||||
|
job.error = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderQueueTable();
|
||||||
updateQueueActionButtons();
|
updateQueueActionButtons();
|
||||||
updateStatusBar();
|
updateStatusBar();
|
||||||
persistQueueStateSoon();
|
persistQueueStateSoon();
|
||||||
@ -975,6 +988,13 @@ function handleProgress(data) {
|
|||||||
|
|
||||||
maybeAddSessionFile(job);
|
maybeAddSessionFile(job);
|
||||||
|
|
||||||
|
// Remove finished jobs from queue immediately if setting is enabled
|
||||||
|
if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) {
|
||||||
|
removeJobFromIndex(job);
|
||||||
|
selectedJobIds.delete(job.id);
|
||||||
|
queueJobs = queueJobs.filter(j => j !== job);
|
||||||
|
}
|
||||||
|
|
||||||
scheduleQueueRender();
|
scheduleQueueRender();
|
||||||
updateQueueActionButtons();
|
updateQueueActionButtons();
|
||||||
updateStatusBar();
|
updateStatusBar();
|
||||||
@ -984,6 +1004,20 @@ function handleProgress(data) {
|
|||||||
function handleBatchDone(summary) {
|
function handleBatchDone(summary) {
|
||||||
uploading = false;
|
uploading = false;
|
||||||
applySummaryResults(summary);
|
applySummaryResults(summary);
|
||||||
|
|
||||||
|
// Reset aborted jobs back to queued so they can be restarted
|
||||||
|
for (const job of queueJobs) {
|
||||||
|
if (job.status === 'aborted') {
|
||||||
|
job.status = 'queued';
|
||||||
|
job.progress = 0;
|
||||||
|
job.bytesUploaded = 0;
|
||||||
|
job.speedKbs = 0;
|
||||||
|
job.elapsed = 0;
|
||||||
|
job.remaining = 0;
|
||||||
|
job.error = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
syncSelectedFilesFromQueue();
|
syncSelectedFilesFromQueue();
|
||||||
updateQueueActionButtons();
|
updateQueueActionButtons();
|
||||||
renderQueueTable();
|
renderQueueTable();
|
||||||
@ -1307,32 +1341,40 @@ function renderSettings() {
|
|||||||
<span class="panel-status active">System</span>
|
<span class="panel-status active">System</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hoster-panel-body" data-panel="global" style="display:block">
|
<div class="hoster-panel-body" data-panel="global" style="display:block">
|
||||||
|
<div class="settings-section-label">Uploads</div>
|
||||||
|
<div class="settings-grid-mini">
|
||||||
|
<div class="settings-row">
|
||||||
|
<label>Globale parallele Uploads</label>
|
||||||
|
<input type="number" class="hs-input settings-autosave" id="parallelUploadCountInput" value="${globalSettings.parallelUploadCount ?? 0}" min="0" max="100">
|
||||||
|
<span class="hint">0 = nur pro Hoster</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<label>Globales Speed-Limit (MB/s)</label>
|
||||||
|
<input type="number" class="hs-input settings-autosave" id="globalMaxSpeedMbsInput" value="${globalSettings.globalMaxSpeedKbs > 0 ? (globalSettings.globalMaxSpeedKbs / 1024).toFixed(2).replace(/\\.00$/, '') : '0'}" min="0" step="0.1">
|
||||||
|
<span class="hint">0 = unbegrenzt</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section-label">Verhalten</div>
|
||||||
|
<div class="settings-grid-mini">
|
||||||
|
<div class="settings-row checkbox-row">
|
||||||
|
<label>Hoster-Limits hochskalieren</label>
|
||||||
|
<input type="checkbox" class="settings-autosave" id="scaleParallelUploadsInput" ${globalSettings.scaleParallelUploads ? 'checked' : ''}>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row checkbox-row">
|
||||||
|
<label>Aus Queue entfernen bei Abschluss</label>
|
||||||
|
<input type="checkbox" class="settings-autosave" id="removeFromQueueOnDoneInput" ${globalSettings.removeFromQueueOnDone ? 'checked' : ''}>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row checkbox-row">
|
||||||
|
<label>Queue beim Start wiederherstellen</label>
|
||||||
|
<input type="checkbox" class="settings-autosave" id="resumeQueueOnLaunchInput" ${globalSettings.resumeQueueOnLaunch === false ? '' : 'checked'}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section-label">Log</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<label>FileUploader Log</label>
|
<label>FileUploader Log</label>
|
||||||
<input type="text" class="key-input settings-autosave" id="logFilePathInput" value="${escapeAttr(globalSettings.logFilePath || '')}" placeholder="Standardpfad verwenden">
|
<input type="text" class="key-input settings-autosave" id="logFilePathInput" value="${escapeAttr(globalSettings.logFilePath || '')}" placeholder="Standardpfad verwenden">
|
||||||
<button class="btn btn-xs btn-secondary" id="chooseLogFilePathBtn">Ordner wählen</button>
|
<button class="btn btn-xs btn-secondary" id="chooseLogFilePathBtn">Ordner wählen</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-row checkbox-row">
|
|
||||||
<label>Queue-Wiederherstellung beim Start</label>
|
|
||||||
<input type="checkbox" class="settings-autosave" id="resumeQueueOnLaunchInput" ${globalSettings.resumeQueueOnLaunch === false ? '' : 'checked'}>
|
|
||||||
</div>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label></label>
|
|
||||||
<span class="hint">Stellt die Queue beim Start wieder her, startet aber pausiert.</span>
|
|
||||||
</div>
|
|
||||||
<div class="settings-row">
|
|
||||||
<label>Globale parallele Uploads</label>
|
|
||||||
<input type="number" class="hs-input settings-autosave" id="parallelUploadCountInput" value="${globalSettings.parallelUploadCount ?? 0}" min="0" max="100">
|
|
||||||
<span class="hint">0 = nur pro Hoster</span>
|
|
||||||
</div>
|
|
||||||
<div class="settings-row checkbox-row">
|
|
||||||
<label>Hoster-Limits hochskalieren</label>
|
|
||||||
<input type="checkbox" class="settings-autosave" id="scaleParallelUploadsInput" ${globalSettings.scaleParallelUploads ? 'checked' : ''}>
|
|
||||||
</div>
|
|
||||||
<div class="settings-row checkbox-row">
|
|
||||||
<label>Aus der Queue entfernen bei Abschluss</label>
|
|
||||||
<input type="checkbox" class="settings-autosave" id="removeFromQueueOnDoneInput" ${globalSettings.removeFromQueueOnDone ? 'checked' : ''}>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(generalPanel);
|
container.appendChild(generalPanel);
|
||||||
@ -1438,7 +1480,8 @@ async function saveSettings(options = {}) {
|
|||||||
resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked,
|
resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked,
|
||||||
parallelUploadCount: Math.max(0, Math.min(100, parseInt(document.getElementById('parallelUploadCountInput')?.value || '0', 10) || 0)),
|
parallelUploadCount: Math.max(0, Math.min(100, parseInt(document.getElementById('parallelUploadCountInput')?.value || '0', 10) || 0)),
|
||||||
scaleParallelUploads: !!document.getElementById('scaleParallelUploadsInput')?.checked,
|
scaleParallelUploads: !!document.getElementById('scaleParallelUploadsInput')?.checked,
|
||||||
removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked
|
removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked,
|
||||||
|
globalMaxSpeedKbs: Math.max(0, Math.round((parseFloat(document.getElementById('globalMaxSpeedMbsInput')?.value || '0') || 0) * 1024))
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const name of HOSTERS) {
|
for (const name of HOSTERS) {
|
||||||
@ -1785,12 +1828,13 @@ async function loadHistory() {
|
|||||||
const dt = formatDateTime(batch.timestamp || new Date());
|
const dt = formatDateTime(batch.timestamp || new Date());
|
||||||
for (const file of (batch.files || [])) {
|
for (const file of (batch.files || [])) {
|
||||||
for (const result of (file.results || [])) {
|
for (const result of (file.results || [])) {
|
||||||
const isErrorLike = result.status === 'error' || result.status === 'aborted';
|
if (result.status === 'aborted') continue;
|
||||||
|
const isError = result.status === 'error';
|
||||||
historyRowsData.push({
|
historyRowsData.push({
|
||||||
date: dt.text, dateTs: dt.ts,
|
date: dt.text, dateTs: dt.ts,
|
||||||
filename: file.name || '', host: result.hoster || '',
|
filename: file.name || '', host: result.hoster || '',
|
||||||
link: isErrorLike ? `[${result.status === 'aborted' ? 'Abgebrochen' : 'Fehler'}] ${result.error || ''}` : (result.download_url || result.embed_url || ''),
|
link: isError ? `[Fehler] ${result.error || ''}` : (result.download_url || result.embed_url || ''),
|
||||||
isError: isErrorLike, order: order++
|
isError, order: order++
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2140,5 +2184,41 @@ function showCopyToast(msg) {
|
|||||||
toast._timer = setTimeout(() => toast.classList.remove('show'), 1500);
|
toast._timer = setTimeout(() => toast.classList.remove('show'), 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Resize handle for recent-files panel ---
|
||||||
|
{
|
||||||
|
const resizer = document.getElementById('recentFilesResizer');
|
||||||
|
const panel = document.getElementById('recentFilesPanel');
|
||||||
|
if (resizer && panel) {
|
||||||
|
let startY = 0;
|
||||||
|
let startH = 0;
|
||||||
|
|
||||||
|
resizer.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startY = e.clientY;
|
||||||
|
startH = panel.getBoundingClientRect().height;
|
||||||
|
resizer.classList.add('dragging');
|
||||||
|
document.body.style.cursor = 'ns-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
|
const onMove = (e2) => {
|
||||||
|
const delta = startY - e2.clientY;
|
||||||
|
const newH = Math.max(60, Math.min(window.innerHeight * 0.7, startH + delta));
|
||||||
|
panel.style.flex = `0 0 ${newH}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
resizer.classList.remove('dragging');
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Start ---
|
// --- Start ---
|
||||||
init();
|
init();
|
||||||
|
|||||||
@ -93,6 +93,7 @@
|
|||||||
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
|
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="resize-handle" id="recentFilesResizer"></div>
|
||||||
<div class="recent-files-panel" id="recentFilesPanel">
|
<div class="recent-files-panel" id="recentFilesPanel">
|
||||||
<div class="recent-files-header">
|
<div class="recent-files-header">
|
||||||
<h3>Files</h3>
|
<h3>Files</h3>
|
||||||
|
|||||||
@ -346,6 +346,17 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
flex: 0 0 5px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
background: var(--border);
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.resize-handle:hover, .resize-handle.dragging {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
.recent-files-panel {
|
.recent-files-panel {
|
||||||
flex: 0 0 180px;
|
flex: 0 0 180px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -616,6 +627,17 @@ body {
|
|||||||
.key-input:focus, .hs-input:focus { border-color: var(--accent); outline: none; }
|
.key-input:focus, .hs-input:focus { border-color: var(--accent); outline: none; }
|
||||||
.hs-input { max-width: 100px; }
|
.hs-input { max-width: 100px; }
|
||||||
.hint { font-size: 10px; color: var(--text-dim); }
|
.hint { font-size: 10px; color: var(--text-dim); }
|
||||||
|
.settings-section-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.settings-section-label:first-child { margin-top: 0; }
|
||||||
|
|
||||||
.toggle-vis {
|
.toggle-vis {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user