Version 2.1.1 - modification d'une certaine UI
This commit is contained in:
1 parent
54bbb2ca04
commit
bb82285e68
9 files changed
+604
-89
No files matched your search
@@ -73,6 +73,7 @@ import AdminMetrics from './AdminMetrics.vue';
|
|||||||
import AdminMaintenance from './AdminMaintenance.vue';
|
import AdminMaintenance from './AdminMaintenance.vue';
|
||||||
import AdminPlaylists from './AdminPlaylists.vue';
|
import AdminPlaylists from './AdminPlaylists.vue';
|
||||||
import AdminConfig from './AdminConfig.vue';
|
import AdminConfig from './AdminConfig.vue';
|
||||||
|
import AdminMedia from './AdminMedia.vue';
|
||||||
import { ADMIN_NAV_KEY, ADMIN_SECTION_TITLE_ID } from './adminUi';
|
import { ADMIN_NAV_KEY, ADMIN_SECTION_TITLE_ID } from './adminUi';
|
||||||
import './admin.scss';
|
import './admin.scss';
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ const sections = [
|
|||||||
{ id: 'servers', group: 'supervision', label: "Serveurs", icon: "fa-solid fa-server", component: AdminServers },
|
{ id: 'servers', group: 'supervision', label: "Serveurs", icon: "fa-solid fa-server", component: AdminServers },
|
||||||
{ id: 'users', group: 'supervision', label: "Comptes", icon: "fa-solid fa-users", component: AdminUsers },
|
{ id: 'users', group: 'supervision', label: "Comptes", icon: "fa-solid fa-users", component: AdminUsers },
|
||||||
{ id: 'playlists', group: 'supervision', label: "Playlists", icon: "fa-solid fa-list", component: AdminPlaylists },
|
{ id: 'playlists', group: 'supervision', label: "Playlists", icon: "fa-solid fa-list", component: AdminPlaylists },
|
||||||
|
{ id: 'media', group: 'supervision', label: "Médias", icon: "fa-solid fa-file-audio", component: AdminMedia },
|
||||||
{ id: 'logs', group: 'system', label: "Logs", icon: "fa-solid fa-scroll", component: AdminLogs },
|
{ id: 'logs', group: 'system', label: "Logs", icon: "fa-solid fa-scroll", component: AdminLogs },
|
||||||
{ id: 'warp', group: 'system', label: "WARP", icon: "fa-solid fa-cloud", component: AdminWarp },
|
{ id: 'warp', group: 'system', label: "WARP", icon: "fa-solid fa-cloud", component: AdminWarp },
|
||||||
{ id: 'youtube', group: 'system', label: "YouTube", icon: "fa-brands fa-youtube", component: AdminYoutube },
|
{ id: 'youtube', group: 'system', label: "YouTube", icon: "fa-brands fa-youtube", component: AdminYoutube },
|
||||||
@@ -145,6 +147,10 @@ const badges = computed(() => {
|
|||||||
if (failing) result.youtube = { text: "!", type: 'bad', sr: "la plupart des lectures récentes échouent" };
|
if (failing) result.youtube = { text: "!", type: 'bad', sr: "la plupart des lectures récentes échouent" };
|
||||||
else if (ytCookies?.present && !ytCookies.valid) result.youtube = { text: "!", type: 'bad', sr: "compte YouTube à reconnecter" };
|
else if (ytCookies?.present && !ytCookies.valid) result.youtube = { text: "!", type: 'bad', sr: "compte YouTube à reconnecter" };
|
||||||
else if (ytCookies?.valid) result.youtube = { text: "OK", type: 'good', sr: "compte YouTube valide" };
|
else if (ytCookies?.valid) result.youtube = { text: "OK", type: 'good', sr: "compte YouTube valide" };
|
||||||
|
// Médias : salon de stockage injoignable ou fichiers supprimés sur Discord
|
||||||
|
const media = adminStore.overview?.media;
|
||||||
|
if (media?.configured && !media.connected) result.media = { text: "KO", type: 'bad', sr: "salon de stockage inaccessible" };
|
||||||
|
else if (media?.missing) result.media = { text: media.missing, type: 'warn', sr: media.missing + " fichier(s) manquant(s) sur Discord" };
|
||||||
if (adminStore.overview?.logs?.interrupted) result.logs = { text: "!", type: 'bad', sr: "journal interrompu" };
|
if (adminStore.overview?.logs?.interrupted) result.logs = { text: "!", type: 'bad', sr: "journal interrompu" };
|
||||||
const online = adminStore.presence?.counts?.online ?? adminStore.overview?.web?.usersOnline;
|
const online = adminStore.presence?.counts?.online ?? adminStore.overview?.web?.usersOnline;
|
||||||
if (online) result.users = { text: online, type: 'neutral', sr: online + " en ligne" };
|
if (online) result.users = { text: online, type: 'neutral', sr: online + " en ligne" };
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
<template>
|
||||||
|
<AdminSection title="Médias" icon="fa-solid fa-file-audio" subtitle="Fichiers envoyés par les utilisateurs, stockés dans le salon Discord multimédia">
|
||||||
|
<template #actions>
|
||||||
|
<Button variant="secondary" icon="fa-solid fa-link" :loading="refreshingAll" :disabled="!status?.connected || !data"
|
||||||
|
@click="refreshAll()">Renouveler les liens</Button>
|
||||||
|
<Button variant="secondary" icon="fa-solid fa-rotate" :loading="loading" @click="load()">Actualiser</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<AdminBanner v-if="status && !status.configured" tone="warning" icon="fa-solid fa-triangle-exclamation" title="Stockage non configuré">
|
||||||
|
Renseignez le serveur et le salon multimédia (media.guildId / media.channelId) pour permettre l'envoi de fichiers.
|
||||||
|
<template #action>
|
||||||
|
<Button size="sm" variant="secondary" icon="fa-solid fa-sliders" @click="adminStore.setSection('config')">Configuration</Button>
|
||||||
|
</template>
|
||||||
|
</AdminBanner>
|
||||||
|
<AdminBanner v-else-if="status && !status.connected" tone="error" icon="fa-solid fa-plug-circle-xmark" title="Salon de stockage inaccessible">
|
||||||
|
Le bot ne voit pas le salon configuré (salon supprimé, bot retiré du serveur ou permissions manquantes).
|
||||||
|
Les envois sont refusés et les liens ne peuvent pas être renouvelés.
|
||||||
|
</AdminBanner>
|
||||||
|
|
||||||
|
<AdminState :loading="!data && !error" :error="!data ? error : null" @retry="load()">
|
||||||
|
<AdminStatRow :columns="4" label="Résumé des médias">
|
||||||
|
<AdminStat label="Fichiers" :value="totals.files" icon="fa-solid fa-file-audio" tone="main"
|
||||||
|
:sub="totals.missing ? totals.missing + ' manquant(s) sur Discord' : totals.owners + ' utilisateur(s)'"/>
|
||||||
|
<AdminStat label="Espace occupé" :value="formatBytes(totals.size)" icon="fa-solid fa-hard-drive" tone="info"
|
||||||
|
:sub="totals.saved > 0 ? formatBytes(totals.saved) + ' économisés par la conversion' : 'Sur le CDN Discord'"/>
|
||||||
|
<AdminStat label="Liens valides" :value="totals.valid + ' / ' + totals.active" icon="fa-solid fa-link" tone="success"
|
||||||
|
:progress="totals.active ? totals.valid / totals.active : null" progress-tone="auto-inverse"
|
||||||
|
:progress-label="'Part des liens encore valides'"/>
|
||||||
|
<AdminStat label="À renouveler" :value="totals.expired + totals.soon" icon="fa-solid fa-hourglass-half"
|
||||||
|
:tone="totals.expired ? 'warning' : 'default'"
|
||||||
|
:sub="totals.expired + ' expiré(s) · ' + totals.soon + ' sous 6 h'"/>
|
||||||
|
</AdminStatRow>
|
||||||
|
|
||||||
|
<AdminGrid>
|
||||||
|
<AdminCard title="Stockage" icon="fa-brands fa-discord" :span="5" :span-md="12"
|
||||||
|
:subtitle="status?.connected ? 'Connecté' : 'Indisponible'" :tone="status?.connected ? 'default' : 'error'">
|
||||||
|
<template #actions>
|
||||||
|
<Tag :tone="status?.connected ? 'success' : 'error'" :icon="status?.connected ? 'fa-solid fa-circle-check' : 'fa-solid fa-circle-xmark'">
|
||||||
|
{{ status?.connected ? "En ligne" : "Hors ligne" }}
|
||||||
|
</Tag>
|
||||||
|
</template>
|
||||||
|
<AdminKv label="Serveur" :value="status?.guildName || status?.guildId || 'Non configuré'"/>
|
||||||
|
<AdminKv label="Salon" :value="status?.channelName ? '#' + status.channelName : (status?.channelId || 'Non configuré')"/>
|
||||||
|
<AdminKv label="Niveau de boost">
|
||||||
|
{{ status?.premiumTier === null || status?.premiumTier === undefined ? "Inconnu" : "Niveau " + status.premiumTier }}
|
||||||
|
</AdminKv>
|
||||||
|
<AdminKv label="Limite par fichier">
|
||||||
|
{{ formatBytes(status?.storageLimitBytes) }}<Tag v-if="status?.limitForced" class="inline-tag" tone="info">MEDIA_UPLOAD_LIMIT_MB</Tag>
|
||||||
|
</AdminKv>
|
||||||
|
<AdminKv label="Durée maximale" :value="status?.maxDurationSec ? '≈ ' + Math.floor(status.maxDurationSec / 60) + ' min par fichier' : '—'"/>
|
||||||
|
<AdminKv label="Fichier d'origine" :value="formatBytes(status?.maxInputBytes) + ' maximum'"/>
|
||||||
|
<AdminKv label="Format stocké" :value="'Ogg/Opus, ' + Math.round(safeNum(status?.preferredBitrate) / 1000) + ' kb/s au plus'"/>
|
||||||
|
</AdminCard>
|
||||||
|
|
||||||
|
<AdminCard title="Expiration des liens" icon="fa-solid fa-hourglass-half" :span="7" :span-md="12"
|
||||||
|
subtitle="Les liens du CDN Discord sont valables environ 24 h">
|
||||||
|
<div class="expiry-bars">
|
||||||
|
<AdminMeter v-for="bucket in buckets" :key="bucket.key" :label="bucket.label" show-value
|
||||||
|
:value="totals.active ? bucket.count / totals.active : 0" :tone="bucket.tone"
|
||||||
|
:value-text="bucket.count + ' fichier(s)'"/>
|
||||||
|
</div>
|
||||||
|
<p class="adm-hint">
|
||||||
|
<Icon icon="fa-solid fa-circle-info" aria-hidden="true"/>
|
||||||
|
Un lien expiré n'empêche pas la lecture : il est renouvelé automatiquement avant chaque lecture et à l'ouverture de « Mes fichiers ».
|
||||||
|
Le renouvellement manuel sert à vérifier qu'un fichier existe encore sur Discord.
|
||||||
|
</p>
|
||||||
|
<template v-if="nextExpiry" #footer>
|
||||||
|
<span class="adm-footer-meta">Prochaine expiration {{ formatRelative(nextExpiry.expiresAt, nowDate) }} : « {{ displayTitle(nextExpiry) }} »</span>
|
||||||
|
</template>
|
||||||
|
</AdminCard>
|
||||||
|
</AdminGrid>
|
||||||
|
|
||||||
|
<AdminToolbar v-model:search="search" search-label="Rechercher un média" search-placeholder="Titre, fichier, artiste, utilisateur ou ID…" label="Filtres des médias">
|
||||||
|
<AdminPills v-model="filter" :options="filterOptions" label="État du lien"/>
|
||||||
|
</AdminToolbar>
|
||||||
|
|
||||||
|
<p class="sr-only" aria-live="polite">{{ filtered.length }} média(s) affiché(s)</p>
|
||||||
|
|
||||||
|
<AdminCard title="Fichiers" icon="fa-solid fa-list" :span="12" flush :busy="loading"
|
||||||
|
:subtitle="filtered.length + ' fichier(s) affiché(s) sur ' + items.length">
|
||||||
|
<AdminState :empty="filtered.length === 0" :retry="false" :empty-icon="search || filter !== 'all' ? 'fa-solid fa-filter' : 'fa-solid fa-file-audio'"
|
||||||
|
:empty-text="search || filter !== 'all' ? 'Aucun média ne correspond à ces critères' : 'Aucun fichier envoyé pour l\'instant'">
|
||||||
|
<AdminList divided label="Médias envoyés">
|
||||||
|
<AdminRow v-for="item in shown" :key="item.id" :tone="item.missing ? 'error' : 'default'">
|
||||||
|
<template #media>
|
||||||
|
<span class="adm-cover">
|
||||||
|
<img v-if="item.thumbnail && !brokenCovers.has(item.id)" :src="item.thumbnail" alt="" loading="lazy"
|
||||||
|
referrerpolicy="no-referrer" @error="markBroken(item.id)"/>
|
||||||
|
<Icon v-else icon="fa-solid fa-music" aria-hidden="true"/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #title>{{ displayTitle(item) }}</template>
|
||||||
|
<template #subtitle>
|
||||||
|
<span v-if="item.author">{{ item.author }} · </span>
|
||||||
|
<span v-if="item.duration">{{ formatClock(item.duration * 1000) }} · </span>
|
||||||
|
<span :title="item.originalSize ? 'Fichier d\'origine : ' + formatBytes(item.originalSize) : null">{{ formatBytes(item.size) }}</span>
|
||||||
|
<span v-if="item.bitrate"> · {{ Math.round(item.bitrate / 1000) }} kb/s</span>
|
||||||
|
<span :title="formatDate(item.createdAt)"> · envoyé {{ formatRelative(item.createdAt, nowDate) }}</span>
|
||||||
|
<span class="adm-mono"> · #{{ item.id }}</span>
|
||||||
|
</template>
|
||||||
|
<div class="adm-tags">
|
||||||
|
<Tag :tone="expiry(item).tone" :icon="expiry(item).icon" :title="item.expiresAt ? 'Expiration : ' + formatDate(item.expiresAt) : null">
|
||||||
|
{{ expiry(item).label }}
|
||||||
|
</Tag>
|
||||||
|
<Tag v-if="item.format === 'opus'" tone="info" icon="fa-solid fa-wave-square">Opus</Tag>
|
||||||
|
<Tag v-else tone="warning" icon="fa-solid fa-file-circle-exclamation" title="Envoyé avant la conversion Opus : décodé par ffmpeg à chaque lecture">Non converti</Tag>
|
||||||
|
<Tag v-if="item.playlists" icon="fa-solid fa-list">Dans {{ item.playlists }} playlist{{ item.playlists > 1 ? 's' : '' }}</Tag>
|
||||||
|
<span v-if="item.name && item.name !== displayTitle(item)" class="file-name adm-mono u-ellipsis" :title="item.name">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
<template #meta>
|
||||||
|
<AdminUserLine v-if="item.uploader" :user="item.uploader" :online="adminStore.onlineIds.has(item.userId)" size="28px"/>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<a v-if="item.url && !item.missing && expiry(item).key !== 'expired'" class="adm-iconbtn" :href="item.url" target="_blank" rel="noopener noreferrer"
|
||||||
|
:aria-label="'Écouter « ' + displayTitle(item) + ' » (nouvel onglet)'" title="Écouter dans un nouvel onglet">
|
||||||
|
<Icon icon="fa-solid fa-headphones" aria-hidden="true"/>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="adm-iconbtn" :disabled="!status?.connected || busy.has(item.id)"
|
||||||
|
:aria-label="'Renouveler le lien de « ' + displayTitle(item) + ' »'" title="Renouveler le lien / vérifier sur Discord"
|
||||||
|
@click="refreshOne(item)">
|
||||||
|
<Icon icon="fa-solid fa-rotate" :spin="busy.has(item.id)" aria-hidden="true"/>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="adm-iconbtn adm-iconbtn--danger" :disabled="busy.has(item.id)"
|
||||||
|
:aria-label="'Supprimer « ' + displayTitle(item) + ' »'" title="Supprimer" @click="remove(item)">
|
||||||
|
<Icon icon="fa-solid fa-trash" aria-hidden="true"/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</AdminRow>
|
||||||
|
</AdminList>
|
||||||
|
</AdminState>
|
||||||
|
<template v-if="filtered.length > shown.length" #footer>
|
||||||
|
<span class="adm-footer-meta">{{ shown.length }} sur {{ filtered.length }}</span>
|
||||||
|
<Button size="sm" variant="secondary" icon="fa-solid fa-chevron-down" @click="limit += PAGE_SIZE">
|
||||||
|
Afficher plus ({{ filtered.length - shown.length }} restant(s))
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</AdminCard>
|
||||||
|
</AdminState>
|
||||||
|
|
||||||
|
<AdminConfirm ref="confirmRef"/>
|
||||||
|
</AdminSection>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Section « Médias » : fichiers envoyés (stockés en Ogg/Opus dans le salon Discord multimédia).
|
||||||
|
* /ADMIN/MEDIA/LIST → {status, items}, /ADMIN/MEDIA/REFRESH {id?}, /ADMIN/MEDIA/DELETE {id} ;
|
||||||
|
* poussée "/ADMIN/MEDIA/UPDATE" (envoi, suppression, liens renouvelés) → relecture.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useAdminStore } from '@/stores/adminStore';
|
||||||
|
import { adminRequest, adminListen, describeError } from '@/utils/AdminRequest';
|
||||||
|
import Button from '@/components/UI/Button.vue';
|
||||||
|
import Tag from '@/components/UI/Tag.vue';
|
||||||
|
import AdminSection from './AdminSection.vue';
|
||||||
|
import AdminBanner from './AdminBanner.vue';
|
||||||
|
import AdminState from './AdminState.vue';
|
||||||
|
import AdminStat from './AdminStat.vue';
|
||||||
|
import AdminStatRow from './AdminStatRow.vue';
|
||||||
|
import AdminGrid from './AdminGrid.vue';
|
||||||
|
import AdminCard from './AdminCard.vue';
|
||||||
|
import AdminKv from './AdminKv.vue';
|
||||||
|
import AdminMeter from './AdminMeter.vue';
|
||||||
|
import AdminToolbar from './AdminToolbar.vue';
|
||||||
|
import AdminPills from './AdminPills.vue';
|
||||||
|
import AdminList from './AdminList.vue';
|
||||||
|
import AdminRow from './AdminRow.vue';
|
||||||
|
import AdminUserLine from './AdminUserLine.vue';
|
||||||
|
import AdminConfirm from './AdminConfirm.vue';
|
||||||
|
import { safeNum, formatBytes, formatClock, formatDate, formatRelative } from './adminFormat';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const HOUR = 3600 * 1000;
|
||||||
|
// En dessous : lien signalé « bientôt expiré »
|
||||||
|
const SOON = 6 * HOUR;
|
||||||
|
|
||||||
|
const adminStore = useAdminStore();
|
||||||
|
const confirmRef = ref(null);
|
||||||
|
|
||||||
|
const data = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
const refreshingAll = ref(false);
|
||||||
|
const busy = ref(new Set());
|
||||||
|
const search = ref("");
|
||||||
|
const filter = ref('all');
|
||||||
|
const limit = ref(PAGE_SIZE);
|
||||||
|
const nowDate = ref(Date.now());
|
||||||
|
|
||||||
|
const status = computed(() => data.value?.status || null);
|
||||||
|
const items = computed(() => (Array.isArray(data.value?.items) ? data.value.items : []));
|
||||||
|
|
||||||
|
function displayTitle(item) {
|
||||||
|
return item.title || item.name || "Sans titre";
|
||||||
|
}
|
||||||
|
|
||||||
|
// État du lien : missing (message supprimé), expired (expiré ou non signé), soon (< 6 h), valid
|
||||||
|
function expiry(item) {
|
||||||
|
if (item.missing) return { key: 'missing', label: "Manquant sur Discord", tone: 'error', icon: "fa-solid fa-link-slash" };
|
||||||
|
if (!item.expiresAt) return { key: 'expired', label: "Lien non signé", tone: 'warning', icon: "fa-solid fa-clock-rotate-left" };
|
||||||
|
const left = Date.parse(item.expiresAt) - nowDate.value;
|
||||||
|
if (!(left > 0)) return { key: 'expired', label: "Expiré " + formatRelative(item.expiresAt, nowDate.value), tone: 'warning', icon: "fa-solid fa-hourglass-end" };
|
||||||
|
if (left < SOON) return { key: 'soon', label: "Expire " + formatRelative(item.expiresAt, nowDate.value), tone: 'warning', icon: "fa-solid fa-hourglass-half" };
|
||||||
|
return { key: 'valid', label: "Expire " + formatRelative(item.expiresAt, nowDate.value), tone: 'success', icon: "fa-solid fa-hourglass-start" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = computed(() => {
|
||||||
|
const list = items.value;
|
||||||
|
const counts = { valid: 0, soon: 0, expired: 0, missing: 0 };
|
||||||
|
let size = 0;
|
||||||
|
let saved = 0;
|
||||||
|
for (const item of list) {
|
||||||
|
counts[expiry(item).key]++;
|
||||||
|
size += safeNum(item.size);
|
||||||
|
if (item.originalSize > item.size) saved += item.originalSize - item.size;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
files: list.length,
|
||||||
|
owners: new Set(list.map(i => i.userId)).size,
|
||||||
|
active: list.length - counts.missing,
|
||||||
|
size,
|
||||||
|
saved,
|
||||||
|
...counts
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const buckets = computed(() => {
|
||||||
|
const result = [
|
||||||
|
{ key: 'expired', label: "Expirés", tone: 'warning', count: 0 },
|
||||||
|
{ key: 'h1', label: "Moins d'une heure", tone: 'warning', count: 0 },
|
||||||
|
{ key: 'h6', label: "Moins de 6 heures", tone: 'main', count: 0 },
|
||||||
|
{ key: 'later', label: "Plus de 6 heures", tone: 'success', count: 0 }
|
||||||
|
];
|
||||||
|
for (const item of items.value) {
|
||||||
|
if (item.missing) continue;
|
||||||
|
const left = item.expiresAt ? Date.parse(item.expiresAt) - nowDate.value : 0;
|
||||||
|
if (!(left > 0)) result[0].count++;
|
||||||
|
else if (left < HOUR) result[1].count++;
|
||||||
|
else if (left < SOON) result[2].count++;
|
||||||
|
else result[3].count++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextExpiry = computed(() => items.value
|
||||||
|
.filter(item => !item.missing && item.expiresAt && Date.parse(item.expiresAt) > nowDate.value)
|
||||||
|
.sort((a, b) => Date.parse(a.expiresAt) - Date.parse(b.expiresAt))[0] || null);
|
||||||
|
|
||||||
|
const filterOptions = computed(() => [
|
||||||
|
{ value: 'all', label: "Tous", icon: "fa-solid fa-layer-group", count: totals.value.files },
|
||||||
|
{ value: 'valid', label: "Valides", icon: "fa-solid fa-hourglass-start", count: totals.value.valid },
|
||||||
|
{ value: 'soon', label: "Bientôt expirés", icon: "fa-solid fa-hourglass-half", count: totals.value.soon },
|
||||||
|
{ value: 'expired', label: "Expirés", icon: "fa-solid fa-hourglass-end", count: totals.value.expired },
|
||||||
|
{ value: 'missing', label: "Manquants", icon: "fa-solid fa-link-slash", count: totals.value.missing },
|
||||||
|
{ value: 'legacy', label: "Non convertis", icon: "fa-solid fa-file-circle-exclamation", count: items.value.filter(i => i.format !== 'opus').length }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const query = search.value.trim().toLowerCase();
|
||||||
|
return items.value.filter(item => {
|
||||||
|
if (filter.value === 'legacy' ? item.format === 'opus' : filter.value !== 'all' && expiry(item).key !== filter.value) return false;
|
||||||
|
if (!query) return true;
|
||||||
|
return [item.title, item.name, item.author, item.album, item.uploader?.username, item.uploader?.global_name, item.userId, item.id]
|
||||||
|
.some(value => String(value || "").toLowerCase().includes(query));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const shown = computed(() => filtered.value.slice(0, limit.value));
|
||||||
|
|
||||||
|
watch([search, filter], () => { limit.value = PAGE_SIZE; });
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (loading.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
data.value = await adminRequest("/ADMIN/MEDIA/LIST", {}, 20000);
|
||||||
|
error.value = null;
|
||||||
|
nowDate.value = Date.now();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = describeError(e);
|
||||||
|
if (data.value) adminStore.notifyError(e, "Liste des médias");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(id, value) {
|
||||||
|
const next = new Set(busy.value);
|
||||||
|
if (value) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
busy.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceItem(id, item) {
|
||||||
|
if (!data.value) return;
|
||||||
|
data.value = { ...data.value, items: items.value.map(i => (i.id === id ? item : i)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOne(item) {
|
||||||
|
setBusy(item.id, true);
|
||||||
|
try {
|
||||||
|
const result = await adminRequest("/ADMIN/MEDIA/REFRESH", { id: item.id }, 30000);
|
||||||
|
if (result?.item) replaceItem(item.id, result.item);
|
||||||
|
if (result?.item?.missing) adminStore.notify('warning', `« ${displayTitle(item)} » n'existe plus sur Discord`);
|
||||||
|
else adminStore.notify('success', `Lien de « ${displayTitle(item)} » renouvelé`);
|
||||||
|
} catch (e) {
|
||||||
|
adminStore.notifyError(e, "Renouvellement du lien");
|
||||||
|
if (e?.code === 'NOT_FOUND') load();
|
||||||
|
} finally {
|
||||||
|
setBusy(item.id, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
refreshingAll.value = true;
|
||||||
|
try {
|
||||||
|
const result = await adminRequest("/ADMIN/MEDIA/REFRESH", {}, 120000);
|
||||||
|
const checked = safeNum(result?.checked);
|
||||||
|
if (!checked) adminStore.notify('info', "Tous les liens sont encore valides");
|
||||||
|
else adminStore.notify(result.missing ? 'warning' : 'success',
|
||||||
|
`${safeNum(result.refreshed)} lien(s) renouvelé(s) sur ${checked}` + (result.missing ? ` · ${result.missing} fichier(s) manquant(s)` : ""));
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
adminStore.notifyError(e, "Renouvellement des liens");
|
||||||
|
} finally {
|
||||||
|
refreshingAll.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(item) {
|
||||||
|
const owner = item.uploader?.global_name || item.uploader?.username || item.userId;
|
||||||
|
const ok = await confirmRef.value.ask({
|
||||||
|
title: "Supprimer le média",
|
||||||
|
icon: "fa-solid fa-trash",
|
||||||
|
message: `« ${displayTitle(item)} » (envoyé par ${owner}) sera supprimé du stockage, message Discord compris.`
|
||||||
|
+ (item.playlists ? `\nIl figure dans ${item.playlists} playlist(s) : ces titres ne pourront plus être lus.` : ""),
|
||||||
|
confirmLabel: "Supprimer",
|
||||||
|
confirmIcon: "fa-solid fa-trash"
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
setBusy(item.id, true);
|
||||||
|
try {
|
||||||
|
await adminRequest("/ADMIN/MEDIA/DELETE", { id: item.id }, 30000);
|
||||||
|
if (data.value) data.value = { ...data.value, items: items.value.filter(i => i.id !== item.id) };
|
||||||
|
adminStore.notify('success', `« ${displayTitle(item)} » supprimé`);
|
||||||
|
} catch (e) {
|
||||||
|
adminStore.notifyError(e, "Suppression du média");
|
||||||
|
if (e?.code === 'NOT_FOUND') load();
|
||||||
|
} finally {
|
||||||
|
setBusy(item.id, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pochettes dont le lien a expiré : remplacées par une icône
|
||||||
|
const brokenCovers = ref(new Set());
|
||||||
|
|
||||||
|
function markBroken(id) {
|
||||||
|
brokenCovers.value = new Set(brokenCovers.value).add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les liens renouvelés changent aussi l'URL des pochettes : on réessaie leur affichage
|
||||||
|
watch(data, () => { brokenCovers.value = new Set(); });
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
let stopListening = null;
|
||||||
|
let pushTimer = null;
|
||||||
|
|
||||||
|
// Poussée regroupée : une relecture, sauf pendant une action en cours (elle relit elle-même)
|
||||||
|
function onMediaPushed() {
|
||||||
|
clearTimeout(pushTimer);
|
||||||
|
pushTimer = setTimeout(() => {
|
||||||
|
if (!refreshingAll.value && busy.value.size === 0) load();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load();
|
||||||
|
timer = setInterval(() => { nowDate.value = Date.now(); }, 30000);
|
||||||
|
stopListening = adminListen("/ADMIN/MEDIA/UPDATE", onMediaPushed);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearInterval(timer);
|
||||||
|
clearTimeout(pushTimer);
|
||||||
|
stopListening?.();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.expiry-bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-tag {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
max-width: 260px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -152,8 +152,12 @@
|
|||||||
<AdminKv v-if="data.media?.configured" label="Connexion">
|
<AdminKv v-if="data.media?.configured" label="Connexion">
|
||||||
<Tag :tone="data.media?.connected ? 'success' : 'error'">{{ data.media?.connected ? "Connecté" : "Déconnecté" }}</Tag>
|
<Tag :tone="data.media?.connected ? 'success' : 'error'">{{ data.media?.connected ? "Connecté" : "Déconnecté" }}</Tag>
|
||||||
</AdminKv>
|
</AdminKv>
|
||||||
|
<AdminKv v-if="data.media?.configured" label="Fichiers envoyés">
|
||||||
|
{{ data.media?.files ?? 0 }}<Tag v-if="data.media?.missing" class="media-missing" tone="warning">{{ data.media.missing }} manquant(s)</Tag>
|
||||||
|
</AdminKv>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button size="sm" variant="ghost" icon-right="fa-solid fa-arrow-right" @click="adminStore.setSection('logs')">Ouvrir les logs</Button>
|
<Button size="sm" variant="ghost" icon-right="fa-solid fa-arrow-right" @click="adminStore.setSection('logs')">Ouvrir les logs</Button>
|
||||||
|
<Button size="sm" variant="ghost" icon-right="fa-solid fa-arrow-right" @click="adminStore.setSection('media')">Médias</Button>
|
||||||
</template>
|
</template>
|
||||||
</AdminCard>
|
</AdminCard>
|
||||||
|
|
||||||
@@ -295,6 +299,10 @@ onUnmounted(() => {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-missing {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.db-icon {
|
.db-icon {
|
||||||
--adm-cover-size: 32px;
|
--adm-cover-size: 32px;
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<span v-else></span>
|
<span v-else></span>
|
||||||
<div :class="{'actions-div': true, 'offline': !online || !video}" role="group" aria-label="Contrôles de lecture">
|
<div :class="{'actions-div': true, 'offline': !online || !video}" role="group" aria-label="Contrôles de lecture">
|
||||||
<button type="button" :class="{'ctrl': true, 'itm-sec': true, 'playIcon': online && video, 'enabled': online}" aria-label="Précédent" title="Précédent" :disabled="!online" @click="backward"><Backward aria-hidden="true"/></button>
|
<button type="button" :class="{'ctrl': true, 'itm-sec': true, 'playIcon': online && video, 'enabled': online, 'enhanced': true}" aria-label="Précédent" title="Précédent" :disabled="!online" @click="backward"><Backward aria-hidden="true"/></button>
|
||||||
<button type="button" :class="{'ctrl': true, 'itm-main': true, 'playIcon': online && video}" :aria-label="pauseTitle" :title="pauseTitle" :aria-busy="pauseBusy ? 'true' : undefined" :disabled="!online || !video" @click="togglePause()">
|
<button type="button" :class="{'ctrl': true, 'itm-main': true, 'playIcon': online && video, 'enhanced-play': true}" :aria-label="pauseTitle" :title="pauseTitle" :aria-busy="pauseBusy ? 'true' : undefined" :disabled="!online || !video" @click="togglePause()">
|
||||||
<Play v-if="showPlayIcon" aria-hidden="true"/>
|
<Play v-if="showPlayIcon" aria-hidden="true"/>
|
||||||
<Pause v-else aria-hidden="true"/>
|
<Pause v-else aria-hidden="true"/>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" :class="{'ctrl': true, 'itm-sec': true, 'playIcon': online && video, 'enabled': online}" aria-label="Suivant" title="Suivant" :disabled="!online" @click="forward"><Forward aria-hidden="true"/></button>
|
<button type="button" :class="{'ctrl': true, 'itm-sec': true, 'playIcon': online && video, 'enabled': online, 'enhanced': true}" aria-label="Suivant" title="Suivant" :disabled="!online" @click="forward"><Forward aria-hidden="true"/></button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="online" class="actions-div" role="group" aria-label="Salon vocal">
|
<div v-if="online" class="actions-div" role="group" aria-label="Salon vocal">
|
||||||
<CustomIcon label="Changer de salon" @click="changeChannel"><Hand/></CustomIcon>
|
<CustomIcon label="Changer de salon" @click="changeChannel"><Hand/></CustomIcon>
|
||||||
@@ -43,12 +43,12 @@
|
|||||||
<DurationBar mobile class="duration-bar" v-bind="durationBarProps" @seek="seek" />
|
<DurationBar mobile class="duration-bar" v-bind="durationBarProps" @seek="seek" />
|
||||||
<span class="actions-container">
|
<span class="actions-container">
|
||||||
<div :class="{'actions-div': true, 'offline': !online || !video}" role="group" aria-label="Contrôles de lecture">
|
<div :class="{'actions-div': true, 'offline': !online || !video}" role="group" aria-label="Contrôles de lecture">
|
||||||
<button type="button" :class="{'ctrl': true, 'itm-sec': true, 'playIcon': online && video, 'enabled': online}" aria-label="Précédent" title="Précédent" :disabled="!online" @click="backward"><Backward aria-hidden="true"/></button>
|
<button type="button" :class="{'ctrl': true, 'itm-sec': true, 'playIcon': online && video, 'enabled': online, 'enhanced': true}" aria-label="Précédent" title="Précédent" :disabled="!online" @click="backward"><Backward aria-hidden="true"/></button>
|
||||||
<button type="button" :class="{'ctrl': true, 'itm-main': true, 'playIcon': online && video}" :aria-label="pauseTitle" :title="pauseTitle" :aria-busy="pauseBusy ? 'true' : undefined" :disabled="!online || !video" @click="togglePause()">
|
<button type="button" :class="{'ctrl': true, 'itm-main': true, 'playIcon': online && video, 'enhanced-play': true}" :aria-label="pauseTitle" :title="pauseTitle" :aria-busy="pauseBusy ? 'true' : undefined" :disabled="!online || !video" @click="togglePause()">
|
||||||
<Play v-if="showPlayIcon" aria-hidden="true"/>
|
<Play v-if="showPlayIcon" aria-hidden="true"/>
|
||||||
<Pause v-else aria-hidden="true"/>
|
<Pause v-else aria-hidden="true"/>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" :class="{'ctrl': true, 'itm-sec': true, 'playIcon': online && video, 'enabled': online}" aria-label="Suivant" title="Suivant" :disabled="!online" @click="forward"><Forward aria-hidden="true"/></button>
|
<button type="button" :class="{'ctrl': true, 'itm-sec': true, 'playIcon': online && video, 'enabled': online, 'enhanced': true}" aria-label="Suivant" title="Suivant" :disabled="!online" @click="forward"><Forward aria-hidden="true"/></button>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
<div class="player-tags" aria-live="polite">
|
<div class="player-tags" aria-live="polite">
|
||||||
@@ -632,6 +632,15 @@ async function seek(ms) {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.enhanced {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enhanced-play {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
:data-handle-id="element.playlistId"
|
:data-handle-id="element.playlistId"
|
||||||
:aria-label="`Déplacer « ${element.title} », position ${index + 1} sur ${fullList.length}`"
|
:aria-label="`Déplacer « ${element.title} », position ${index + 1} sur ${fullList.length}`"
|
||||||
:aria-describedby="helpId"
|
:aria-describedby="helpId"
|
||||||
|
@click="open(element)"
|
||||||
@keydown="onHandleKeydown($event, element)"
|
@keydown="onHandleKeydown($event, element)"
|
||||||
>
|
>
|
||||||
<Icon icon="fa-solid fa-grip-vertical" aria-hidden="true" />
|
<Icon icon="fa-solid fa-grip-vertical" aria-hidden="true" />
|
||||||
@@ -487,6 +488,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.playlist__row {
|
.playlist__row {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -497,26 +499,31 @@ onMounted(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Poignée de déplacement : visible au survol / au focus (toujours sur écran tactile) */
|
/* Poignée posée sur la pochette (aucune place réservée) : voile + icône au survol / au focus.
|
||||||
|
Un clic sans glisser ouvre la playlist, comme un clic sur la pochette. */
|
||||||
.playlist__handle {
|
.playlist__handle {
|
||||||
|
position: absolute;
|
||||||
|
/* Même position et même taille que .playlist-item__cover (marge interne de 6px, 44px) */
|
||||||
|
top: 50%;
|
||||||
|
left: 6px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
transform: translateY(-50%);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
|
||||||
width: 22px;
|
|
||||||
height: 44px;
|
|
||||||
margin-right: -2px;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 8px;
|
||||||
background: transparent;
|
background-color: rgba(0, 0, 0, 0.55);
|
||||||
color: var(--text-tertiary);
|
color: #fff;
|
||||||
font-size: 0.85em;
|
font-size: 0.95em;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
outline: 2px solid transparent;
|
outline: 2px solid transparent;
|
||||||
transition: opacity var(--dur-fast) ease, color var(--dur-fast) ease, background-color var(--dur-fast) ease;
|
z-index: 1;
|
||||||
|
transition: opacity var(--dur-fast) ease, background-color var(--dur-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist__row:hover .playlist__handle,
|
.playlist__row:hover .playlist__handle,
|
||||||
@@ -526,22 +533,29 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.playlist__handle:hover {
|
.playlist__handle:hover {
|
||||||
color: var(--text);
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
background-color: color-mix(in srgb, var(--text) 8%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist__handle:focus-visible {
|
.playlist__handle:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: inset 0 0 0 2px var(--main);
|
box-shadow: inset 0 0 0 2px var(--main);
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist__handle:active {
|
.playlist__handle:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Écran tactile : petite pastille dans le coin de la pochette, qui ne la masque pas */
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.playlist__handle {
|
.playlist__handle {
|
||||||
|
top: auto;
|
||||||
|
bottom: 6px;
|
||||||
|
left: 6px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
transform: none;
|
||||||
|
border-radius: 6px 0 6px 0;
|
||||||
|
font-size: 0.7em;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ async function restore(target, playlistId, song, position) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.songs__row {
|
.songs__row {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -380,34 +381,43 @@ async function restore(target, playlistId, song, position) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Poignée posée sur la colonne du numéro (aucune place réservée) : elle remplace le numéro au survol / au focus */
|
||||||
.songs__handle {
|
.songs__handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
/* Même retrait et même largeur que .song-row__index (0.8rem × 2.2em) */
|
||||||
|
left: 2px;
|
||||||
|
width: calc(0.8rem * 2.2 + 4px);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-end;
|
||||||
flex-shrink: 0;
|
padding: 0 2px 0 0;
|
||||||
width: 24px;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
opacity: 0.35;
|
opacity: 0;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
outline: 2px solid transparent;
|
outline: 2px solid transparent;
|
||||||
transition: opacity var(--dur-fast) ease, color var(--dur-fast) ease, background-color var(--dur-fast) ease;
|
z-index: 1;
|
||||||
|
transition: opacity var(--dur-fast) ease, color var(--dur-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.songs__row:hover .songs__handle,
|
.songs__row--sortable:hover .songs__handle,
|
||||||
.songs__row:focus-within .songs__handle {
|
.songs__row--sortable:focus-within .songs__handle {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.songs__row--sortable:hover :deep(.song-row__index),
|
||||||
|
.songs__row--sortable:focus-within :deep(.song-row__index) {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.songs__handle:hover {
|
.songs__handle:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background-color: color-mix(in srgb, var(--text) 8%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.songs__handle:focus-visible {
|
.songs__handle:focus-visible {
|
||||||
@@ -421,10 +431,15 @@ async function restore(target, playlistId, song, position) {
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Écran tactile : pas de survol, la poignée remplace le numéro en permanence */
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.songs__handle {
|
.songs__handle {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.songs__row--sortable :deep(.song-row__index) {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.songs__row--ghost {
|
.songs__row--ghost {
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ function onListTouchStart(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.queue-row {
|
.queue-row {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -377,25 +378,28 @@ function onListTouchStart(event) {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Poignée : visible au survol / au focus (toujours sur écran tactile) */
|
/* Poignée posée sur la colonne de position (aucune place réservée) : elle remplace le numéro au survol / au focus */
|
||||||
.queue-row__handle {
|
.queue-row__handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
left: 0;
|
||||||
|
/* Même largeur que .queue-row__position (0.75rem × 1.6em) */
|
||||||
|
width: calc(0.75rem * 1.6 + 2px);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-end;
|
||||||
flex-shrink: 0;
|
|
||||||
width: 20px;
|
|
||||||
height: 36px;
|
|
||||||
margin-right: -6px;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
outline: 2px solid transparent;
|
outline: 2px solid transparent;
|
||||||
|
z-index: 1;
|
||||||
transition: opacity var(--dur-fast) ease, color var(--dur-fast) ease;
|
transition: opacity var(--dur-fast) ease, color var(--dur-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +408,11 @@ function onListTouchStart(event) {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-row:hover .queue-row__position,
|
||||||
|
.queue-row:focus-within .queue-row__position {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.queue-row__handle:hover {
|
.queue-row__handle:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
@@ -414,10 +423,15 @@ function onListTouchStart(event) {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Écran tactile : pas de survol, la poignée remplace le numéro en permanence */
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.queue-row__handle {
|
.queue-row__handle {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-row__position {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-row__more {
|
.queue-row__more {
|
||||||
|
|||||||
@@ -100,14 +100,17 @@ const playlistStore = usePlaylistStore();
|
|||||||
|
|
||||||
const canPlay = computed(() => !!globalStore.currentChannel);
|
const canPlay = computed(() => !!globalStore.currentChannel);
|
||||||
|
|
||||||
// Fichier envoyé (createdAt) : affiché avec sa date d'ajout et une pochette par défaut (sans modifier la prop)
|
// Fichier envoyé (createdAt) : artiste et pochette du fichier s'ils existent, sinon date d'ajout et pochette par défaut
|
||||||
|
const DEFAULT_COVER = 'radomisol.fr';
|
||||||
const displayVideo = computed(() => {
|
const displayVideo = computed(() => {
|
||||||
const video = props.video || {};
|
const video = props.video || {};
|
||||||
if (!video.createdAt) return video;
|
if (!video.createdAt) return video;
|
||||||
|
const hasArtist = video.author && video.author !== 'Auteur inconnu' && !/^\d{15,25}$/.test(video.author);
|
||||||
|
const hasCover = video.thumbnail && !video.thumbnail.includes(DEFAULT_COVER);
|
||||||
return {
|
return {
|
||||||
...video,
|
...video,
|
||||||
author: 'Ajouté le ' + new Date(video.createdAt).toLocaleString('fr-FR'),
|
author: hasArtist ? video.author : 'Ajouté le ' + new Date(video.createdAt).toLocaleString('fr-FR'),
|
||||||
thumbnail: '/default_thumbnail.png'
|
thumbnail: hasCover ? video.thumbnail : '/default_thumbnail.png'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,6 +118,7 @@ const displayVideo = computed(() => {
|
|||||||
const playableVideo = computed(() => {
|
const playableVideo = computed(() => {
|
||||||
const video = props.video || {};
|
const video = props.video || {};
|
||||||
if (!video.createdAt) return { ...video };
|
if (!video.createdAt) return { ...video };
|
||||||
|
if (video.author && video.author !== 'Auteur inconnu' && !/^\d{15,25}$/.test(video.author)) return { ...video };
|
||||||
return { ...video, author: userStore.userInfo?.identity?.username || video.author };
|
return { ...video, author: userStore.userInfo?.identity?.username || video.author };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,13 @@
|
|||||||
<span class="dropzone__icon" aria-hidden="true"><Icon icon="fa-solid fa-cloud-arrow-up"/></span>
|
<span class="dropzone__icon" aria-hidden="true"><Icon icon="fa-solid fa-cloud-arrow-up"/></span>
|
||||||
<div class="dropzone__text">
|
<div class="dropzone__text">
|
||||||
<p class="dropzone__title">Glissez vos fichiers audio ici</p>
|
<p class="dropzone__title">Glissez vos fichiers audio ici</p>
|
||||||
<p class="dropzone__hint">MP3, WAV ou OGG · 300 Mo maximum par fichier</p>
|
<p class="dropzone__hint">{{ limitsHint }}</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".mp3,.wav,.ogg,audio/mpeg,audio/wav,audio/ogg"
|
:accept="ACCEPT_ATTR"
|
||||||
multiple
|
multiple
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -42,15 +42,18 @@
|
|||||||
<p v-if="status === 'download'" class="status-progress">
|
<p v-if="status === 'download'" class="status-progress">
|
||||||
<Spinner size="sm" :label="progressLabel" visible-label />
|
<Spinner size="sm" :label="progressLabel" visible-label />
|
||||||
</p>
|
</p>
|
||||||
<Error v-else-if="status === 'error'">{{ statusDetail || "Erreur lors de l'envoi d'un fichier." }}</Error>
|
<template v-else-if="status === 'error'">
|
||||||
<Error v-else-if="status === 'toohigh'">{{ statusDetail || 'Un fichier est trop volumineux (300 Mo maximum).' }}</Error>
|
<Success v-if="sentCount > 0">{{ sentCount > 1 ? `${sentCount} fichiers envoyés.` : 'Un fichier envoyé.' }}</Success>
|
||||||
<Error v-else-if="status === 'invalid'">Seuls les fichiers MP3, WAV et OGG sont acceptés.</Error>
|
<Error v-for="(message, index) in errors" :key="index">{{ message }}</Error>
|
||||||
|
</template>
|
||||||
|
<Error v-else-if="status === 'invalid'">Format non pris en charge : envoyez un fichier audio ({{ FORMATS_LABEL }}).</Error>
|
||||||
|
<Error v-else-if="status === 'unavailable'">Le stockage des fichiers n'est pas configuré sur ce bot.</Error>
|
||||||
<Success v-else-if="status === 'success'">{{ statusDetail || 'Fichiers envoyés avec succès.' }}</Success>
|
<Success v-else-if="status === 'success'">{{ statusDetail || 'Fichiers envoyés avec succès.' }}</Success>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="files-note">
|
<p class="files-note">
|
||||||
<Icon icon="fa-solid fa-circle-info" aria-hidden="true"/>
|
<Icon icon="fa-solid fa-circle-info" aria-hidden="true"/>
|
||||||
Ce n'est pas un stockage permanent : les fichiers sont hébergés sur le CDN de Discord et peuvent expirer à tout moment.
|
Les fichiers sont convertis en Opus (le format audio de Discord) puis stockés dans un salon privé du bot. Leurs liens sont renouvelés automatiquement.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="files-scroll">
|
<div class="files-scroll">
|
||||||
@@ -78,7 +81,7 @@
|
|||||||
<span class="upload-modal-size">{{ formatSize(value.size) }}</span>
|
<span class="upload-modal-size">{{ formatSize(value.size) }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="modal-hint">Les fichiers sont stockés sur le CDN de Discord et accessibles à quiconque possède le lien. N'envoyez rien de sensible.</p>
|
<p class="modal-hint">La conversion prend quelques secondes par fichier. Les fichiers sont accessibles à quiconque possède leur lien : n'envoyez rien de sensible.</p>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button variant="secondary" @click="closeModal()">Annuler</Button>
|
<Button variant="secondary" @click="closeModal()">Annuler</Button>
|
||||||
<Button icon="fa-solid fa-upload" @click="confirmUpload()">Envoyer</Button>
|
<Button icon="fa-solid fa-upload" @click="confirmUpload()">Envoyer</Button>
|
||||||
@@ -89,8 +92,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
/**
|
/**
|
||||||
* Vue « Mes fichiers » : envoi de fichiers audio (bouton ou glisser-déposer), liste des fichiers envoyés.
|
* Vue « Mes fichiers » : envoi de fichiers audio (bouton ou glisser-déposer), liste des fichiers envoyés.
|
||||||
* Requêtes : /UPLOAD/FILE {name, file: ArrayBuffer} → {url, name} | "TOOHIGH" | false, /UPLOAD/FILES,
|
* Le serveur convertit chaque fichier en Ogg/Opus pour tenir dans la limite d'envoi de Discord.
|
||||||
* /UPLOAD/FILE/DELETE (via l'événement du bus video:delete émis par VideoComposable).
|
* Requêtes (ack) : /UPLOAD/INFO → limites, /UPLOAD/FILE {name, file: ArrayBuffer} → {ok, song} | {ok:false, error, message},
|
||||||
|
* /UPLOAD/FILES → Song[], /UPLOAD/FILE/DELETE (via l'événement du bus video:delete émis par VideoComposable).
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, onUnmounted, ref, useId } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, useId } from 'vue';
|
||||||
import Button from '@/components/UI/Button.vue';
|
import Button from '@/components/UI/Button.vue';
|
||||||
@@ -100,29 +104,52 @@ import Error from '@/components/UI/Error.vue';
|
|||||||
import Spinner from '@/components/UI/Spinner.vue';
|
import Spinner from '@/components/UI/Spinner.vue';
|
||||||
import EmptyState from '@/components/UI/EmptyState.vue';
|
import EmptyState from '@/components/UI/EmptyState.vue';
|
||||||
import VideoComposable from '@/components/Widget/VideoComposable.vue';
|
import VideoComposable from '@/components/Widget/VideoComposable.vue';
|
||||||
import { IORequest } from '@/utils/IORequest';
|
import { IORequestAck } from '@/utils/IORequest';
|
||||||
import Events from '@/utils/Events';
|
import Events from '@/utils/Events';
|
||||||
|
|
||||||
const MAX_SIZE = 300 * 1024 * 1024;
|
const EXTENSIONS = ['mp3', 'wav', 'ogg', 'opus', 'flac', 'm4a', 'aac', 'webm'];
|
||||||
const ACCEPTED = /\.(mp3|wav|ogg)$/i;
|
const ACCEPTED = new RegExp(`\\.(${EXTENSIONS.join('|')})$`, 'i');
|
||||||
|
const ACCEPT_ATTR = [...EXTENSIONS.map((ext) => `.${ext}`), 'audio/*'].join(',');
|
||||||
|
const FORMATS_LABEL = 'MP3, WAV, OGG, FLAC, M4A…';
|
||||||
|
// Conversion + envoi à Discord : peut être long pour un gros fichier
|
||||||
|
const UPLOAD_TIMEOUT = 5 * 60 * 1000;
|
||||||
|
const ERROR_MESSAGES = {
|
||||||
|
INVALID_FILE: 'ce fichier ne contient pas de piste audio lisible',
|
||||||
|
TOO_LARGE: 'fichier trop volumineux',
|
||||||
|
TOO_LONG: 'fichier trop long',
|
||||||
|
UNAVAILABLE: "le stockage n'est pas disponible",
|
||||||
|
TIMEOUT: "le serveur n'a pas répondu à temps",
|
||||||
|
FAILED: "l'envoi a échoué"
|
||||||
|
};
|
||||||
|
|
||||||
const headingId = `files-${useId()}`;
|
const headingId = `files-${useId()}`;
|
||||||
const fileInput = ref(null);
|
const fileInput = ref(null);
|
||||||
const fileSelected = ref([]);
|
const fileSelected = ref([]);
|
||||||
// false | 'download' | 'error' | 'toohigh' | 'invalid' | 'success'
|
// false | 'download' | 'error' | 'invalid' | 'unavailable' | 'success'
|
||||||
const status = ref(false);
|
const status = ref(false);
|
||||||
const statusDetail = ref('');
|
const statusDetail = ref('');
|
||||||
|
const errors = ref([]);
|
||||||
|
const sentCount = ref(0);
|
||||||
const progress = ref({ done: 0, total: 0 });
|
const progress = ref({ done: 0, total: 0 });
|
||||||
const uploadModal = ref(null);
|
const uploadModal = ref(null);
|
||||||
const myFiles = ref([]);
|
const myFiles = ref([]);
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const dragOver = ref(false);
|
const dragOver = ref(false);
|
||||||
|
// Limites annoncées par le serveur (valeurs par défaut prudentes en attendant la réponse)
|
||||||
|
const limits = ref({ available: true, maxInputBytes: 250 * 1024 * 1024, maxDurationSec: 0, storageLimitBytes: 10 * 1024 * 1024 });
|
||||||
let dragDepth = 0;
|
let dragDepth = 0;
|
||||||
let confirmed = false;
|
let confirmed = false;
|
||||||
|
|
||||||
const progressLabel = computed(() => `Envoi en cours (${Math.min(progress.value.done + 1, progress.value.total)} sur ${progress.value.total})…`);
|
const progressLabel = computed(() => `Conversion et envoi (${Math.min(progress.value.done + 1, progress.value.total)} sur ${progress.value.total})…`);
|
||||||
|
|
||||||
|
const limitsHint = computed(() => {
|
||||||
|
const parts = [FORMATS_LABEL, `${formatSize(limits.value.maxInputBytes)} maximum`];
|
||||||
|
if (limits.value.maxDurationSec > 0) parts.push(`jusqu'à ${Math.floor(limits.value.maxDurationSec / 60)} min par fichier`);
|
||||||
|
return parts.join(' · ');
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadLimits();
|
||||||
refreshUploadedFiles();
|
refreshUploadedFiles();
|
||||||
Events.on("video:delete", deleteFile);
|
Events.on("video:delete", deleteFile);
|
||||||
});
|
});
|
||||||
@@ -131,22 +158,38 @@ onUnmounted(() => {
|
|||||||
Events.off("video:delete", deleteFile);
|
Events.off("video:delete", deleteFile);
|
||||||
});
|
});
|
||||||
|
|
||||||
function deleteFile(data) {
|
async function loadLimits() {
|
||||||
IORequest('/UPLOAD/FILE/DELETE', () => {
|
try {
|
||||||
refreshUploadedFiles(false);
|
const info = await IORequestAck('/UPLOAD/INFO', undefined, 10000);
|
||||||
}, data.video);
|
if (info && info.ok) limits.value = { ...limits.value, ...info };
|
||||||
|
} catch (e) {
|
||||||
|
// Serveur plus ancien ou lent : on garde les valeurs par défaut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(data) {
|
||||||
|
try {
|
||||||
|
await IORequestAck('/UPLOAD/FILE/DELETE', data.video, 20000);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Suppression du fichier sans réponse :", e);
|
||||||
|
}
|
||||||
|
refreshUploadedFiles(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes) {
|
function formatSize(bytes) {
|
||||||
const size = Number(bytes) || 0;
|
const size = Number(bytes) || 0;
|
||||||
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} Ko`;
|
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} Ko`;
|
||||||
return `${(size / (1024 * 1024)).toFixed(1).replace('.', ',')} Mo`;
|
return `${(size / (1024 * 1024)).toFixed(1).replace('.', ',').replace(',0', '')} Mo`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFiles(list) {
|
function selectFiles(list) {
|
||||||
const files = Array.from(list || []);
|
const files = Array.from(list || []);
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
const accepted = files.filter((file) => ACCEPTED.test(file.name));
|
if (!limits.value.available) {
|
||||||
|
status.value = 'unavailable';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accepted = files.filter((file) => ACCEPTED.test(file.name) || file.type?.startsWith('audio/'));
|
||||||
if (accepted.length === 0) {
|
if (accepted.length === 0) {
|
||||||
status.value = 'invalid';
|
status.value = 'invalid';
|
||||||
return;
|
return;
|
||||||
@@ -190,46 +233,37 @@ async function confirmUpload() {
|
|||||||
uploadModal.value.close();
|
uploadModal.value.close();
|
||||||
status.value = 'download';
|
status.value = 'download';
|
||||||
statusDetail.value = '';
|
statusDetail.value = '';
|
||||||
|
errors.value = [];
|
||||||
|
sentCount.value = 0;
|
||||||
progress.value = { done: 0, total: files.length };
|
progress.value = { done: 0, total: files.length };
|
||||||
|
|
||||||
let errorStatus = null;
|
|
||||||
let sent = 0;
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
if (file.size > MAX_SIZE) {
|
if (file.size > limits.value.maxInputBytes) {
|
||||||
errorStatus = 'toohigh';
|
errors.value.push(`« ${file.name} » : fichier trop volumineux (${formatSize(limits.value.maxInputBytes)} maximum).`);
|
||||||
statusDetail.value = `« ${file.name} » est trop volumineux (300 Mo maximum).`;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const fileBuffer = await readFileAsArrayBuffer(file);
|
const fileBuffer = await readFileAsArrayBuffer(file);
|
||||||
await new Promise((resolve) => {
|
const response = await IORequestAck('/UPLOAD/FILE', { name: file.name, file: fileBuffer }, UPLOAD_TIMEOUT);
|
||||||
IORequest('/UPLOAD/FILE', (response) => {
|
if (response && response.ok) {
|
||||||
if (!response || response === "TOOHIGH") {
|
sentCount.value++;
|
||||||
errorStatus = response === "TOOHIGH" ? 'toohigh' : 'error';
|
} else {
|
||||||
statusDetail.value = response === "TOOHIGH"
|
const reason = String(response?.message || ERROR_MESSAGES[response?.error] || ERROR_MESSAGES.FAILED).replace(/\.$/, '');
|
||||||
? `« ${file.name} » est trop volumineux.`
|
errors.value.push(`« ${file.name} » : ${reason.charAt(0).toLowerCase()}${reason.slice(1)}.`);
|
||||||
: `Échec de l'envoi de « ${file.name} ».`;
|
}
|
||||||
} else {
|
|
||||||
sent++;
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
}, { name: file.name, file: fileBuffer });
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Erreur upload fichier:", file.name, err);
|
console.error("Erreur upload fichier:", file.name, err);
|
||||||
errorStatus = 'error';
|
errors.value.push(`« ${file.name} » : ${err?.code === 'TIMEOUT' ? ERROR_MESSAGES.TIMEOUT : 'lecture du fichier impossible'}.`);
|
||||||
statusDetail.value = `Lecture impossible de « ${file.name} ».`;
|
|
||||||
} finally {
|
} finally {
|
||||||
progress.value = { ...progress.value, done: progress.value.done + 1 };
|
progress.value = { ...progress.value, done: progress.value.done + 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorStatus) {
|
if (errors.value.length > 0) {
|
||||||
status.value = errorStatus;
|
status.value = 'error';
|
||||||
} else {
|
} else {
|
||||||
status.value = 'success';
|
status.value = 'success';
|
||||||
statusDetail.value = sent > 1 ? `${sent} fichiers envoyés.` : 'Fichier envoyé.';
|
statusDetail.value = sentCount.value > 1 ? `${sentCount.value} fichiers envoyés.` : 'Fichier envoyé.';
|
||||||
}
|
}
|
||||||
fileSelected.value = [];
|
fileSelected.value = [];
|
||||||
refreshUploadedFiles(false);
|
refreshUploadedFiles(false);
|
||||||
@@ -256,15 +290,23 @@ function onModalClose() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// showLoader = false : rafraîchissement discret (la liste reste affichée pendant la requête)
|
// showLoader = false : rafraîchissement discret (la liste reste affichée pendant la requête)
|
||||||
function refreshUploadedFiles(showLoader = true) {
|
let listToken = 0;
|
||||||
|
async function refreshUploadedFiles(showLoader = true) {
|
||||||
if (showLoader) {
|
if (showLoader) {
|
||||||
myFiles.value = [];
|
myFiles.value = [];
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
}
|
}
|
||||||
IORequest('/UPLOAD/FILES', (response) => {
|
// Seule la dernière demande compte (envoi + suppression rapprochés)
|
||||||
myFiles.value = Array.isArray(response) ? response : [];
|
const token = ++listToken;
|
||||||
isLoading.value = false;
|
let response = null;
|
||||||
});
|
try {
|
||||||
|
response = await IORequestAck('/UPLOAD/FILES', undefined, 60000);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Liste des fichiers sans réponse :", e);
|
||||||
|
}
|
||||||
|
if (token !== listToken) return;
|
||||||
|
if (Array.isArray(response)) myFiles.value = response;
|
||||||
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in new issue
Block a user