- QueueService.remove() remplace cancel() : supprime l'élément de la liste quel que soit son état (annule d'abord s'il était en attente/en cours), au lieu de se limiter aux deux cas actifs et renvoyer false pour un élément déjà terminé. - consumeCompletedFile() met fileName à null une fois le fichier livré (et son dossier nettoyé) : évite qu'un second appel tente de relire un fichier qui n'existe déjà plus. - QueuePanel : un même bouton retire l'élément quel que soit son état - "Annuler" en attente/en cours, "Supprimer" une fois terminé. Bouton "Tout télécharger" dans l'en-tête, qui déclenche avec un léger décalage chaque téléchargement encore disponible (évite le blocage navigateur des téléchargements multiples simultanés). - Liste de résultats de recherche de chaîne agrandie (320px -> 65vh). 11 nouveaux tests côté serveur (63 -> alignés). Vérifié en navigateur : ajout, échec, suppression effective (l'élément disparaît de la liste). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
235 lines
7.2 KiB
TypeScript
235 lines
7.2 KiB
TypeScript
import {
|
||
type QueueItemDto,
|
||
listQueueItems,
|
||
queueItemFileUrl,
|
||
removeQueueItem,
|
||
} from '../../api/queueApi';
|
||
import queuePanelTemplate from './QueuePanel.html';
|
||
import './QueuePanel.scss';
|
||
|
||
/** Intervalle de sondage de `GET /api/queue`, en l'absence de push temps réel (à venir). */
|
||
const POLL_INTERVAL_MS = 1500;
|
||
|
||
/** Délai entre deux déclenchements de téléchargement lors d'un "tout télécharger" (limite les blocages navigateur sur les téléchargements multiples). */
|
||
const BULK_DOWNLOAD_STAGGER_MS = 250;
|
||
|
||
const STATUS_LABELS: Record<QueueItemDto['status'], string> = {
|
||
pending: 'En attente',
|
||
running: 'En cours',
|
||
completed: 'Terminé',
|
||
failed: 'Échec',
|
||
cancelled: 'Annulé',
|
||
};
|
||
|
||
const MODE_LABELS: Record<QueueItemDto['mode']['kind'], string> = {
|
||
video: 'Vidéo',
|
||
audio: 'Audio',
|
||
thumbnail: 'Miniature',
|
||
};
|
||
|
||
/** Contrôleur exposé par {@link mountQueuePanel}. */
|
||
export interface QueuePanelController {
|
||
/** Recharge immédiatement la liste, sans attendre le prochain sondage. */
|
||
refresh: () => void;
|
||
}
|
||
|
||
/**
|
||
* @param item - Élément dont on veut savoir s'il est encore actionnable (en attente/en cours).
|
||
* @returns `true` si l'élément peut encore être annulé.
|
||
*/
|
||
function isActive(item: QueueItemDto): boolean {
|
||
return item.status === 'pending' || item.status === 'running';
|
||
}
|
||
|
||
/**
|
||
* @param item - Élément à tester.
|
||
* @returns `true` si un fichier est disponible au téléchargement pour cet élément.
|
||
*/
|
||
function isDownloadable(item: QueueItemDto): boolean {
|
||
return item.status === 'completed' && item.fileName !== null;
|
||
}
|
||
|
||
/**
|
||
* Supprime un élément de la file au clic sur son bouton "Annuler"/"Supprimer".
|
||
*
|
||
* @param id - Identifiant de l'élément à supprimer.
|
||
* @param button - Bouton cliqué, désactivé pendant la requête.
|
||
* @param onDone - Rappelé après la requête (succès ou échec) pour rafraîchir la liste.
|
||
*/
|
||
async function handleRemove(
|
||
id: string,
|
||
button: HTMLButtonElement,
|
||
onDone: () => void,
|
||
): Promise<void> {
|
||
button.disabled = true;
|
||
try {
|
||
await removeQueueItem(id);
|
||
} catch (error) {
|
||
console.error('Échec de la suppression', error);
|
||
} finally {
|
||
onDone();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Déclenche, avec un léger décalage entre chacun, le téléchargement de tous
|
||
* les fichiers actuellement disponibles (évite le blocage navigateur des
|
||
* téléchargements multiples déclenchés au même instant).
|
||
*
|
||
* @param button - Bouton "Tout télécharger" cliqué, désactivé pendant l'opération.
|
||
*/
|
||
async function handleDownloadAll(button: HTMLButtonElement): Promise<void> {
|
||
button.disabled = true;
|
||
try {
|
||
const items = await listQueueItems();
|
||
const downloadable = items.filter(isDownloadable);
|
||
|
||
downloadable.forEach((item, index) => {
|
||
setTimeout(() => {
|
||
const link = document.createElement('a');
|
||
link.href = queueItemFileUrl(item.id);
|
||
document.body.append(link);
|
||
link.click();
|
||
link.remove();
|
||
}, index * BULK_DOWNLOAD_STAGGER_MS);
|
||
});
|
||
} catch (error) {
|
||
console.error('Échec du téléchargement groupé', error);
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Construit la ligne d'affichage d'un élément de la file.
|
||
*
|
||
* @param item - Élément à afficher.
|
||
* @param onChanged - Rappelé après une action (annulation/suppression) pour rafraîchir la liste.
|
||
* @returns L'élément `<li>` prêt à être inséré dans la liste.
|
||
*/
|
||
function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement {
|
||
const li = document.createElement('li');
|
||
li.className = `queue-panel__item queue-panel__item--${item.status}`;
|
||
|
||
const title = document.createElement('p');
|
||
title.className = 'queue-panel__item-title';
|
||
title.textContent = item.title ?? item.url;
|
||
li.append(title);
|
||
|
||
const meta = document.createElement('p');
|
||
meta.className = 'queue-panel__item-meta';
|
||
meta.textContent = `${MODE_LABELS[item.mode.kind]} · ${STATUS_LABELS[item.status]}`;
|
||
li.append(meta);
|
||
|
||
if (isActive(item)) {
|
||
const progress = document.createElement('progress');
|
||
progress.className = 'queue-panel__item-progress';
|
||
progress.max = 100;
|
||
progress.value = item.progress;
|
||
li.append(progress);
|
||
}
|
||
|
||
if (item.status === 'failed' && item.error) {
|
||
const error = document.createElement('p');
|
||
error.className = 'queue-panel__item-error';
|
||
error.textContent = item.error;
|
||
li.append(error);
|
||
}
|
||
|
||
if (item.status === 'completed' && !item.fileName) {
|
||
const note = document.createElement('p');
|
||
note.className = 'queue-panel__item-meta';
|
||
note.textContent = 'Déjà téléchargé';
|
||
li.append(note);
|
||
}
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'queue-panel__item-actions';
|
||
|
||
const removeButton = document.createElement('button');
|
||
removeButton.type = 'button';
|
||
removeButton.className = 'secondary';
|
||
removeButton.textContent = isActive(item) ? 'Annuler' : 'Supprimer';
|
||
removeButton.addEventListener('click', () => {
|
||
handleRemove(item.id, removeButton, onChanged).catch((error: unknown) => {
|
||
console.error('Erreur inattendue lors de la suppression', error);
|
||
});
|
||
});
|
||
|
||
if (isDownloadable(item)) {
|
||
const link = document.createElement('a');
|
||
link.href = queueItemFileUrl(item.id);
|
||
link.textContent = 'Télécharger';
|
||
link.className = 'queue-panel__item-download';
|
||
actions.append(link);
|
||
}
|
||
|
||
actions.append(removeButton);
|
||
li.append(actions);
|
||
return li;
|
||
}
|
||
|
||
/**
|
||
* Rend la liste des éléments dans le panneau et ajuste la visibilité du
|
||
* bouton "Tout télécharger" selon qu'il existe au moins un fichier prêt.
|
||
*
|
||
* @param root - Racine du panneau monté.
|
||
* @param items - Éléments à afficher.
|
||
* @param onChanged - Rappelé après une action sur un élément (pour rafraîchir la liste).
|
||
*/
|
||
function render(root: HTMLElement, items: readonly QueueItemDto[], onChanged: () => void): void {
|
||
const list = root.querySelector<HTMLElement>('[data-role="list"]');
|
||
const empty = root.querySelector<HTMLElement>('[data-role="empty"]');
|
||
const downloadAllButton = root.querySelector<HTMLButtonElement>('[data-role="download-all"]');
|
||
if (!list || !empty) {
|
||
return;
|
||
}
|
||
|
||
empty.hidden = items.length > 0;
|
||
list.replaceChildren(...items.map((item) => renderItem(item, onChanged)));
|
||
|
||
if (downloadAllButton) {
|
||
downloadAllButton.hidden = !items.some(isDownloadable);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Monte le panneau de file d'attente dans l'élément racine fourni, et
|
||
* démarre le sondage périodique de `GET /api/queue`.
|
||
*
|
||
* @param root - Élément DOM dans lequel injecter le panneau.
|
||
* @returns Un contrôleur permettant de déclencher un rafraîchissement immédiat.
|
||
*/
|
||
export function mountQueuePanel(root: HTMLElement): QueuePanelController {
|
||
root.innerHTML = queuePanelTemplate;
|
||
let isRefreshing = false;
|
||
|
||
const refresh = (): void => {
|
||
if (isRefreshing) {
|
||
return;
|
||
}
|
||
isRefreshing = true;
|
||
listQueueItems()
|
||
.then((items) => {
|
||
render(root, items, refresh);
|
||
})
|
||
.catch((error: unknown) => {
|
||
console.error('Échec du chargement de la file d’attente', error);
|
||
})
|
||
.finally(() => {
|
||
isRefreshing = false;
|
||
});
|
||
};
|
||
|
||
const downloadAllButton = root.querySelector<HTMLButtonElement>('[data-role="download-all"]');
|
||
downloadAllButton?.addEventListener('click', () => {
|
||
handleDownloadAll(downloadAllButton).catch((error: unknown) => {
|
||
console.error('Erreur inattendue lors du téléchargement groupé', error);
|
||
});
|
||
});
|
||
|
||
refresh();
|
||
setInterval(refresh, POLL_INTERVAL_MS);
|
||
|
||
return { refresh };
|
||
}
|