Version 2.1.1 - modification d'une certaine UI

This commit is contained in:
raphix committed 2026-10-08 21:47:34 +02:00
1 parent 54bbb2ca04
commit bb82285e68
9 files changed
+604 -89

No files matched your search

+6
View File
@@ -73,6 +73,7 @@ import AdminMetrics from './AdminMetrics.vue';
import AdminMaintenance from './AdminMaintenance.vue';
import AdminPlaylists from './AdminPlaylists.vue';
import AdminConfig from './AdminConfig.vue';
import AdminMedia from './AdminMedia.vue';
import { ADMIN_NAV_KEY, ADMIN_SECTION_TITLE_ID } from './adminUi';
import './admin.scss';
@@ -95,6 +96,7 @@ const sections = [
{ 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: '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: '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 },
@@ -145,6 +147,10 @@ const badges = computed(() => {
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?.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" };
const online = adminStore.presence?.counts?.online ?? adminStore.overview?.web?.usersOnline;
if (online) result.users = { text: online, type: 'neutral', sr: online + " en ligne" };
+403
View File
@@ -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>
+8
View File
@@ -152,8 +152,12 @@
<AdminKv v-if="data.media?.configured" label="Connexion">
<Tag :tone="data.media?.connected ? 'success' : 'error'">{{ data.media?.connected ? "Connecté" : "Déconnecté" }}</Tag>
</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>
<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>
</AdminCard>
@@ -295,6 +299,10 @@ onUnmounted(() => {
overflow-wrap: anywhere;
}
.media-missing {
margin-left: 6px;
}
.db-icon {
--adm-cover-size: 32px;
+15 -6
View File
@@ -19,12 +19,12 @@
</div>
<span v-else></span>
<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-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-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, '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"/>
<Pause v-else aria-hidden="true"/>
</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 v-if="online" class="actions-div" role="group" aria-label="Salon vocal">
<CustomIcon label="Changer de salon" @click="changeChannel"><Hand/></CustomIcon>
@@ -43,12 +43,12 @@
<DurationBar mobile class="duration-bar" v-bind="durationBarProps" @seek="seek" />
<span class="actions-container">
<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-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-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, '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"/>
<Pause v-else aria-hidden="true"/>
</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>
</span>
<div class="player-tags" aria-live="polite">
@@ -632,6 +632,15 @@ async function seek(ms) {
display: none;
}
.enhanced {
border: none;
background: none;
}
.enhanced-play {
border: none;
}
}
+27 -13
View File
@@ -111,6 +111,7 @@
:data-handle-id="element.playlistId"
:aria-label="`Déplacer « ${element.title} », position ${index + 1} sur ${fullList.length}`"
:aria-describedby="helpId"
@click="open(element)"
@keydown="onHandleKeydown($event, element)"
>
<Icon icon="fa-solid fa-grip-vertical" aria-hidden="true" />
@@ -487,6 +488,7 @@ onMounted(() => {
}
.playlist__row {
position: relative;
display: flex;
align-items: center;
min-width: 0;
@@ -497,26 +499,31 @@ onMounted(() => {
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 {
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;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 22px;
height: 44px;
margin-right: -2px;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-tertiary);
font-size: 0.85em;
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 0.95em;
cursor: grab;
opacity: 0;
touch-action: none;
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,
@@ -526,22 +533,29 @@ onMounted(() => {
}
.playlist__handle:hover {
color: var(--text);
background-color: color-mix(in srgb, var(--text) 8%, transparent);
background-color: rgba(0, 0, 0, 0.7);
}
.playlist__handle:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--main);
color: var(--text);
}
.playlist__handle:active {
cursor: grabbing;
}
/* Écran tactile : petite pastille dans le coin de la pochette, qui ne la masque pas */
@media (hover: none) {
.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;
}
}
@@ -370,6 +370,7 @@ async function restore(target, playlistId, song, position) {
}
.songs__row {
position: relative;
display: flex;
align-items: center;
min-width: 0;
@@ -380,34 +381,43 @@ async function restore(target, playlistId, song, position) {
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 {
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;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 44px;
padding: 0;
justify-content: flex-end;
padding: 0 2px 0 0;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-tertiary);
color: var(--text-secondary);
font-size: 0.85em;
cursor: grab;
opacity: 0.35;
opacity: 0;
touch-action: none;
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:focus-within .songs__handle {
.songs__row--sortable:hover .songs__handle,
.songs__row--sortable:focus-within .songs__handle {
opacity: 1;
}
.songs__row--sortable:hover :deep(.song-row__index),
.songs__row--sortable:focus-within :deep(.song-row__index) {
visibility: hidden;
}
.songs__handle:hover {
color: var(--text);
background-color: color-mix(in srgb, var(--text) 8%, transparent);
}
.songs__handle:focus-visible {
@@ -421,10 +431,15 @@ async function restore(target, playlistId, song, position) {
cursor: grabbing;
}
/* Écran tactile : pas de survol, la poignée remplace le numéro en permanence */
@media (hover: none) {
.songs__handle {
opacity: 1;
}
.songs__row--sortable :deep(.song-row__index) {
visibility: hidden;
}
}
.songs__row--ghost {
+21 -7
View File
@@ -348,6 +348,7 @@ function onListTouchStart(event) {
}
.queue-row {
position: relative;
display: flex;
align-items: center;
gap: 6px;
@@ -377,25 +378,28 @@ function onListTouchStart(event) {
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 {
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;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 36px;
margin-right: -6px;
justify-content: flex-end;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-tertiary);
color: var(--text-secondary);
font-size: 0.8em;
cursor: grab;
opacity: 0;
touch-action: none;
outline: 2px solid transparent;
z-index: 1;
transition: opacity var(--dur-fast) ease, color var(--dur-fast) ease;
}
@@ -404,6 +408,11 @@ function onListTouchStart(event) {
opacity: 1;
}
.queue-row:hover .queue-row__position,
.queue-row:focus-within .queue-row__position {
visibility: hidden;
}
.queue-row__handle:hover {
color: var(--text);
}
@@ -414,10 +423,15 @@ function onListTouchStart(event) {
color: var(--text);
}
/* Écran tactile : pas de survol, la poignée remplace le numéro en permanence */
@media (hover: none) {
.queue-row__handle {
opacity: 1;
}
.queue-row__position {
visibility: hidden;
}
}
.queue-row__more {
+7 -3
View File
@@ -100,14 +100,17 @@ const playlistStore = usePlaylistStore();
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 video = props.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 {
...video,
author: 'Ajouté le ' + new Date(video.createdAt).toLocaleString('fr-FR'),
thumbnail: '/default_thumbnail.png'
author: hasArtist ? video.author : 'Ajouté le ' + new Date(video.createdAt).toLocaleString('fr-FR'),
thumbnail: hasCover ? video.thumbnail : '/default_thumbnail.png'
};
});
@@ -115,6 +118,7 @@ const displayVideo = computed(() => {
const playableVideo = computed(() => {
const video = props.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 };
});
+91 -49
View File
@@ -18,13 +18,13 @@
<span class="dropzone__icon" aria-hidden="true"><Icon icon="fa-solid fa-cloud-arrow-up"/></span>
<div class="dropzone__text">
<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>
<input
ref="fileInput"
class="sr-only"
type="file"
accept=".mp3,.wav,.ogg,audio/mpeg,audio/wav,audio/ogg"
:accept="ACCEPT_ATTR"
multiple
tabindex="-1"
aria-hidden="true"
@@ -42,15 +42,18 @@
<p v-if="status === 'download'" class="status-progress">
<Spinner size="sm" :label="progressLabel" visible-label />
</p>
<Error v-else-if="status === 'error'">{{ statusDetail || "Erreur lors de l'envoi d'un fichier." }}</Error>
<Error v-else-if="status === 'toohigh'">{{ statusDetail || 'Un fichier est trop volumineux (300 Mo maximum).' }}</Error>
<Error v-else-if="status === 'invalid'">Seuls les fichiers MP3, WAV et OGG sont acceptés.</Error>
<template v-else-if="status === 'error'">
<Success v-if="sentCount > 0">{{ sentCount > 1 ? `${sentCount} fichiers envoyés.` : 'Un fichier envoyé.' }}</Success>
<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>
</div>
<p class="files-note">
<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>
<div class="files-scroll">
@@ -78,7 +81,7 @@
<span class="upload-modal-size">{{ formatSize(value.size) }}</span>
</li>
</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>
<Button variant="secondary" @click="closeModal()">Annuler</Button>
<Button icon="fa-solid fa-upload" @click="confirmUpload()">Envoyer</Button>
@@ -89,8 +92,9 @@
<script setup>
/**
* 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,
* /UPLOAD/FILE/DELETE (via l'événement du bus video:delete émis par VideoComposable).
* Le serveur convertit chaque fichier en Ogg/Opus pour tenir dans la limite d'envoi de Discord.
* 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 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 EmptyState from '@/components/UI/EmptyState.vue';
import VideoComposable from '@/components/Widget/VideoComposable.vue';
import { IORequest } from '@/utils/IORequest';
import { IORequestAck } from '@/utils/IORequest';
import Events from '@/utils/Events';
const MAX_SIZE = 300 * 1024 * 1024;
const ACCEPTED = /\.(mp3|wav|ogg)$/i;
const EXTENSIONS = ['mp3', 'wav', 'ogg', 'opus', 'flac', 'm4a', 'aac', 'webm'];
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 fileInput = ref(null);
const fileSelected = ref([]);
// false | 'download' | 'error' | 'toohigh' | 'invalid' | 'success'
// false | 'download' | 'error' | 'invalid' | 'unavailable' | 'success'
const status = ref(false);
const statusDetail = ref('');
const errors = ref([]);
const sentCount = ref(0);
const progress = ref({ done: 0, total: 0 });
const uploadModal = ref(null);
const myFiles = ref([]);
const isLoading = ref(true);
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 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(() => {
loadLimits();
refreshUploadedFiles();
Events.on("video:delete", deleteFile);
});
@@ -131,22 +158,38 @@ onUnmounted(() => {
Events.off("video:delete", deleteFile);
});
function deleteFile(data) {
IORequest('/UPLOAD/FILE/DELETE', () => {
refreshUploadedFiles(false);
}, data.video);
async function loadLimits() {
try {
const info = await IORequestAck('/UPLOAD/INFO', undefined, 10000);
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) {
const size = Number(bytes) || 0;
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) {
const files = Array.from(list || []);
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) {
status.value = 'invalid';
return;
@@ -190,46 +233,37 @@ async function confirmUpload() {
uploadModal.value.close();
status.value = 'download';
statusDetail.value = '';
errors.value = [];
sentCount.value = 0;
progress.value = { done: 0, total: files.length };
let errorStatus = null;
let sent = 0;
for (const file of files) {
try {
if (file.size > MAX_SIZE) {
errorStatus = 'toohigh';
statusDetail.value = `« ${file.name} » est trop volumineux (300 Mo maximum).`;
if (file.size > limits.value.maxInputBytes) {
errors.value.push(`« ${file.name} » : fichier trop volumineux (${formatSize(limits.value.maxInputBytes)} maximum).`);
continue;
}
const fileBuffer = await readFileAsArrayBuffer(file);
await new Promise((resolve) => {
IORequest('/UPLOAD/FILE', (response) => {
if (!response || response === "TOOHIGH") {
errorStatus = response === "TOOHIGH" ? 'toohigh' : 'error';
statusDetail.value = response === "TOOHIGH"
? `« ${file.name} » est trop volumineux.`
: `Échec de l'envoi de « ${file.name} ».`;
} else {
sent++;
}
resolve();
}, { name: file.name, file: fileBuffer });
});
const response = await IORequestAck('/UPLOAD/FILE', { name: file.name, file: fileBuffer }, UPLOAD_TIMEOUT);
if (response && response.ok) {
sentCount.value++;
} else {
const reason = String(response?.message || ERROR_MESSAGES[response?.error] || ERROR_MESSAGES.FAILED).replace(/\.$/, '');
errors.value.push(`« ${file.name} » : ${reason.charAt(0).toLowerCase()}${reason.slice(1)}.`);
}
} catch (err) {
console.error("Erreur upload fichier:", file.name, err);
errorStatus = 'error';
statusDetail.value = `Lecture impossible de « ${file.name} ».`;
errors.value.push(`« ${file.name} » : ${err?.code === 'TIMEOUT' ? ERROR_MESSAGES.TIMEOUT : 'lecture du fichier impossible'}.`);
} finally {
progress.value = { ...progress.value, done: progress.value.done + 1 };
}
}
if (errorStatus) {
status.value = errorStatus;
if (errors.value.length > 0) {
status.value = 'error';
} else {
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 = [];
refreshUploadedFiles(false);
@@ -256,15 +290,23 @@ function onModalClose() {
}
// 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) {
myFiles.value = [];
isLoading.value = true;
}
IORequest('/UPLOAD/FILES', (response) => {
myFiles.value = Array.isArray(response) ? response : [];
isLoading.value = false;
});
// Seule la dernière demande compte (envoi + suppression rapprochés)
const token = ++listToken;
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>
<style scoped>