Merge pull request 'feat(ui): une zone de dépôt par cible, pause globale' (#15) from feat/upload-ui-v2 into main
This commit is contained in:
commit
8d115622ce
2 changed files with 153 additions and 118 deletions
|
|
@ -110,7 +110,7 @@
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
#dropzone {
|
.dropzone {
|
||||||
border: 2px dashed var(--border);
|
border: 2px dashed var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 2.5rem 1rem;
|
padding: 2.5rem 1rem;
|
||||||
|
|
@ -122,15 +122,32 @@
|
||||||
background 0.15s,
|
background 0.15s,
|
||||||
opacity 0.15s;
|
opacity 0.15s;
|
||||||
}
|
}
|
||||||
#dropzone.hover {
|
.dropzone.hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
#dropArea {
|
.dropzone strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
#zones {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
.queue-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
flex: none;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.05rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
#list {
|
#list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|
@ -250,28 +267,14 @@
|
||||||
<div id="banner" hidden></div>
|
<div id="banner" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="targetCard" class="card" hidden>
|
<div id="zones"></div>
|
||||||
<label for="targetSelect">Dossier de dépôt</label>
|
|
||||||
<select id="targetSelect"></select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="dropArea">
|
|
||||||
<div id="dropzone" class="card">
|
|
||||||
<strong>Déposez fichiers ou dossiers ici</strong><br />
|
|
||||||
<span class="muted">ou cliquez pour parcourir</span>
|
|
||||||
<input id="picker" type="file" multiple hidden />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="dirRow" class="card" hidden>
|
|
||||||
<button id="dirBtn" type="button" class="btn secondary">Parcourir un dossier…</button>
|
|
||||||
<span class="muted"> — ou glissez un dossier dans la zone ci-dessus</span>
|
|
||||||
<input id="dirPicker" type="file" webkitdirectory multiple hidden />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="queueRow" class="card" hidden>
|
<div id="queueRow" class="card" hidden>
|
||||||
<span id="queueInfo" class="muted"></span>
|
<span id="queueInfo" class="muted"></span>
|
||||||
|
<span class="queue-actions">
|
||||||
|
<button id="queuePause" type="button" class="btn secondary">Tout mettre en pause</button>
|
||||||
<button id="queueCancel" type="button" class="btn secondary">Tout annuler</button>
|
<button id="queueCancel" type="button" class="btn secondary">Tout annuler</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="list"></div>
|
<div id="list"></div>
|
||||||
|
|
@ -279,16 +282,11 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const banner = document.getElementById('banner');
|
const banner = document.getElementById('banner');
|
||||||
const targetCard = document.getElementById('targetCard');
|
const zones = document.getElementById('zones');
|
||||||
const targetSelect = document.getElementById('targetSelect');
|
|
||||||
const dropzone = document.getElementById('dropzone');
|
|
||||||
const picker = document.getElementById('picker');
|
|
||||||
const dirRow = document.getElementById('dirRow');
|
|
||||||
const dirBtn = document.getElementById('dirBtn');
|
|
||||||
const dirPicker = document.getElementById('dirPicker');
|
|
||||||
const queueRow = document.getElementById('queueRow');
|
const queueRow = document.getElementById('queueRow');
|
||||||
const queueInfo = document.getElementById('queueInfo');
|
const queueInfo = document.getElementById('queueInfo');
|
||||||
const queueCancel = document.getElementById('queueCancel');
|
const queueCancel = document.getElementById('queueCancel');
|
||||||
|
const queuePause = document.getElementById('queuePause');
|
||||||
const list = document.getElementById('list');
|
const list = document.getElementById('list');
|
||||||
|
|
||||||
const MAX_PARALLEL = 3; // dépôts directs (petits fichiers) simultanés
|
const MAX_PARALLEL = 3; // dépôts directs (petits fichiers) simultanés
|
||||||
|
|
@ -298,9 +296,6 @@
|
||||||
const CHUNK_RETRIES = 3;
|
const CHUNK_RETRIES = 3;
|
||||||
const INIT_RETRY_MS = 2000; // serveur saturé (429) : délai avant de réessayer l'init
|
const INIT_RETRY_MS = 2000; // serveur saturé (429) : délai avant de réessayer l'init
|
||||||
|
|
||||||
let currentTarget = null;
|
|
||||||
let currentTargetAllowsDirs = false;
|
|
||||||
|
|
||||||
function showBanner(message) {
|
function showBanner(message) {
|
||||||
banner.textContent = message;
|
banner.textContent = message;
|
||||||
banner.hidden = false;
|
banner.hidden = false;
|
||||||
|
|
@ -314,53 +309,6 @@
|
||||||
|
|
||||||
let targets = [];
|
let targets = [];
|
||||||
|
|
||||||
function applyTargetSelection() {
|
|
||||||
const target = targets.find((t) => t.name === targetSelect.value) || targets[0] || null;
|
|
||||||
currentTarget = target ? target.name : null;
|
|
||||||
currentTargetAllowsDirs = Boolean(target && target.allowDirectories);
|
|
||||||
dirRow.hidden = !currentTargetAllowsDirs;
|
|
||||||
// Filtre le sélecteur natif de fichiers selon les types acceptés par la cible
|
|
||||||
// (le serveur reste l'autorité ; l'attribut ne bloque pas le glisser-déposer).
|
|
||||||
const mimes = target && target.allowedMimeTypes;
|
|
||||||
picker.accept =
|
|
||||||
Array.isArray(mimes) && !mimes.includes('*') && !mimes.includes('*/*')
|
|
||||||
? mimes.join(',')
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadTargets() {
|
|
||||||
fetch('/targets', { headers: { accept: 'application/json' } })
|
|
||||||
.then((r) => {
|
|
||||||
if (r.status === 401) {
|
|
||||||
goToLogin();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return r.ok ? r.json() : [];
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
targets = Array.isArray(data) ? data : [];
|
|
||||||
targetSelect.replaceChildren();
|
|
||||||
targets.forEach((target) => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = target.name;
|
|
||||||
option.textContent = target.label;
|
|
||||||
targetSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
targetCard.hidden = targets.length < 2;
|
|
||||||
applyTargetSelection();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
targets = [];
|
|
||||||
currentTarget = null;
|
|
||||||
currentTargetAllowsDirs = false;
|
|
||||||
targetCard.hidden = true;
|
|
||||||
dirRow.hidden = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
targetSelect.addEventListener('change', applyTargetSelection);
|
|
||||||
loadTargets();
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
function formatSize(bytes) {
|
||||||
const units = ['o', 'Ko', 'Mo', 'Go', 'To'];
|
const units = ['o', 'Ko', 'Mo', 'Go', 'To'];
|
||||||
let value = bytes;
|
let value = bytes;
|
||||||
|
|
@ -379,7 +327,7 @@
|
||||||
return '~' + (seconds / 3600).toFixed(1) + ' h';
|
return '~' + (seconds / 3600).toFixed(1) + ' h';
|
||||||
}
|
}
|
||||||
|
|
||||||
function addItem(file, label) {
|
function addItem(file, label, targetLabel) {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'item';
|
item.className = 'item';
|
||||||
item.innerHTML =
|
item.innerHTML =
|
||||||
|
|
@ -395,6 +343,13 @@
|
||||||
'<div class="bar"><span></span></div>' +
|
'<div class="bar"><span></span></div>' +
|
||||||
'<div class="row"><span class="status muted">En attente…</span>' +
|
'<div class="row"><span class="status muted">En attente…</span>' +
|
||||||
'<span class="detail progress"></span></div>';
|
'<span class="detail progress"></span></div>';
|
||||||
|
if (targetLabel) {
|
||||||
|
// Pastille de catégorie, utile seulement quand plusieurs cibles existent.
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'badge';
|
||||||
|
badge.textContent = targetLabel;
|
||||||
|
item.querySelector('.row').prepend(badge);
|
||||||
|
}
|
||||||
const nameEl = item.querySelector('.name');
|
const nameEl = item.querySelector('.name');
|
||||||
nameEl.textContent = label || file.name;
|
nameEl.textContent = label || file.name;
|
||||||
nameEl.title = label || file.name; // nom complet au survol quand il est tronqué
|
nameEl.title = label || file.name; // nom complet au survol quand il est tronqué
|
||||||
|
|
@ -438,6 +393,7 @@
|
||||||
const active = new Set();
|
const active = new Set();
|
||||||
|
|
||||||
const jobs = new Set(); // jobs non terminés (en attente, en cours ou en pause)
|
const jobs = new Set(); // jobs non terminés (en attente, en cours ou en pause)
|
||||||
|
let allPaused = false; // « Tout mettre en pause » actif : la file ne démarre plus rien
|
||||||
|
|
||||||
function updateQueueRow() {
|
function updateQueueRow() {
|
||||||
let running = 0;
|
let running = 0;
|
||||||
|
|
@ -450,6 +406,8 @@
|
||||||
}
|
}
|
||||||
if (jobs.size === 0) {
|
if (jobs.size === 0) {
|
||||||
queueRow.hidden = true;
|
queueRow.hidden = true;
|
||||||
|
allPaused = false;
|
||||||
|
queuePause.textContent = 'Tout mettre en pause';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queueRow.hidden = false;
|
queueRow.hidden = false;
|
||||||
|
|
@ -461,6 +419,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function pump() {
|
function pump() {
|
||||||
|
if (allPaused) {
|
||||||
|
updateQueueRow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
let simple = 0;
|
let simple = 0;
|
||||||
let chunked = 0;
|
let chunked = 0;
|
||||||
for (const j of active) {
|
for (const j of active) {
|
||||||
|
|
@ -502,13 +464,13 @@
|
||||||
pump();
|
pump();
|
||||||
}
|
}
|
||||||
|
|
||||||
function enqueue(file, relativePath) {
|
function enqueue(file, relativePath, dropTarget) {
|
||||||
const ui = addItem(file, relativePath);
|
const ui = addItem(file, relativePath, targets.length > 1 ? dropTarget.label : '');
|
||||||
// La cible est figée à l'ajout : changer le sélecteur ensuite ne détourne pas la file.
|
// La cible est figée à l'ajout : changer le sélecteur ensuite ne détourne pas la file.
|
||||||
const job = {
|
const job = {
|
||||||
file,
|
file,
|
||||||
relativePath,
|
relativePath,
|
||||||
target: currentTarget,
|
target: dropTarget.name,
|
||||||
ui,
|
ui,
|
||||||
chunked: file.size > CHUNK_THRESHOLD,
|
chunked: file.size > CHUNK_THRESHOLD,
|
||||||
state: 'queued',
|
state: 'queued',
|
||||||
|
|
@ -934,18 +896,31 @@
|
||||||
for (const job of Array.from(jobs)) cancelJob(job);
|
for (const job of Array.from(jobs)) cancelJob(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bascule globale : met en pause les envois en cours et fige la file (aucun fichier en
|
||||||
|
// attente ne démarre), puis « Tout reprendre » relance le tout.
|
||||||
|
function toggleAllPaused() {
|
||||||
|
allPaused = !allPaused;
|
||||||
|
queuePause.textContent = allPaused ? 'Tout reprendre' : 'Tout mettre en pause';
|
||||||
|
for (const job of Array.from(jobs)) {
|
||||||
|
if (allPaused && job.state === 'running') job.pause();
|
||||||
|
else if (!allPaused && job.state === 'paused') job.resume();
|
||||||
|
}
|
||||||
|
pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
queuePause.addEventListener('click', toggleAllPaused);
|
||||||
queueCancel.addEventListener('click', cancelAll);
|
queueCancel.addEventListener('click', cancelAll);
|
||||||
|
|
||||||
// items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier.
|
// items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier.
|
||||||
function handleBatch(items) {
|
function handleBatch(items, target) {
|
||||||
let batch = items;
|
let batch = items;
|
||||||
if (!currentTargetAllowsDirs && batch.some((entry) => entry.path)) {
|
if (!target.allowDirectories && batch.some((entry) => entry.path)) {
|
||||||
showBanner('La cible sélectionnée n’accepte pas le dépôt de dossiers.');
|
showBanner('« ' + target.label + ' » n’accepte pas le dépôt de dossiers.');
|
||||||
batch = batch.filter((entry) => !entry.path);
|
batch = batch.filter((entry) => !entry.path);
|
||||||
} else {
|
} else {
|
||||||
banner.hidden = true;
|
banner.hidden = true;
|
||||||
}
|
}
|
||||||
for (const entry of batch) enqueue(entry.file, entry.path);
|
for (const entry of batch) enqueue(entry.file, entry.path, target);
|
||||||
pump();
|
pump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -995,38 +970,85 @@
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
dropzone.addEventListener('click', () => picker.click());
|
// Une zone de dépôt par cible : clic = sélecteur de fichiers, glisser-déposer =
|
||||||
picker.addEventListener('change', () => {
|
// fichiers et (si la cible l'autorise) dossiers. Un champ natif ne peut pas
|
||||||
handleBatch(fromFileList(picker.files, false));
|
// choisir fichiers et dossiers à la fois, d'où le dossier par glisser-déposer.
|
||||||
picker.value = '';
|
function createZone(target) {
|
||||||
});
|
const zone = document.createElement('div');
|
||||||
dirBtn.addEventListener('click', () => dirPicker.click());
|
zone.className = 'dropzone card';
|
||||||
dirPicker.addEventListener('change', () => {
|
|
||||||
handleBatch(fromFileList(dirPicker.files, true));
|
const title = document.createElement('strong');
|
||||||
dirPicker.value = '';
|
title.textContent = target.label;
|
||||||
|
const hint = document.createElement('span');
|
||||||
|
hint.className = 'muted';
|
||||||
|
hint.textContent = target.allowDirectories
|
||||||
|
? 'Déposez fichiers ou dossiers ici, ou cliquez pour parcourir'
|
||||||
|
: 'Déposez des fichiers ici, ou cliquez pour parcourir';
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.multiple = true;
|
||||||
|
input.hidden = true;
|
||||||
|
// Filtre le sélecteur natif selon les types acceptés par la cible (le serveur
|
||||||
|
// reste l'autorité ; l'attribut ne bloque pas le glisser-déposer).
|
||||||
|
const mimes = target.allowedMimeTypes;
|
||||||
|
input.accept =
|
||||||
|
Array.isArray(mimes) && !mimes.includes('*') && !mimes.includes('*/*')
|
||||||
|
? mimes.join(',')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
zone.append(title, document.createElement('br'), hint, input);
|
||||||
|
|
||||||
|
zone.addEventListener('click', () => input.click());
|
||||||
|
// Le clic synthétique du champ remonterait jusqu'à la zone et rouvrirait le sélecteur.
|
||||||
|
input.addEventListener('click', (event) => event.stopPropagation());
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
handleBatch(fromFileList(input.files, false), target);
|
||||||
|
input.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
['dragenter', 'dragover'].forEach((type) => {
|
['dragenter', 'dragover'].forEach((type) => {
|
||||||
dropzone.addEventListener(type, (event) => {
|
zone.addEventListener(type, (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
dropzone.classList.add('hover');
|
zone.classList.add('hover');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
['dragleave', 'drop'].forEach((type) => {
|
['dragleave', 'drop'].forEach((type) => {
|
||||||
dropzone.addEventListener(type, (event) => {
|
zone.addEventListener(type, (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (type === 'dragleave' && dropzone.contains(event.relatedTarget)) return;
|
if (type === 'dragleave' && zone.contains(event.relatedTarget)) return;
|
||||||
dropzone.classList.remove('hover');
|
zone.classList.remove('hover');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
dropzone.addEventListener('drop', (event) => {
|
zone.addEventListener('drop', (event) => {
|
||||||
if (!event.dataTransfer) return;
|
if (!event.dataTransfer) return;
|
||||||
collectDropItems(event.dataTransfer)
|
collectDropItems(event.dataTransfer)
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
if (items.length) handleBatch(items);
|
if (items.length) handleBatch(items, target);
|
||||||
})
|
})
|
||||||
.catch(() => showBanner('Impossible de lire le contenu déposé.'));
|
.catch(() => showBanner('Impossible de lire le contenu déposé.'));
|
||||||
});
|
});
|
||||||
|
return zone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTargets() {
|
||||||
|
fetch('/targets', { headers: { accept: 'application/json' } })
|
||||||
|
.then((r) => {
|
||||||
|
if (r.status === 401) {
|
||||||
|
goToLogin();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return r.ok ? r.json() : [];
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
targets = Array.isArray(data) ? data : [];
|
||||||
|
zones.replaceChildren(...targets.map(createZone));
|
||||||
|
if (targets.length === 0) showBanner('Aucune cible de dépôt disponible.');
|
||||||
|
})
|
||||||
|
.catch(() => showBanner('Impossible de charger les cibles de dépôt.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTargets();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ describe('GET /', () => {
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.headers['content-type']).toContain('text/html');
|
expect(res.headers['content-type']).toContain('text/html');
|
||||||
expect(res.body).toContain('href="/auth/login"');
|
expect(res.body).toContain('href="/auth/login"');
|
||||||
expect(res.body).not.toContain('id="dropzone"');
|
expect(res.body).not.toContain('id="zones"');
|
||||||
expect(res.body).not.toContain('Se déconnecter');
|
expect(res.body).not.toContain('Se déconnecter');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -68,7 +68,7 @@ describe('GET /', () => {
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body).toContain('Connecté : Alice');
|
expect(res.body).toContain('Connecté : Alice');
|
||||||
expect(res.body).toContain('Se déconnecter');
|
expect(res.body).toContain('Se déconnecter');
|
||||||
expect(res.body).toContain('id="dropzone"');
|
expect(res.body).toContain('id="zones"');
|
||||||
expect(res.body).not.toContain('{{USER_NAME}}');
|
expect(res.body).not.toContain('{{USER_NAME}}');
|
||||||
expect(res.body).not.toContain('href="/auth/login"');
|
expect(res.body).not.toContain('href="/auth/login"');
|
||||||
});
|
});
|
||||||
|
|
@ -86,6 +86,19 @@ describe('GET /', () => {
|
||||||
expect(res.body).not.toContain('max-width: 640px');
|
expect(res.body).not.toContain('max-width: 640px');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('page de dépôt : une zone par cible, plus de menu déroulant ni de second sélecteur', async () => {
|
||||||
|
const cookie = await login(app, idp);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/',
|
||||||
|
cookies: { [SESSION_COOKIE]: cookie },
|
||||||
|
});
|
||||||
|
expect(res.body).toContain('id="zones"');
|
||||||
|
expect(res.body).toContain('id="queuePause"');
|
||||||
|
expect(res.body).not.toContain('targetSelect');
|
||||||
|
expect(res.body).not.toContain('dirPicker');
|
||||||
|
});
|
||||||
|
|
||||||
it('échappe le nom d’utilisateur avant de l’insérer dans le HTML', async () => {
|
it('échappe le nom d’utilisateur avant de l’insérer dans le HTML', async () => {
|
||||||
const cookie = await login(app, idp, { name: '<script>alert(1)</script>' });
|
const cookie = await login(app, idp, { name: '<script>alert(1)</script>' });
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
|
|
@ -105,6 +118,6 @@ describe('GET /', () => {
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body).toContain('href="/auth/login"');
|
expect(res.body).toContain('href="/auth/login"');
|
||||||
expect(res.body).not.toContain('id="dropzone"');
|
expect(res.body).not.toContain('id="zones"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue