Version 2.1.0 - Update UI

This commit is contained in:
raphix committed 2026-10-08 10:06:45 +02:00
1 parent 6d81a8c1c0
commit 54bbb2ca04
158 files changed
+24134 -7605

No files matched your search

+10 -2
View File
@@ -1,10 +1,18 @@
<!DOCTYPE html>
<html lang="">
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#111210">
<link rel="icon" href="/favicon.ico">
<title>Subsonics</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
</head>
<body>
<noscript>
<strong>We're sorry but Subsonics doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
<strong>Désolé, Subsonics ne peut pas fonctionner sans JavaScript. Active-le pour continuer.</strong>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "chopin-frontend",
"version": "2.0.0",
"version": "2.1.0",
"private": true,
"scripts": {
"dev": "vite --host --port 8080",
+5 -1
View File
@@ -1,10 +1,14 @@
<template>
<router-view/>
<Announcement/>
<!-- Playlists : écouteurs, modales de création / d'enregistrement et notifications (une seule instance) -->
<PlaylistHost/>
</template>
<script setup>
import { useGlobalStore } from '@/stores/globalStore';
import Announcement from '@/components/UI/Announcement.vue';
const globalStore = useGlobalStore();
import PlaylistHost from '@/components/Widget/Playlist/PlaylistHost.vue';
// Créé dès le démarrage : applique le thème enregistré (data-theme) avant le premier rendu des pages
useGlobalStore();
console.log("Subsonics Chopin - Welcome");
</script>
+250 -49
View File
@@ -1,10 +1,13 @@
@font-face {
font-family: 'Gunship';
src: url("/gunship.ttf");
font-display: swap;
}
[data-theme='dark'] {
/* Thème sombre : appliqué aussi tant que data-theme n'est pas encore posé (évite un flash blanc) */
[data-theme='dark'],
:root:not([data-theme]) {
color-scheme: dark;
--primary: #111210;
--primary-inverse: #FFFFFF;
@@ -19,11 +22,14 @@
--text-error: #ff2b2b;
--text-success: #00ff00;
--text-warning: #FFA500;
--text-info: #4db4ff;
--scrollbar-thumb: color-mix(in srgb, var(--text) 30%, transparent);
--scrollbar-thumb-hover: color-mix(in srgb, var(--text) 55%, transparent);
}
[data-theme='light'] {
color-scheme: light;
--primary: #FFFFFF;
--primary-inverse: #111210;
@@ -34,25 +40,84 @@
--text: #111210;
--text-inverse: #FFFFFF;
--text-secondary: #404040;
--text-tertiary: #C5c3c3;
/* Contraste suffisant sur fond clair (l'ancien #C5c3c3 était quasi invisible) */
--text-tertiary: #6b6b6b;
--text-error: #CD034F;
--text-success: #00a900;
--text-warning: #FFA500;
--text-success: #007a1f;
--text-warning: #a35f00;
--text-info: #0063b1;
--scrollbar-thumb: color-mix(in srgb, var(--text) 30%, transparent);
--scrollbar-thumb-hover: color-mix(in srgb, var(--text) 55%, transparent);
}
:root {
--main: #CD034F;
--main-hover: color-mix(in srgb, var(--main) 90%, black);
--main-active: color-mix(in srgb, var(--main) 70%, black);
--main-soft: color-mix(in srgb, var(--main) 16%, transparent);
--main-contrast: #fff;
--admin-color: #209AFE;
--owner-color: #FFAA32;
--mod-color: #0BFF89;
/* Variables utilisées par des composants mais jamais définies jusqu'ici */
--text-primary: var(--text);
--text-color: var(--text);
--text-hover: var(--text-secondary);
--border: color-mix(in srgb, var(--quaternary) 45%, transparent);
--background: transparent;
/* Surfaces */
--surface-1: var(--secondary);
--surface-2: var(--tertiary);
--surface-3: var(--quaternary);
/* Rayons */
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--radius-pill: 999px;
/* Espacements */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
/* Élévation */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, .25);
--shadow: 0 0 10px rgba(0, 0, 0, .35);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, .45);
/* Focus clavier : anneau doublé pour rester visible sur fond rose comme sur fond sombre */
--focus-ring: 0 0 0 2px var(--primary), 0 0 0 4px var(--main);
/* Variante intérieure, pour un élément coupé par un conteneur overflow: hidden */
--focus-ring-inset: inset 0 0 0 2px var(--main);
/* Survol / pression d'une ligne de liste (teinte neutre qui suit le thème) */
--surface-hover: color-mix(in srgb, var(--text) 8%, transparent);
--surface-active: color-mix(in srgb, var(--text) 13%, transparent);
/* Voile des modales et texte posé sur une image (miniatures) */
--overlay: rgba(0, 0, 0, .55);
--on-media: #fff;
/* Mouvement */
--ease: cubic-bezier(.2, .7, .2, 1);
--dur-fast: .15s;
--dur: .25s;
--font: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-mono: ui-monospace, 'Cascadia Mono', 'SFMono-Regular', Menlo, Consolas, monospace;
}
html, body {
margin: 0 !important;
font-family: 'Inter', sans-serif;
font-family: var(--font);
min-height: 100vh;
min-width: 100vw;
margin: 0;
@@ -60,10 +125,11 @@ html, body {
height: 100%;
overflow-x: hidden;
background-color: var(--primary);
transition: all 0.2s ease-in-out;
/* Seules les couleurs s'animent au changement de thème (avant : transition: all) */
transition: background-color var(--dur) ease-in-out, color var(--dur) ease-in-out;
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
@@ -71,6 +137,11 @@ html, body {
display: flex;
}
/* Les boutons natifs n'héritent pas de la police par défaut */
button, input, select, textarea {
font-family: inherit;
}
.no-decoration {
text-decoration: none !important;
}
@@ -89,46 +160,175 @@ a {
color: var(--text-secondary)
}
a:hover {
color: var(--text);
}
/* Focus clavier visible partout (la souris ne déclenche pas :focus-visible) */
:focus-visible {
outline: 2px solid var(--main);
outline-offset: 2px;
}
::selection {
background-color: color-mix(in srgb, var(--main) 38%, transparent);
}
/*Scrollbar */
::-webkit-scrollbar {
width: 5px;
height: 5px;
margin-right: 20px;
margin-left: 20px;
}
}
::-webkit-scrollbar-track {
border-radius: 12px;
}
}
::-webkit-scrollbar-thumb {
background: #ffffff56;
background: var(--scrollbar-thumb);
border-radius: 5px;
transition: 0.2s;
}
}
::-webkit-scrollbar-thumb:hover {
background: #ffffffa8;
}
background: var(--scrollbar-thumb-hover);
}
/* Firefox ne connaît pas ::-webkit-scrollbar */
@supports not selector(::-webkit-scrollbar) {
* {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
}
.full {
width: 100%;
}
/* Champs de formulaire */
input:is([type="text"], [type="search"], [type="url"], [type="password"], [type="number"], [type="email"]),
select,
textarea {
background-color: var(--tertiary);
color: var(--text);
border: none;
border-radius: var(--radius-sm);
padding: 8px 10px;
font-family: var(--font);
font-size: inherit;
/* Contour transparent : invisible normalement, mais rendu en mode contraste élevé */
outline: 2px solid transparent;
outline-offset: 0;
transition: box-shadow var(--dur-fast) ease, background-color var(--dur-fast) ease;
}
input:is([type="text"], [type="search"], [type="url"], [type="password"], [type="number"], [type="email"]):focus,
select:focus,
textarea:focus {
box-shadow: var(--focus-ring);
}
input:is([type="text"], [type="search"], [type="url"], [type="password"], [type="number"], [type="email"]):disabled,
select:disabled,
textarea:disabled {
opacity: .6;
cursor: not-allowed;
}
input::placeholder,
textarea::placeholder {
color: var(--text-secondary);
opacity: .7;
}
select {
cursor: pointer;
}
textarea {
resize: vertical;
background-color: var(--tertiary);
border: none;
border-radius: 5px;
padding: 5px;
color: var(--text);
font-family: 'Inter', sans-serif;
outline: none;
min-height: 4vh;
max-height: 15vh;
}
textarea:focus {
box-shadow: 0 0 5px var(--main);
/* Champ posé sur une carte --tertiary : on redescend d'un niveau pour garder le contraste */
.input-inset {
background-color: var(--secondary) !important;
}
input[type="checkbox"],
input[type="radio"],
input[type="range"],
progress {
accent-color: var(--main);
}
input[type="checkbox"],
input[type="radio"] {
cursor: pointer;
}
/* Utilitaires */
/* Bouton sans apparence (ligne de liste, carte cliquable…) : garde le focus visible du navigateur */
.u-plain-button {
margin: 0;
padding: 0;
border: none;
background: none;
color: inherit;
font: inherit;
text-align: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.u-plain-button:disabled {
cursor: not-allowed;
}
/* Titre de vue (résultats, fichiers…) : même échelle partout */
.u-view-title {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.25;
color: var(--text);
}
.u-view-title > svg {
color: var(--main);
}
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.u-mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.u-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
@keyframes appear {
from {
@@ -150,30 +350,6 @@ textarea:focus {
}
}
input[type="checkbox"] {
background-color: #FFFFFF !important;
}
input[type="checkbox"]:checked {
background-color: var(--main-hover) !important;
}
input[type="checkbox"]:hover {
background-color: var(--main-active) !important;
}
input[type="text"] {
background-color: var(--tertiary);
border: none;
border-radius: 5px;
padding: 7px;
color: var(--text);
font-family: 'Inter', sans-serif;
outline: none;
}
@keyframes scroll {
0% {
transform: translateX(0);
@@ -182,3 +358,28 @@ input[type="text"] {
transform: translateX(var(--scroll-distance));
}
}
@keyframes u-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes u-spin {
to { transform: rotate(360deg); }
}
/* Mouvement réduit : on coupe les animations et transitions (notes du splash, marquee, glissements…) */
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto !important;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+52
View File
@@ -0,0 +1,52 @@
<template>
<div :class="['adm-banner', `adm-tone-${toneName}`]" :role="toneName === 'error' ? 'alert' : 'status'">
<Icon class="adm-banner__icon" :icon="icon || toneIcon(toneName)" aria-hidden="true"/>
<div class="adm-banner__content">
<p v-if="title" class="adm-banner__title">{{ title }}</p>
<div v-if="$slots.default" class="adm-banner__text"><slot></slot></div>
</div>
<div v-if="$slots.action || dismissible" class="adm-banner__action">
<slot name="action"></slot>
<IconAction v-if="dismissible" icon="fa-solid fa-xmark" :label="dismissLabel" size="1em" @click="$emit('dismiss')"/>
</div>
</div>
</template>
<script setup>
/**
* Bandeau d'information en tête de section (remplace les anciens .banner / .banner-action).
* `tone` : info (défaut) | success | warning | error (+ alias warn) ; slot `action` pour un bouton.
* Une erreur est annoncée (role="alert"), les autres tons poliment (role="status").
*/
import { computed } from 'vue';
import IconAction from '@/components/UI/IconAction.vue';
import { normalizeTone, toneIcon, toneValidator } from './adminUi';
import './admin.scss';
const props = defineProps({
tone: {
type: String,
default: 'info',
validator: toneValidator
},
title: {
type: String,
default: null
},
icon: {
type: String,
default: null
},
dismissible: {
type: Boolean,
default: false
},
dismissLabel: {
type: String,
default: "Masquer ce message"
}
});
defineEmits(['dismiss']);
const toneName = computed(() => normalizeTone(props.tone, 'info'));
</script>
+168 -54
View File
@@ -1,73 +1,187 @@
<template>
<Box level="second" padding="closed" no-shadow class="admin-card">
<div class="card-header">
<div class="card-title">
<Icon class="card-icon" :icon="icon" :style="{ color: color }"/>
<p>{{ title }}</p>
<component :is="as" :class="rootClasses" :style="rootStyle" :aria-busy="busy ? 'true' : undefined">
<header v-if="hasHeader" class="adm-card__header">
<div class="adm-card__lead">
<!-- Slot media : avatar / pochette à la place de la pastille d'icône -->
<span v-if="$slots.media" class="adm-card__media"><slot name="media"></slot></span>
<span v-else-if="icon" class="adm-card__chip" aria-hidden="true"><Icon :icon="icon"/></span>
<div v-if="title || subtitle" class="adm-card__heading">
<component :is="headingTag" v-if="title" :id="titleId" class="adm-card__title">
<button v-if="isCollapsible" type="button" class="adm-card__toggle"
:aria-expanded="isOpen ? 'true' : 'false'" :aria-controls="bodyId"
@click="toggle()">{{ title }}</button>
<template v-else>{{ title }}</template>
</component>
<p v-if="subtitle" class="adm-card__subtitle">{{ subtitle }}</p>
</div>
<Icon v-if="isCollapsible" class="adm-card__chevron" icon="fa-solid fa-chevron-down" aria-hidden="true"/>
</div>
<slot name="header"></slot>
</div>
<div class="card-content">
<Spinner v-if="busy" size="sm" :label="busyLabel"/>
<div v-if="$slots.actions || $slots.header" class="adm-card__actions">
<slot name="actions"></slot>
<!-- Ancien nom du slot d'actions, conservé pour les sections pas encore migrées -->
<slot name="header"></slot>
</div>
</header>
<div v-show="isOpen" :id="bodyId" :class="['adm-card__body', bodyClass]">
<slot></slot>
</div>
</Box>
<footer v-if="$slots.footer" v-show="isOpen" class="adm-card__footer">
<slot name="footer"></slot>
</footer>
</component>
</template>
<script setup>
// Carte d'information (fond --tertiary, pastille d'icône en --secondary comme Metric.vue)
import Box from '@/components/UI/Box.vue';
/**
* Carte des sections d'administration (fond --tertiary, en-tête avec pastille d'icône et filet).
* - `span` : largeur dans une AdminGrid (1–12) ; `spanMd` / `spanSm` remplacent les largeurs automatiques.
* - Slots : `actions` (à droite de l'en-tête ; alias historique `header`), défaut (corps), `footer` (épinglé en bas),
* `media` (avatar, pochette… à la place de la pastille d'icône).
* - Titre h3 (h4 dans un groupe titré AdminGrid). Sans titre ni sous-titre ni actions : pas d'en-tête.
* - `collapsible` : le titre devient un bouton (aria-expanded) ; état contrôlable via v-model:open
* ou mémorisé par navigateur avec `persistKey`.
*/
import { computed, inject, ref, unref, useId, useSlots, watch } from 'vue';
import Spinner from '@/components/UI/Spinner.vue';
import { ADMIN_HEADING_KEY, normalizeTone, spanClasses, toneValidator, readLocal, writeLocal } from './adminUi';
import './admin.scss';
defineProps({
const props = defineProps({
title: {
type: String,
required: true
default: null
},
icon: {
type: String,
default: "fa-solid fa-circle-info"
},
// Historique : couleur libre de l'icône (prioritaire sur `tone` pour la pastille)
color: {
type: String,
default: "var(--text)"
default: null
},
subtitle: {
type: String,
default: null
},
span: {
type: [Number, String],
default: 4
},
spanMd: {
type: [Number, String],
default: null
},
spanSm: {
type: [Number, String],
default: null
},
tone: {
type: String,
default: 'default',
validator: toneValidator
},
flush: {
type: Boolean,
default: false
},
collapsible: {
type: Boolean,
default: false
},
defaultOpen: {
type: Boolean,
default: true
},
// Contrôle externe facultatif (v-model:open) ; undefined = état interne
open: {
type: Boolean,
default: undefined
},
// Mémorise l'état replié dans le navigateur (clé courte, ex. "yt-assistant")
persistKey: {
type: String,
default: null
},
busy: {
type: Boolean,
default: false
},
busyLabel: {
type: String,
default: "Actualisation…"
},
bodyClass: {
type: [String, Array, Object],
default: null
},
as: {
type: String,
default: 'section'
}
});
const emit = defineEmits(['update:open', 'toggle']);
const slots = useSlots();
const uid = useId();
const titleId = `adm-card-${uid}-title`;
const bodyId = `adm-card-${uid}-body`;
const toneName = computed(() => normalizeTone(props.tone));
const isCollapsible = computed(() => props.collapsible && !!props.title);
// L'icône seule ne suffit pas à créer un en-tête (elle a une valeur par défaut) : carte « nue » possible
const hasHeader = computed(() => !!(props.title || props.subtitle || slots.actions || slots.header || slots.media));
const headingLevel = inject(ADMIN_HEADING_KEY, null);
const headingTag = computed(() => 'h' + Math.min(6, Math.max(2, Number(unref(headingLevel)) || 3)));
// État replié : contrôlé (prop open) > mémorisé (persistKey) > defaultOpen
const storageKey = computed(() => (props.persistKey ? "adminCard:" + props.persistKey : null));
function initialOpen() {
if (storageKey.value) {
const saved = readLocal(storageKey.value);
if (saved === "1") return true;
if (saved === "0") return false;
}
return props.defaultOpen;
}
const localOpen = ref(initialOpen());
const isOpen = computed(() => !isCollapsible.value || (props.open !== undefined ? props.open : localOpen.value));
watch(() => props.defaultOpen, (value) => {
// Valeur par défaut calculée après chargement (ex. replié quand le compte est valide) : suivie tant que rien n'est mémorisé
if (props.open === undefined && !(storageKey.value && readLocal(storageKey.value) !== null)) localOpen.value = value;
});
function setOpen(value) {
const next = !!value;
localOpen.value = next;
if (storageKey.value) writeLocal(storageKey.value, next ? "1" : "0");
emit('update:open', next);
emit('toggle', next);
}
function toggle() {
setOpen(!isOpen.value);
}
const rootClasses = computed(() => [
'adm-card',
`adm-tone-${toneName.value}`,
...spanClasses(props.span, props.spanMd, props.spanSm),
{
'adm-tone-custom': !!props.color,
'adm-card--accent': toneName.value === 'warning' || toneName.value === 'error',
'adm-card--flush': props.flush,
'adm-card--collapsible': isCollapsible.value,
'adm-card--collapsed': !isOpen.value
}
]);
const rootStyle = computed(() => (props.color ? { '--adm-tone': props.color } : null));
defineExpose({ isOpen, setOpen, toggle });
</script>
<style scoped>
.admin-card {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.card-title {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
}
.card-title p {
margin: 0;
}
.card-icon {
background-color: var(--secondary);
padding: 10px;
border-radius: 100%;
width: 16px;
height: 16px;
}
.card-content {
display: flex;
flex-direction: column;
gap: 5px;
}
</style>
+212 -341
View File
@@ -1,154 +1,111 @@
<template>
<AdminSection title="🎛️ Configuration" subtitle="Paramètres de config.json et état des intégrations (Discord, Google, Spotify, WARP, YouTube)">
<template #actions>
<Button icon="fa-solid fa-rotate" :disabled="adminStore.configLoading" @click="adminStore.fetchConfig()">Actualiser</Button>
<Button variant="secondary" icon="fa-solid fa-rotate" :loading="adminStore.configLoading" @click="adminStore.fetchConfig()">Actualiser</Button>
</template>
<AdminState :loading="!data && adminStore.configLoading" :error="!data ? adminStore.configError : null"
:empty="!data" empty-text="Aucune configuration reçue" @retry="adminStore.fetchConfig()">
<div v-if="data.restartRequired" class="banner banner-action">
<Info>
Des paramètres modifiés ne seront pris en compte qu'au prochain redémarrage de Subsonics :
{{ pendingLabels.join(", ") }}.
</Info>
<Button icon="fa-solid fa-screwdriver-wrench" @click="adminStore.setSection('maintenance')">Redémarrer depuis la maintenance</Button>
</div>
<AdminBanner v-if="data.restartRequired" tone="warning" title="Redémarrage nécessaire">
Ces paramètres ne seront pris en compte qu'au prochain redémarrage de Subsonics : {{ pendingLabels.join(", ") }}.
<template #action>
<Button size="sm" icon="fa-solid fa-screwdriver-wrench" @click="adminStore.setSection('maintenance')">Ouvrir la maintenance</Button>
</template>
</AdminBanner>
<!-- Intégrations -->
<AdminCard title="Intégrations" icon="fa-solid fa-plug" color="var(--admin-color)" class="wide">
<div class="integrations">
<div class="integration">
<div class="integration-head">
<span class="integration-name"><Icon icon="fa-brands fa-discord"/> Bot Discord</span>
<Tag :color="integ.discord?.ready ? 'var(--text-success)' : 'var(--text-error)'" :icon="integ.discord?.ready ? 'fa-solid fa-circle-check' : 'fa-solid fa-circle-xmark'">
{{ integ.discord?.ready ? "Connecté" : "Déconnecté" }}
</Tag>
</div>
<p v-if="integ.discord?.tag" class="hint">Compte : {{ integ.discord.tag }}</p>
<p class="hint">Redirection OAuth2 à déclarer dans le portail développeur Discord :</p>
<div class="uri-row">
<code class="uri">{{ integ.discord?.redirectUri || "Adresse du site non définie" }}</code>
<IconAction v-if="integ.discord?.redirectUri" title="Copier" icon="fa-solid fa-copy" @click="copy(integ.discord.redirectUri)"/>
<!-- Intégrations : un chiffre clé par service -->
<AdminStatRow :columns="3" label="État des intégrations">
<AdminStat label="Bot Discord" icon="fa-brands fa-discord" :tone="integ.discord?.ready ? 'success' : 'error'"
:value="integ.discord?.ready ? 'Connecté' : 'Déconnecté'" :sub="integ.discord?.tag ? 'Compte ' + integ.discord.tag : null"/>
<AdminStat label="Google (playlists YouTube)" icon="fa-brands fa-google" :tone="integ.google?.configured ? 'success' : 'warning'"
:value="integ.google?.configured ? 'Configuré' : 'Incomplet'" :sub="googleSub"/>
<AdminStat label="Spotify" icon="fa-brands fa-spotify" :tone="spotifyState.tone" :value="spotifyState.label" :sub="spotifySub"/>
<AdminStat label="Proxy WARP" icon="fa-solid fa-cloud" :tone="warpState.tone" :value="warpState.label" :sub="warpSub" to="warp"/>
<AdminStat label="Compte YouTube (lecture)" icon="fa-brands fa-youtube" :tone="cookiesState.tone" :value="cookiesState.label"
:sub="cookiesSub" to="youtube"/>
<AdminStat label="Base média" icon="fa-solid fa-photo-film" :tone="mediaState.tone" :value="mediaState.label"
sub="Salon Discord des fichiers envoyés"/>
</AdminStatRow>
<!-- Paramètres, une carte par service -->
<AdminGrid>
<AdminCard v-for="group in groups" :key="group.id" :title="group.title" :subtitle="group.hint" :icon="group.icon"
:tone="group.tone" :color="group.color" :span="6" :span-sm="12">
<!-- Adresses de redirection à déclarer chez le fournisseur -->
<div v-if="group.id === 'discord'" class="cfg-uri">
<p class="adm-hint">Redirection OAuth2 à déclarer dans le portail développeur Discord :</p>
<div class="cfg-uri__row">
<code class="adm-code cfg-uri__value">{{ integ.discord?.redirectUri || "Adresse du site non définie" }}</code>
<IconAction v-if="integ.discord?.redirectUri" label="Copier l'adresse de redirection Discord" icon="fa-solid fa-copy"
@click="copy(integ.discord.redirectUri)"/>
</div>
</div>
<div class="integration">
<div class="integration-head">
<span class="integration-name"><Icon icon="fa-brands fa-google"/> Google (playlists YouTube)</span>
<Tag :color="integ.google?.configured ? 'var(--text-success)' : 'var(--text-warning)'" :icon="integ.google?.configured ? 'fa-solid fa-circle-check' : 'fa-solid fa-triangle-exclamation'">
{{ integ.google?.configured ? "Configuré" : "Incomplet" }}
</Tag>
<template v-else-if="group.id === 'google'">
<AdminBanner v-if="!integ.google?.configured" tone="warning">Il manque : {{ googleMissing.join(", ") }}.</AdminBanner>
<div class="cfg-uri">
<p class="adm-hint">URI de redirection autorisée à déclarer dans la console Google Cloud (identifiants OAuth « Application Web ») :</p>
<div class="cfg-uri__row">
<code class="adm-code cfg-uri__value">{{ integ.google?.redirectUri || "Définissez d'abord l'adresse du site" }}</code>
<IconAction v-if="integ.google?.redirectUri" label="Copier l'adresse de redirection Google" icon="fa-solid fa-copy"
@click="copy(integ.google.redirectUri)"/>
</div>
</div>
<p class="hint">
<template v-if="integ.google?.accountsAvailable">
{{ safeNum(integ.google.linkedUsers) }} compte(s) lié(s)<template v-if="safeNum(integ.google.invalidUsers)">, dont {{ safeNum(integ.google.invalidUsers) }} à reconnecter</template>
</template>
<div class="cfg-fields">
<AdminField v-for="field in group.fields" :key="field.key" :for="'cfg-' + field.key" :label="field.label" :hint="field.hint"
:restart="!!entry(field.key).requiresRestart" :pending="!!entry(field.key).pendingRestart"
:dirty="isDirty(field)" :error="fieldError(field)">
<template #aside>
<span v-if="entry(field.key).secret" class="cfg-secret">
<Icon :icon="entry(field.key).set ? 'fa-solid fa-lock' : 'fa-solid fa-lock-open'" aria-hidden="true"/>
{{ entry(field.key).set ? "Défini · " + safeNum(entry(field.key).length) + " caractères" : "Non défini" }}
</span>
</template>
<template v-else>Aucun compte lié</template>
</p>
<p v-if="!integ.google?.configured" class="hint">
Il manque : {{ googleMissing.join(", ") }}.
</p>
<template #default="{ id, describedby, invalid }">
<input v-if="entry(field.key).secret" :id="id" type="password" v-model="drafts[field.key]"
class="adm-mono" :maxlength="field.max || 300" autocomplete="new-password" spellcheck="false"
:aria-describedby="describedby" :aria-invalid="invalid ? 'true' : undefined"
:placeholder="entry(field.key).set ? 'Nouvelle valeur (vide = conserver)' : 'Valeur'"
@keyup.enter="save(field)"/>
<input v-else :id="id" type="text" v-model="drafts[field.key]"
:class="{ 'adm-mono': field.mono }" :maxlength="field.max || 300" spellcheck="false" autocomplete="off"
:inputmode="field.kind === 'port' || field.kind === 'snowflake' ? 'numeric' : field.kind === 'url' ? 'url' : undefined"
:aria-describedby="describedby" :aria-invalid="invalid ? 'true' : undefined"
:placeholder="field.placeholder || ''" @keyup.enter="save(field)" @keyup.esc="resetDraft(field.key)"/>
<Button size="sm" icon="fa-solid fa-floppy-disk" :loading="!!saving[field.key]"
:disabled="!isDirty(field) || !!fieldError(field)" :aria-label="'Enregistrer « ' + field.label + ' »'"
@click="save(field)">Enregistrer</Button>
<IconAction v-if="!entry(field.key).secret && isDirty(field)" :label="'Annuler la modification de « ' + field.label + ' »'"
icon="fa-solid fa-xmark" @click="resetDraft(field.key)"/>
<IconAction v-if="entry(field.key).secret && isDirty(field)" :label="'Vider le champ « ' + field.label + ' »'"
icon="fa-solid fa-xmark" @click="drafts[field.key] = ''"/>
<IconAction v-if="entry(field.key).secret && entry(field.key).set && entry(field.key).optional && !isDirty(field)"
:label="'Effacer la valeur enregistrée de « ' + field.label + ' »'" icon="fa-solid fa-trash"
color="var(--text-error)" :disabled="!!saving[field.key]" @click="clearSecret(field)"/>
</template>
</AdminField>
</div>
<div class="integration">
<div class="integration-head">
<span class="integration-name"><Icon icon="fa-brands fa-spotify"/> Spotify</span>
<Tag :color="spotifyState.color" :icon="spotifyState.icon">{{ spotifyState.label }}</Tag>
</div>
<p v-if="integ.spotify?.lastTest" class="hint">
Dernier test {{ formatRelative(integ.spotify.lastTest.at, nowDate) }} : {{ integ.spotify.lastTest.message }}
</p>
<p v-else class="hint">Liens publics uniquement (albums, playlists, titres), convertis vers YouTube.</p>
<div class="actions">
<Button icon="fa-solid fa-vial" :disabled="spotifyTesting || !integ.spotify?.configured" @click="testSpotify()">
{{ spotifyTesting ? "Test…" : "Tester" }}
</Button>
</div>
</div>
<div class="integration">
<div class="integration-head">
<span class="integration-name"><Icon icon="fa-solid fa-cloud"/> Proxy WARP</span>
<Tag :color="warpState.color" :icon="warpState.icon">{{ warpState.label }}</Tag>
</div>
<p class="hint">{{ integ.warp?.configured ? "Routes : " + (integ.warp.routes || []).map(r => r === 'warp' ? 'WARP' : 'directe').join(" puis ") : "Connexion directe uniquement" }}</p>
<div class="actions">
<Button class="secondary" icon="fa-solid fa-arrow-right" @click="adminStore.setSection('warp')">Section WARP</Button>
</div>
</div>
<div class="integration">
<div class="integration-head">
<span class="integration-name"><Icon icon="fa-brands fa-youtube"/> Compte YouTube (lecture)</span>
<Tag :color="cookiesState.color" :icon="cookiesState.icon">{{ cookiesState.label }}</Tag>
</div>
<p class="hint">
<template v-if="integ.youtube?.cookies?.present">Cookies importés {{ formatRelative(integ.youtube.cookies.importedAt, nowDate) }}<template v-if="integ.youtube.cookies.invalidReason"> · {{ integ.youtube.cookies.invalidReason }}</template></template>
<template v-else>Lecture anonyme (aucun cookie)</template>
</p>
<div class="actions">
<Button class="secondary" icon="fa-solid fa-arrow-right" @click="adminStore.setSection('youtube')">Section YouTube</Button>
</div>
</div>
<div class="integration">
<div class="integration-head">
<span class="integration-name"><Icon icon="fa-solid fa-photo-film"/> Base média</span>
<Tag :color="!integ.media?.configured ? 'var(--text-secondary)' : integ.media.connected ? 'var(--text-success)' : 'var(--text-error)'"
:icon="integ.media?.connected ? 'fa-solid fa-circle-check' : 'fa-solid fa-circle-xmark'">
{{ !integ.media?.configured ? "Non configurée" : integ.media.connected ? "Salon accessible" : "Salon introuvable" }}
</Tag>
</div>
<p class="hint">Salon Discord où sont stockés les fichiers envoyés par les utilisateurs.</p>
</div>
</div>
</AdminCard>
<!-- Paramètres -->
<div class="cards">
<AdminCard v-for="group in groups" :key="group.id" :title="group.title" :icon="group.icon" :color="group.color">
<p v-if="group.hint" class="hint">{{ group.hint }}</p>
<div v-if="group.id === 'google'" class="uri-block">
<p class="hint">URI de redirection autorisée à déclarer dans la console Google Cloud (identifiants OAuth « Application Web ») :</p>
<div class="uri-row">
<code class="uri">{{ integ.google?.redirectUri || "Définissez d'abord l'adresse du site" }}</code>
<IconAction v-if="integ.google?.redirectUri" title="Copier" icon="fa-solid fa-copy" @click="copy(integ.google.redirectUri)"/>
</div>
</div>
<div v-for="field in group.fields" :key="field.key" class="field">
<div class="field-head">
<label class="field-label" :for="'cfg-' + field.key">{{ field.label }}</label>
<Tag v-if="entry(field.key).pendingRestart" color="var(--text-warning)" icon="fa-solid fa-power-off">Redémarrage requis</Tag>
<Tag v-else-if="entry(field.key).requiresRestart" color="var(--text-secondary)" icon="fa-solid fa-power-off">Au redémarrage</Tag>
</div>
<p v-if="entry(field.key).secret" class="secret-state">
<Icon :icon="entry(field.key).set ? 'fa-solid fa-lock' : 'fa-solid fa-lock-open'"/>
{{ entry(field.key).set ? "Défini (" + safeNum(entry(field.key).length) + " caractères)" : "Non défini" }}
</p>
<div class="field-row">
<input v-if="entry(field.key).secret" :id="'cfg-' + field.key" type="password" v-model="drafts[field.key]"
class="input mono" :maxlength="field.max || 300" autocomplete="new-password" spellcheck="false"
:placeholder="entry(field.key).set ? 'Nouvelle valeur (laisser vide pour conserver)' : 'Valeur'"
@keyup.enter="save(field)"/>
<input v-else :id="'cfg-' + field.key" type="text" v-model="drafts[field.key]"
:class="['input', { mono: field.mono }]" :maxlength="field.max || 300" spellcheck="false" autocomplete="off"
:inputmode="field.numeric ? 'numeric' : undefined" :placeholder="field.placeholder || ''"
@keyup.enter="save(field)"/>
<Button icon="fa-solid fa-floppy-disk" :disabled="!isDirty(field) || saving[field.key]" @click="save(field)">
{{ saving[field.key] ? "…" : "Enregistrer" }}
</Button>
<IconAction v-if="!entry(field.key).secret && isDirty(field)" title="Annuler" icon="fa-solid fa-xmark" @click="resetDraft(field.key)"/>
<IconAction v-if="entry(field.key).secret && entry(field.key).set && entry(field.key).optional" title="Effacer"
icon="fa-solid fa-trash" color="var(--text-error)" @click="clearSecret(field)"/>
</div>
<p v-if="field.hint" class="hint">{{ field.hint }}</p>
</div>
<template v-if="group.id === 'spotify'" #footer>
<span class="adm-footer-meta" aria-live="polite">
<template v-if="integ.spotify?.lastTest">
Dernier test {{ formatRelative(integ.spotify.lastTest.at, nowDate) }} : {{ integ.spotify.lastTest.message }}
</template>
<template v-else>Liens publics uniquement (albums, playlists, titres), convertis vers YouTube.</template>
</span>
<Button variant="secondary" size="sm" icon="fa-solid fa-vial" :loading="spotifyTesting"
:disabled="!integ.spotify?.configured" @click="testSpotify()">
{{ spotifyTesting ? "Test…" : "Tester la connexion" }}
</Button>
</template>
</AdminCard>
</div>
<p class="hint">
</AdminGrid>
<p class="adm-hint cfg-note">
<Icon icon="fa-solid fa-shield-halved" aria-hidden="true"/>
Les secrets ne sont jamais renvoyés au navigateur ni écrits dans les journaux : seule leur longueur est affichée.
Chaque modification est enregistrée dans config.json et journalisée (nom du paramètre uniquement).
</p>
@@ -167,63 +124,67 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { useAdminStore } from '@/stores/adminStore';
import { adminRequest } from '@/utils/AdminRequest';
import Button from '@/components/UI/Button.vue';
import Tag from '@/components/UI/Tag.vue';
import Info from '@/components/UI/Info.vue';
import IconAction from '@/components/UI/IconAction.vue';
import AdminSection from './AdminSection.vue';
import AdminState from './AdminState.vue';
import AdminGrid from './AdminGrid.vue';
import AdminCard from './AdminCard.vue';
import AdminStatRow from './AdminStatRow.vue';
import AdminStat from './AdminStat.vue';
import AdminBanner from './AdminBanner.vue';
import AdminField from './AdminField.vue';
import AdminConfirm from './AdminConfirm.vue';
import { safeNum, formatRelative } from './adminFormat';
const adminStore = useAdminStore();
const confirmRef = ref(null);
// kind : même validation que le serveur (Configuration.validateValue) — text | url | port | snowflake
const groups = [
{
id: 'discord', title: "Discord", icon: "fa-brands fa-discord", color: "var(--admin-color)",
id: 'discord', title: "Discord", icon: "fa-brands fa-discord", tone: 'admin',
fields: [
{ key: 'token', label: "Token du bot", max: 200, hint: "Portail développeur Discord > Bot > Reset Token. Un token invalide empêche le bot de démarrer." },
{ key: 'client_secret', label: "Secret OAuth2 (connexion au site)", max: 200, hint: "Portail développeur Discord > OAuth2 > Client Secret. Pris en compte immédiatement." }
{ key: 'token', kind: 'text', label: "Token du bot", max: 200, hint: "Portail développeur Discord > Bot > Reset Token. Un token invalide empêche le bot de démarrer." },
{ key: 'client_secret', kind: 'text', label: "Secret OAuth2 (connexion au site)", max: 200, hint: "Portail développeur Discord > OAuth2 > Client Secret. Pris en compte immédiatement." }
]
},
{
id: 'website', title: "Site web", icon: "fa-solid fa-globe", color: "var(--main)",
id: 'website', title: "Site web", icon: "fa-solid fa-globe", tone: 'main',
fields: [
{ key: 'website', label: "Adresse publique du site", mono: true, max: 300, placeholder: "https://subsonics.example.org", hint: "Sert aux redirections Discord (/redirect) et Google (/oauth2callback)." },
{ key: 'server_port', label: "Port du serveur", mono: true, numeric: true, max: 5, placeholder: "5000", hint: "Port d'écoute du backend (le healthcheck Docker utilise 4000)." }
{ key: 'website', kind: 'url', label: "Adresse publique du site", mono: true, max: 300, placeholder: "https://subsonics.example.org", hint: "Sert aux redirections Discord (/redirect) et Google (/oauth2callback)." },
{ key: 'server_port', kind: 'port', label: "Port du serveur", mono: true, max: 5, placeholder: "5000", hint: "Port d'écoute du backend (le healthcheck Docker utilise 4000)." }
]
},
{
id: 'google', title: "Google / YouTube Data API", icon: "fa-brands fa-google", color: "var(--text-error)",
id: 'google', title: "Google / YouTube Data API", icon: "fa-brands fa-google", tone: 'error',
hint: "Permet aux utilisateurs de lier leur compte Google pour importer leurs playlists YouTube (et « J'aime »).",
fields: [
{ key: 'api.youtube.clientId', label: "ID client OAuth", mono: true, max: 300, placeholder: "….apps.googleusercontent.com" },
{ key: 'api.youtube.clientSecret', label: "Secret client OAuth", max: 300 }
{ key: 'api.youtube.clientId', kind: 'text', label: "ID client OAuth", mono: true, max: 300, placeholder: "….apps.googleusercontent.com" },
{ key: 'api.youtube.clientSecret', kind: 'text', label: "Secret client OAuth", max: 300 }
]
},
{
id: 'spotify', title: "Spotify", icon: "fa-brands fa-spotify", color: "var(--mod-color)",
hint: "Application Spotify (developer.spotify.com) : identifiants « client credentials ».",
fields: [
{ key: 'api.spotify.clientId', label: "Client ID", mono: true, max: 200 },
{ key: 'api.spotify.clientSecret', label: "Client secret", max: 200 }
{ key: 'api.spotify.clientId', kind: 'text', label: "Client ID", mono: true, max: 200 },
{ key: 'api.spotify.clientSecret', kind: 'text', label: "Client secret", max: 200 }
]
},
{
id: 'media', title: "Base média", icon: "fa-solid fa-photo-film", color: "var(--owner-color)",
hint: "Serveur et salon Discord où le bot dépose les fichiers envoyés par les utilisateurs.",
fields: [
{ key: 'media.guildId', label: "ID du serveur", mono: true, numeric: true, max: 25 },
{ key: 'media.channelId', label: "ID du salon", mono: true, numeric: true, max: 25 }
{ key: 'media.guildId', kind: 'snowflake', label: "ID du serveur", mono: true, max: 25 },
{ key: 'media.channelId', kind: 'snowflake', label: "ID du salon", mono: true, max: 25 }
]
},
{
id: 'report', title: "Signalements", icon: "fa-solid fa-flag", color: "var(--text-warning)",
id: 'report', title: "Signalements", icon: "fa-solid fa-flag", tone: 'warning',
hint: "Destination des signalements envoyés depuis le site.",
fields: [
{ key: 'report.channel', label: "ID du salon de signalement", mono: true, numeric: true, max: 25 },
{ key: 'report.contact', label: "ID de l'utilisateur à contacter", mono: true, numeric: true, max: 25 }
{ key: 'report.channel', kind: 'snowflake', label: "ID du salon de signalement", mono: true, max: 25 },
{ key: 'report.contact', kind: 'snowflake', label: "ID de l'utilisateur à contacter", mono: true, max: 25 }
]
}
];
@@ -269,12 +230,35 @@ function isDirty(field) {
return draft.trim() !== serverText(field.key);
}
// Validation locale (le serveur revalide) : affichée seulement pour une valeur modifiée
function fieldError(field) {
if (!isDirty(field)) return null;
const text = String(drafts[field.key] ?? "").trim();
if (text === "") {
return entry(field.key).optional === false ? "Ce paramètre ne peut pas être vide" : null;
}
switch (field.kind) {
case 'port': {
const n = Number(text);
return /^\d+$/.test(text) && n >= 1 && n <= 65535 ? null : "Port invalide : entier entre 1 et 65535";
}
case 'snowflake':
return /^\d{5,25}$/.test(text) ? null : "Identifiant Discord invalide : 5 à 25 chiffres";
case 'url':
if (!/^https?:\/\/\S+$/i.test(text)) return "Lien http(s):// attendu, sans espace";
if (/[?#]/.test(text)) return "L'adresse ne doit contenir ni paramètres (?) ni ancre (#)";
return null;
default:
return /\s/.test(text) ? "Les espaces sont interdits (copier-coller incomplet ?)" : null;
}
}
function resetDraft(key) {
drafts[key] = serverText(key);
}
const pendingLabels = computed(() => Object.entries(data.value?.config || {})
.filter(([, value]) => value.pendingRestart)
.filter(([, value]) => value?.pendingRestart)
.map(([key]) => labelOf[key] || key));
const googleMissing = computed(() => {
@@ -285,31 +269,55 @@ const googleMissing = computed(() => {
return missing;
});
const googleSub = computed(() => {
const google = integ.value.google;
if (!google?.accountsAvailable) return "Aucun compte lié";
const invalid = safeNum(google.invalidUsers);
return safeNum(google.linkedUsers) + " compte(s) lié(s)" + (invalid ? ", dont " + invalid + " à reconnecter" : "");
});
const spotifyState = computed(() => {
const spotify = integ.value.spotify;
if (!spotify?.configured) return { label: "Non configuré", color: "var(--text-secondary)", icon: "fa-solid fa-circle-minus" };
if (spotify.lastTest) {
return spotify.lastTest.ok
? { label: "Testé : OK", color: "var(--text-success)", icon: "fa-solid fa-circle-check" }
: { label: "Test échoué", color: "var(--text-error)", icon: "fa-solid fa-circle-xmark" };
}
return { label: "Configuré", color: "var(--admin-color)", icon: "fa-solid fa-circle-check" };
if (!spotify?.configured) return { label: "Non configuré", tone: "default" };
if (spotify.lastTest) return spotify.lastTest.ok ? { label: "Testé : OK", tone: "success" } : { label: "Test échoué", tone: "error" };
return { label: "Configuré", tone: "admin" };
});
const spotifySub = computed(() => {
const last = integ.value.spotify?.lastTest;
return last ? "Testé " + formatRelative(last.at, nowDate.value) : "Liens publics uniquement";
});
const warpState = computed(() => {
const warp = integ.value.warp;
if (!warp?.configured) return { label: "Non configuré", color: "var(--text-secondary)", icon: "fa-solid fa-circle-minus" };
return warp.healthy
? { label: "Opérationnel", color: "var(--text-success)", icon: "fa-solid fa-circle-check" }
: { label: "Injoignable", color: "var(--text-error)", icon: "fa-solid fa-circle-xmark" };
if (!warp?.configured) return { label: "Non configuré", tone: "default" };
return warp.healthy ? { label: "Opérationnel", tone: "success" } : { label: "Injoignable", tone: "error" };
});
const warpSub = computed(() => {
const warp = integ.value.warp;
if (!warp?.configured) return "Connexion directe uniquement";
return "Routes : " + (warp.routes || []).map(r => (r === 'warp' ? 'WARP' : 'directe')).join(" puis ");
});
const cookiesState = computed(() => {
const cookies = integ.value.youtube?.cookies;
if (!cookies?.present) return { label: "Aucun compte", color: "var(--text-secondary)", icon: "fa-solid fa-user-slash" };
if (!cookies.loggedIn) return { label: "Non connecté", color: "var(--text-warning)", icon: "fa-solid fa-triangle-exclamation" };
if (!cookies.valid) return { label: "Invalidé", color: "var(--text-error)", icon: "fa-solid fa-circle-xmark" };
return { label: "Connecté", color: "var(--text-success)", icon: "fa-solid fa-circle-check" };
if (!cookies?.present) return { label: "Aucun compte", tone: "default" };
if (!cookies.loggedIn) return { label: "Non connecté", tone: "warning" };
if (!cookies.valid) return { label: "Invalidé", tone: "error" };
return { label: "Connecté", tone: "success" };
});
const cookiesSub = computed(() => {
const cookies = integ.value.youtube?.cookies;
if (!cookies?.present) return "Lecture anonyme (aucun cookie)";
return "Importés " + formatRelative(cookies.importedAt, nowDate.value) + (cookies.invalidReason ? " · " + cookies.invalidReason : "");
});
const mediaState = computed(() => {
const media = integ.value.media;
if (!media?.configured) return { label: "Non configurée", tone: "default" };
return media.connected ? { label: "Salon accessible", tone: "success" } : { label: "Salon introuvable", tone: "error" };
});
async function send(field, value) {
@@ -336,7 +344,7 @@ async function send(field, value) {
}
async function save(field) {
if (!isDirty(field) || saving[field.key]) return;
if (!isDirty(field) || saving[field.key] || fieldError(field)) return;
const value = drafts[field.key].trim();
if (field.key === 'token') {
const ok = await confirmRef.value.ask({
@@ -400,188 +408,51 @@ onMounted(() => {
onUnmounted(() => clearInterval(timer));
</script>
<style scoped>
p {
margin: 0;
}
.banner {
background-color: var(--tertiary);
border-radius: 10px;
padding: 10px;
}
.banner-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 10px;
align-items: start;
}
.wide {
grid-column: 1 / -1;
}
.hint {
font-size: 0.8em;
color: var(--text-secondary);
}
.mono {
font-family: monospace;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.secondary {
background-color: var(--secondary);
color: var(--text);
}
.secondary:hover {
background-color: var(--quaternary);
}
/* Intégrations */
.integrations {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 10px;
}
.integration {
.cfg-fields {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Champ + bouton : passent à la ligne sur une carte étroite */
.cfg-fields :deep(.adm-field__control) {
flex-wrap: wrap;
}
.cfg-fields :deep(.adm-field__control > input) {
flex: 1 1 180px;
}
.cfg-secret {
display: inline-flex;
align-items: center;
gap: 5px;
background-color: var(--secondary);
border-radius: 10px;
padding: 10px;
white-space: nowrap;
}
.cfg-uri {
display: flex;
flex-direction: column;
gap: 6px;
}
.cfg-uri__row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.integration-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.integration-name {
font-weight: 600;
display: flex;
align-items: center;
gap: 5px;
}
.uri-block {
display: flex;
flex-direction: column;
gap: 5px;
}
.uri-row {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
}
.uri {
.cfg-uri__value {
flex: 1;
min-width: 0;
font-size: 0.75em;
background-color: var(--secondary);
border-radius: 5px;
padding: 7px;
word-break: break-all;
padding: 7px 9px;
user-select: all;
}
.integration .uri {
background-color: var(--tertiary);
}
/* Champs */
.field {
.cfg-note {
display: flex;
flex-direction: column;
gap: 5px;
padding-bottom: 10px;
border-bottom: 1px solid var(--quaternary);
}
.field:last-child {
border-bottom: none;
padding-bottom: 0;
}
.field-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.field-label {
font-weight: 600;
font-size: 0.9em;
}
.secret-state {
font-size: 0.8em;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 5px;
}
.field-row {
display: flex;
align-items: center;
gap: 5px;
}
.input {
flex: 1;
min-width: 0;
background-color: var(--secondary);
border: none;
border-radius: 5px;
padding: 7px;
color: var(--text);
font-size: 0.9em;
outline: none;
}
.input:not(.mono) {
font-family: 'Inter', sans-serif;
}
.input:focus {
box-shadow: 0 0 5px var(--main);
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.cards {
grid-template-columns: 1fr;
}
.field-row {
flex-wrap: wrap;
}
align-items: baseline;
gap: 6px;
}
</style>
+16 -45
View File
@@ -1,23 +1,23 @@
<template>
<Modal ref="modal" :title="options.title" :icon="options.icon">
<p ref="messageRef" class="confirm-message">{{ options.message }}</p>
<Modal ref="modal" size="sm" :title="options.title" :icon="options.icon" @close="onModalClose">
<p class="confirm-message">{{ options.message }}</p>
<div v-if="options.requireText" class="confirm-field">
<label>Pour confirmer, tape <strong class="mono">{{ options.requireText }}</strong> ci-dessous :</label>
<input type="text" v-model="typed" :placeholder="options.requireText" @keyup.enter="confirm()"/>
<label :for="`${uid}-text`">Pour confirmer, tape <strong class="mono">{{ options.requireText }}</strong> ci-dessous :</label>
<input :id="`${uid}-text`" type="text" v-model="typed" :placeholder="options.requireText" autocomplete="off" data-autofocus @keyup.enter="confirm()"/>
</div>
<div v-if="options.askReason" class="confirm-field">
<label>{{ options.reasonLabel }}</label>
<textarea v-model="reason" :maxlength="options.reasonMax" :placeholder="options.reasonPlaceholder"></textarea>
<span class="hint">{{ reason.trim().length }} / {{ options.reasonMax }} caractères (minimum {{ options.reasonMin }})</span>
<label :for="`${uid}-reason`">{{ options.reasonLabel }}</label>
<textarea :id="`${uid}-reason`" v-model="reason" :maxlength="options.reasonMax" :placeholder="options.reasonPlaceholder" :aria-describedby="`${uid}-reason-hint`"></textarea>
<span :id="`${uid}-reason-hint`" class="hint">{{ reason.trim().length }} / {{ options.reasonMax }} caractères (minimum {{ options.reasonMin }})</span>
</div>
<label v-if="options.checkbox" class="confirm-check">
<input type="checkbox" v-model="checked"/>
<span>{{ options.checkbox }}</span>
</label>
<div class="confirm-actions">
<Button class="cancel" icon="fa-solid fa-xmark" @click="cancel()">Annuler</Button>
<template #footer>
<Button variant="secondary" icon="fa-solid fa-xmark" @click="cancel()">Annuler</Button>
<Button :disabled="!canConfirm" :icon="options.confirmIcon" @click="confirm()">{{ options.confirmLabel }}</Button>
</div>
</template>
</Modal>
</template>
<script setup>
@@ -26,10 +26,9 @@
* Usage : const result = await confirmRef.value.ask({ title, message, requireText?, askReason?, checkbox? })
* Renvoie null si annulé, sinon { reason, checked }.
*/
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { ref, computed, onUnmounted, useId } from 'vue';
import Modal from '@/components/UI/Modal.vue';
import Button from '@/components/UI/Button.vue';
import Events from '@/utils/Events';
const defaults = {
title: "Confirmation",
@@ -47,6 +46,7 @@ const defaults = {
checkboxDefault: false
};
const uid = `confirm-${useId()}`;
const modal = ref(null);
const options = ref({ ...defaults });
const typed = ref("");
@@ -91,31 +91,19 @@ function cancel() {
modal.value?.close();
}
const messageRef = ref(null);
// Fermeture par la croix ou un clic extérieur : Modal n'émet que l'événement global "modal:close",
// partagé par toutes les modales. On n'annule que si NOTRE modale est réellement masquée
// (Modal masque son overlay avant d'émettre l'événement).
// Fermeture par la croix, Échap ou un clic extérieur : événement "close" de NOTRE modale uniquement
// (confirm() et cancel() ont déjà réglé la promesse, pending vaut alors null).
function onModalClose() {
if (!pending) return;
const element = messageRef.value;
if (element && element.getClientRects().length > 0) return;
settle(null);
}
onMounted(() => Events.on("modal:close", onModalClose));
onUnmounted(() => {
Events.off("modal:close", onModalClose);
settle(null);
});
onUnmounted(() => settle(null));
defineExpose({ ask });
</script>
<style scoped>
.confirm-message {
margin: 0;
font-size: 1em !important;
font-weight: normal !important;
color: var(--text-secondary);
white-space: pre-line;
}
@@ -137,8 +125,7 @@ defineExpose({ ask });
}
.mono {
font-family: monospace;
font-size: 1em !important;
font-family: var(--font-mono);
word-break: break-all;
}
@@ -149,20 +136,4 @@ defineExpose({ ask });
font-size: 0.9em;
cursor: pointer;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.cancel {
background-color: var(--tertiary);
color: var(--text);
}
.cancel:hover {
background-color: var(--quaternary);
}
</style>
+468 -101
View File
@@ -1,45 +1,62 @@
<template>
<DefaultSplash v-if="!isAdmin">
<h1 class="denied-title"><Icon icon="fa-solid fa-lock"/> Accès refusé</h1>
<h1 class="denied-title"><Icon icon="fa-solid fa-lock" aria-hidden="true"/> Accès refusé</h1>
<Error>Cette partition est réservée aux administrateurs de Subsonics.</Error>
<Button icon="fa-solid fa-arrow-left" @click="router.push('/servers')">Revenir au concert</Button>
<Button icon="fa-solid fa-arrow-left" @click="goBack()">Revenir au concert</Button>
</DefaultSplash>
<div v-else class="admin-container">
<div class="left-side">
<Box padding="closed" class="nav-box">
<SubsonicsLogo class="logo"/>
<p class="admin-label"><Icon icon="fa-solid fa-star"/> Administration</p>
<nav class="admin-nav">
<button v-for="item in sections" :key="item.id"
:class="['nav-item', { active: adminStore.section === item.id }]"
@click="adminStore.setSection(item.id)">
<Icon class="nav-icon" :icon="item.icon"/>
<span>{{ item.label }}</span>
<span v-if="badges[item.id]" :class="['nav-badge', badges[item.id].type]">{{ badges[item.id].text }}</span>
</button>
<a class="skip-link" href="#admin-main" @click.prevent="focusMain()">Aller au contenu</a>
<div class="admin-side">
<Box padding="closed" class="side-box">
<div class="brand">
<SubsonicsLogo class="logo"/>
<p class="admin-label"><Icon icon="fa-solid fa-star" aria-hidden="true"/> Administration</p>
<IconAction class="back-mobile" icon="fa-solid fa-arrow-left" label="Retour aux serveurs" @click="goBack()"/>
</div>
<nav ref="navRef" :class="['admin-nav', { 'fade-start': fadeStart, 'fade-end': fadeEnd }]"
aria-label="Sections de l'administration" @scroll.passive="updateFades()">
<div v-for="group in groups" :key="group.id" class="nav-group">
<p :id="'admin-nav-' + group.id" class="nav-group-label">{{ group.label }}</p>
<ul class="nav-list" :aria-labelledby="'admin-nav-' + group.id">
<li v-for="item in group.items" :key="item.id">
<button type="button" :class="['nav-item', { active: current.id === item.id }]"
:aria-current="current.id === item.id ? 'page' : undefined"
@click="adminStore.setSection(item.id)">
<Icon class="nav-icon" :icon="item.icon" aria-hidden="true"/>
<span class="nav-text">{{ item.label }}</span>
<span v-if="badges[item.id]" :class="['nav-badge', badges[item.id].type]" :title="badges[item.id].sr">
<span aria-hidden="true">{{ badges[item.id].text }}</span>
<span class="sr-only">({{ badges[item.id].sr }})</span>
</span>
</button>
</li>
</ul>
</div>
</nav>
<Button class="back" icon="fa-solid fa-arrow-left" @click="router.push('/servers')">Retour aux serveurs</Button>
<Button class="back" variant="secondary" block icon="fa-solid fa-arrow-left" @click="goBack()">Retour aux serveurs</Button>
</Box>
<Account class="account"/>
</div>
<Box class="main" padding="closed">
<main id="admin-main" ref="mainRef" class="admin-main" tabindex="-1" :aria-labelledby="ADMIN_SECTION_TITLE_ID">
<component :is="current.component" :key="current.id"/>
</Box>
</main>
<AdminToasts/>
</div>
</template>
<script setup>
/**
* Tableau de bord d'administration : navigation par sections + zone principale.
* Tableau de bord d'administration : navigation groupée par thèmes + zone principale.
* Les poussées temps réel ("/ADMIN/*\/UPDATE") sont enregistrées au montage et retirées au démontage,
* car routes.js fait socket.removeAllListeners() à chaque navigation.
* Sur mobile : en-tête compact, navigation horizontale collante (l'élément actif est ramené au centre).
*/
import { computed, onMounted, onUnmounted, watch } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/userStore';
import { useAdminStore } from '@/stores/adminStore';
import Box from '@/components/UI/Box.vue';
import Button from '@/components/UI/Button.vue';
import IconAction from '@/components/UI/IconAction.vue';
import Error from '@/components/UI/Error.vue';
import DefaultSplash from '@/components/UI/DefaultSplash.vue';
import SubsonicsLogo from '@/components/UI/SubsonicsLogo.vue';
@@ -56,6 +73,8 @@ import AdminMetrics from './AdminMetrics.vue';
import AdminMaintenance from './AdminMaintenance.vue';
import AdminPlaylists from './AdminPlaylists.vue';
import AdminConfig from './AdminConfig.vue';
import { ADMIN_NAV_KEY, ADMIN_SECTION_TITLE_ID } from './adminUi';
import './admin.scss';
const router = useRouter();
const userStore = useUserStore();
@@ -64,29 +83,58 @@ const adminStore = useAdminStore();
// Source de vérité : les labels (userInfo est remplacé à chaque /USER/READY)
const isAdmin = computed(() => !!userStore.userInfo?.labels?.includes('ADMIN'));
const sections = [
{ id: 'overview', label: "Vue d'ensemble", icon: "fa-solid fa-gauge-high", component: AdminOverview },
{ id: 'players', label: "Players", icon: "fa-solid fa-music", component: AdminPlayers },
{ id: 'servers', label: "Serveurs", icon: "fa-solid fa-server", component: AdminServers },
{ id: 'users', label: "Comptes", icon: "fa-solid fa-users", component: AdminUsers },
{ id: 'playlists', label: "Playlists", icon: "fa-solid fa-list", component: AdminPlaylists },
{ id: 'logs', label: "Logs", icon: "fa-solid fa-scroll", component: AdminLogs },
{ id: 'warp', label: "WARP", icon: "fa-solid fa-cloud", component: AdminWarp },
{ id: 'youtube', label: "YouTube", icon: "fa-brands fa-youtube", component: AdminYoutube },
{ id: 'metrics', label: "Métriques", icon: "fa-solid fa-chart-simple", component: AdminMetrics },
{ id: 'config', label: "Configuration", icon: "fa-solid fa-sliders", component: AdminConfig },
{ id: 'maintenance', label: "Maintenance", icon: "fa-solid fa-screwdriver-wrench", component: AdminMaintenance }
const GROUPS = [
{ id: 'supervision', label: "Supervision" },
{ id: 'system', label: "Système" },
{ id: 'settings', label: "Réglages" }
];
const sections = [
{ id: 'overview', group: 'supervision', label: "Vue d'ensemble", icon: "fa-solid fa-gauge-high", component: AdminOverview },
{ id: 'players', group: 'supervision', label: "Players", icon: "fa-solid fa-music", component: AdminPlayers },
{ 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: '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 },
{ id: 'metrics', group: 'system', label: "Métriques", icon: "fa-solid fa-chart-simple", component: AdminMetrics },
{ id: 'config', group: 'settings', label: "Configuration", icon: "fa-solid fa-sliders", component: AdminConfig },
{ id: 'maintenance', group: 'settings', label: "Maintenance", icon: "fa-solid fa-screwdriver-wrench", component: AdminMaintenance }
];
const groups = GROUPS.map(group => ({ ...group, items: sections.filter(s => s.group === group.id) }));
const current = computed(() => sections.find(s => s.id === adminStore.section) || sections[0]);
// Petites pastilles d'état dans la navigation
// Les sections (AdminSection, AdminStat `to`) y lisent l'icône et les libellés de la navigation
provide(ADMIN_NAV_KEY, { sections, current });
// Petites pastilles d'état dans la navigation (texte court visible + phrase pour les lecteurs d'écran)
const badges = computed(() => {
const result = {};
const playing = adminStore.players.filter(p => p.status === 'playing').length;
if (adminStore.players.length) result.players = { text: playing + "/" + adminStore.players.length, type: playing ? 'good' : 'neutral' };
const troubled = adminStore.players.filter(p => p.status === 'stalled' || (p.lastError && !p.lastErrorStale)).length;
if (troubled) {
result.players = { text: "!", type: 'bad', sr: troubled + " player(s) en difficulté" };
} else if (adminStore.players.length) {
result.players = {
text: playing + "/" + adminStore.players.length,
type: playing ? 'good' : 'neutral',
sr: playing + " en lecture sur " + adminStore.players.length + " connecté(s)"
};
}
// Serveurs où il manque des permissions au bot (seulement si la liste a été chargée)
if (adminStore.guildsLoaded) {
const missing = adminStore.guilds.filter(g => g.missingPermissions?.length).length;
if (missing) result.servers = { text: missing, type: 'warn', sr: missing + " serveur(s) avec des permissions manquantes" };
}
const warp = adminStore.warp || adminStore.overview?.warp;
if (warp?.configured) result.warp = warp.healthy ? { text: "OK", type: 'good' } : { text: "KO", type: 'bad' };
if (warp?.configured) {
result.warp = warp.healthy
? { text: "OK", type: 'good', sr: "WARP opérationnel" }
: { text: "KO", type: 'bad', sr: "WARP injoignable" };
}
// YouTube : statut complet si la section a été chargée, sinon le résumé de la vue d'ensemble
const yt = adminStore.youtube;
const ytCookies = yt?.cookies || adminStore.overview?.ytdlp?.cookies;
@@ -94,90 +142,249 @@ const badges = computed(() => {
const recentCount = yt ? yt.diagnostics?.recentCount : adminStore.overview?.ytdlp?.recentCount;
// La majorité des dernières lectures échoue (au moins 4 tentatives) : problème, même sans compte
const failing = Number(recentCount) >= 4 && Number(failureRate) >= 0.5;
if (failing || (ytCookies?.present && !ytCookies.valid)) result.youtube = { text: "!", type: 'bad' };
else if (ytCookies?.valid) result.youtube = { text: "OK", type: 'good' };
if (adminStore.overview?.logs?.interrupted) result.logs = { text: "!", type: 'bad' };
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" };
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' };
if (online) result.users = { text: online, type: 'neutral', sr: online + " en ligne" };
// Paramètres modifiés en attente d'un redémarrage (seulement si la section a été chargée)
if (adminStore.config?.restartRequired) result.config = { text: "!", type: 'warn' };
if (adminStore.config?.restartRequired) result.config = { text: "!", type: 'warn', sr: "redémarrage requis" };
return result;
});
// ----- Navigation, défilement et focus -----
const navRef = ref(null);
const mainRef = ref(null);
const fadeStart = ref(false);
const fadeEnd = ref(false);
let resizeObserver = null;
function prefersReducedMotion() {
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) { return false; }
}
// Fondu sur les bords de la navigation horizontale (mobile) quand il reste des éléments à faire défiler
function updateFades() {
const nav = navRef.value;
if (!nav) return;
const horizontal = nav.scrollWidth > nav.clientWidth + 1;
fadeStart.value = horizontal && nav.scrollLeft > 4;
fadeEnd.value = horizontal && nav.scrollLeft + nav.clientWidth < nav.scrollWidth - 4;
}
// Ramène l'élément actif dans la zone visible de la navigation (centré à l'horizontale sur mobile)
function revealActiveItem(smooth = true) {
const nav = navRef.value;
const item = nav?.querySelector('[aria-current="page"]');
if (!nav || !item) return;
const behavior = smooth && !prefersReducedMotion() ? 'smooth' : 'auto';
if (nav.scrollWidth > nav.clientWidth + 1) {
const left = item.offsetLeft - (nav.clientWidth - item.offsetWidth) / 2;
nav.scrollTo({ left: Math.max(0, left), behavior });
} else if (nav.scrollHeight > nav.clientHeight + 1) {
const top = item.offsetTop;
const bottom = top + item.offsetHeight;
if (top < nav.scrollTop) nav.scrollTo({ top: top - 8, behavior });
else if (bottom > nav.scrollTop + nav.clientHeight) nav.scrollTo({ top: bottom - nav.clientHeight + 8, behavior });
}
}
function focusMain() {
mainRef.value?.focus();
}
function goBack() {
router.push('/servers');
}
watch(() => current.value.id, async () => {
document.title = "Administration · " + current.value.label + " - Subsonics";
// Le focus était dans l'ancienne section (tuile « to », lien interne…) : il irait sur <body>, on le pose sur le titre
const active = document.activeElement;
const focusWasInMain = !!(active && mainRef.value?.contains(active));
await nextTick();
const main = mainRef.value;
if (main) {
// Bureau : la zone principale défile seule ; mobile : la page défile, on remonte au début de la section
main.scrollTop = 0;
if (main.getBoundingClientRect().top < 0) main.scrollIntoView({ block: 'start', behavior: 'auto' });
}
if (focusWasInMain || document.activeElement === document.body) {
document.getElementById(ADMIN_SECTION_TITLE_ID)?.focus({ preventScroll: true });
}
revealActiveItem();
updateFades();
});
// ----- Cycle de vie -----
function start() {
adminStore.attach();
adminStore.fetchOverview();
adminStore.fetchPlayers();
}
function observeNav() {
resizeObserver?.disconnect();
resizeObserver = null;
if (!navRef.value || typeof ResizeObserver === 'undefined') return;
resizeObserver = new ResizeObserver(() => updateFades());
resizeObserver.observe(navRef.value);
}
onMounted(() => {
document.title = "Administration - Subsonics";
document.title = isAdmin.value ? "Administration · " + current.value.label + " - Subsonics" : "Administration - Subsonics";
if (isAdmin.value) start();
nextTick(() => {
observeNav();
revealActiveItem(false);
updateFades();
});
});
onUnmounted(() => {
adminStore.detach();
resizeObserver?.disconnect();
resizeObserver = null;
});
// Droit retiré ou accordé en direct (/USER/READY recharge userInfo)
watch(isAdmin, (value) => {
if (value) start();
else adminStore.detach();
if (value) {
start();
nextTick(() => {
observeNav();
revealActiveItem(false);
updateFades();
});
} else {
adminStore.detach();
}
});
</script>
<style scoped>
.admin-container {
padding: 15px;
width: 100vw;
height: 100vh;
box-sizing: border-box;
position: relative;
display: grid;
grid-template-columns: minmax(240px, 0.7fr) 3fr;
gap: 15px;
grid-template-columns: clamp(232px, 19vw, 280px) minmax(0, 1fr);
gap: 16px;
width: 100%;
height: 100vh;
height: 100dvh;
padding: 16px;
box-sizing: border-box;
}
.left-side {
/* Lien d'évitement : invisible jusqu'au premier Tab */
.skip-link {
position: absolute;
top: 12px;
left: 12px;
z-index: 50;
padding: 8px 14px;
border-radius: var(--radius-sm);
background-color: var(--main);
color: var(--main-contrast);
font-weight: 600;
text-decoration: none;
transform: translateY(-200%);
transition: transform var(--dur-fast) ease;
}
.skip-link:focus {
transform: translateY(0);
color: var(--main-contrast);
}
.skip-link:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
/* ----- Colonne de gauche ----- */
.admin-side {
display: flex;
flex-direction: column;
gap: 15px;
height: 100%;
gap: 12px;
min-height: 0;
}
.nav-box {
.side-box {
display: flex;
flex-direction: column;
gap: 10px;
gap: 14px;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px !important;
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.logo :deep(h1) {
margin: 0;
font-size: 20px;
}
.logo :deep(.img) {
width: 70px;
height: 70px;
width: 56px;
height: 56px;
}
.admin-label {
margin: 0;
text-align: center;
color: var(--admin-color);
font-weight: 600;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
gap: 6px;
margin: 0;
padding: 3px 10px;
border-radius: var(--radius-pill);
background-color: color-mix(in srgb, var(--admin-color) 14%, transparent);
color: var(--admin-color);
font-size: 0.75em;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.back-mobile {
display: none;
}
.admin-nav {
position: relative;
display: flex;
flex-direction: column;
gap: 5px;
gap: 16px;
flex: 1;
min-height: 0;
overflow-y: auto;
/* Marge intérieure : l'anneau de focus des boutons n'est pas rogné par le défilement */
padding: 4px;
margin: -4px;
}
.nav-group-label {
margin: 0 0 6px 10px;
font-size: 0.7em;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.nav-list {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.nav-item {
@@ -185,61 +392,103 @@ watch(isAdmin, (value) => {
align-items: center;
gap: 10px;
width: 100%;
padding: 10px;
min-height: 38px;
padding: 0 10px;
border: none;
border-radius: 10px;
border-radius: 8px;
background-color: transparent;
color: var(--text);
font-size: 0.95em;
color: var(--text-secondary);
font: inherit;
font-size: 0.9rem;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
outline: 2px solid transparent;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--dur-fast) ease, color var(--dur-fast) ease;
}
.nav-item:hover {
background-color: var(--tertiary);
background-color: color-mix(in srgb, var(--text) 7%, transparent);
color: var(--text);
}
.nav-item:focus-visible {
box-shadow: var(--focus-ring);
}
.nav-item.active {
background-color: var(--main);
color: #FFFFFF;
color: var(--main-contrast);
box-shadow: var(--shadow-sm);
}
.nav-icon {
width: 18px;
flex-shrink: 0;
}
.nav-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-badge {
margin-left: auto;
font-size: 0.7em;
padding: 2px 7px;
border-radius: 20px;
background-color: var(--quaternary);
color: #FFFFFF;
font-family: monospace;
--badge-color: var(--text-secondary);
flex-shrink: 0;
min-width: 20px;
padding: 1px 7px;
box-sizing: border-box;
border-radius: var(--radius-pill);
border: 1px solid color-mix(in srgb, var(--badge-color) 50%, transparent);
background-color: color-mix(in srgb, var(--badge-color) 16%, transparent);
color: var(--text);
font-size: 0.7rem;
font-weight: 600;
line-height: 1.5;
text-align: center;
font-variant-numeric: tabular-nums;
}
.nav-badge.good {
background-color: color-mix(in srgb, var(--mod-color) 70%, black);
.nav-badge.good { --badge-color: var(--text-success); }
.nav-badge.warn { --badge-color: var(--text-warning); }
.nav-badge.bad { --badge-color: var(--text-error); }
.nav-item.active .nav-badge {
border-color: rgba(255, 255, 255, 0.45);
background-color: rgba(255, 255, 255, 0.2);
color: #fff;
}
.nav-badge.warn {
background-color: var(--text-warning);
/* Problème signalé sur un élément actif : pastille pleine pour rester visible sur le rose */
.nav-item.active .nav-badge.bad,
.nav-item.active .nav-badge.warn {
background-color: #fff;
color: var(--main);
}
.nav-badge.bad {
background-color: var(--text-error);
}
.back {
width: 100%;
}
.main {
/* ----- Zone principale ----- */
.admin-main {
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 15px !important;
padding: 22px 24px;
box-sizing: border-box;
border-radius: var(--radius);
background-color: var(--secondary);
box-shadow: var(--shadow);
}
.admin-main:focus {
outline: none;
}
.admin-main:focus-visible {
box-shadow: var(--shadow), inset 0 0 0 2px var(--main);
}
.denied-title {
@@ -248,42 +497,160 @@ watch(isAdmin, (value) => {
gap: 10px;
}
/* Fenêtre moyenne : un peu moins d'air autour du contenu */
@media screen and (max-width: 1100px) {
.admin-container {
gap: 12px;
padding: 12px;
}
.admin-main {
padding: 18px;
}
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.admin-container {
display: flex;
flex-direction: column;
gap: 0;
height: auto;
min-height: 100vh;
min-height: 100dvh;
padding: 0 0 16px;
}
.left-side {
height: auto;
/* Les blocs de la colonne gauche deviennent des éléments de la page : en-tête, navigation collante, contenu */
.admin-side,
.side-box {
display: contents;
}
.nav-box {
overflow: visible;
.brand {
order: 1;
flex-direction: row;
justify-content: flex-start;
gap: 10px;
padding: 10px 12px;
border-bottom: none;
}
.logo {
width: auto;
}
.logo :deep(.img) {
width: 34px;
height: 34px;
}
.logo :deep(h1) {
font-size: 17px;
}
.back-mobile {
display: inline-flex;
margin-left: auto;
}
.admin-nav {
order: 2;
position: sticky;
top: 0;
z-index: 20;
flex-direction: row;
align-items: center;
gap: 0;
flex: none;
margin: 0;
padding: 8px 12px;
overflow-x: auto;
padding-bottom: 5px;
overflow-y: hidden;
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
scrollbar-width: none;
}
.admin-nav::-webkit-scrollbar {
display: none;
}
.admin-nav.fade-end {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
}
.admin-nav.fade-start {
-webkit-mask-image: linear-gradient(to right, transparent, #000 32px);
mask-image: linear-gradient(to right, transparent, #000 32px);
}
.admin-nav.fade-start.fade-end {
-webkit-mask-image: linear-gradient(to right, transparent, #000 32px, #000 calc(100% - 32px), transparent);
mask-image: linear-gradient(to right, transparent, #000 32px, #000 calc(100% - 32px), transparent);
}
.nav-group {
display: flex;
align-items: center;
flex-shrink: 0;
}
/* Séparateur discret entre les groupes */
.nav-group + .nav-group::before {
content: "";
width: 1px;
height: 20px;
margin: 0 8px;
background-color: var(--border);
}
/* Titre de groupe : gardé pour les lecteurs d'écran seulement */
.nav-group-label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.nav-list {
flex-direction: row;
gap: 6px;
}
.nav-item {
width: auto;
min-height: 40px;
padding: 0 14px;
border-radius: var(--radius-pill);
background-color: var(--secondary);
color: var(--text);
white-space: nowrap;
flex-shrink: 0;
border-radius: 20px;
background-color: var(--tertiary);
}
.nav-text {
overflow: visible;
}
.back,
.account {
display: none;
}
.main {
.admin-main {
order: 3;
margin: 12px 12px 0;
padding: 14px;
overflow: visible;
/* La navigation collante ne recouvre pas le haut de la section après un changement */
scroll-margin-top: 64px;
}
}
</style>
+145
View File
@@ -0,0 +1,145 @@
<template>
<div :class="classes" :role="group ? 'group' : undefined" :aria-labelledby="group && label ? labelId : undefined"
:aria-describedby="group ? describedBy : undefined">
<div class="adm-field__top">
<component :is="isPlain ? 'span' : 'label'" v-if="label" :id="labelId" :for="isPlain ? undefined : controlId" class="adm-field__label">
{{ label }}<span v-if="required" class="adm-field__required" aria-hidden="true"> *</span>
<span v-if="required" class="sr-only"> (obligatoire)</span>
</component>
<span v-if="hasBadges" class="adm-field__badges">
<span v-if="isLocked" :id="lockId" class="adm-badge adm-tone-warning"
:title="env ? 'Défini par la variable d\'environnement ' + env + ' : modifiable seulement sur le serveur' : 'Valeur verrouillée'">
<Icon icon="fa-solid fa-lock" aria-hidden="true"/>
<span v-if="env">Verrouillé par <span class="adm-mono">{{ env }}</span></span>
<span v-else>Verrouillé</span>
</span>
<span v-if="pending" class="adm-badge adm-tone-warning">
<Icon icon="fa-solid fa-hourglass-half" aria-hidden="true"/> Redémarrage en attente
</span>
<span v-else-if="restart" class="adm-badge adm-tone-info">
<Icon icon="fa-solid fa-rotate" aria-hidden="true"/> Redémarrage requis
</span>
<span v-if="dirty" class="adm-badge adm-tone-main">
<Icon icon="fa-solid fa-pen" aria-hidden="true"/> Modifié
</span>
<slot name="badges"></slot>
</span>
<span v-if="$slots.aside" class="adm-field__aside"><slot name="aside"></slot></span>
</div>
<div class="adm-field__control">
<slot :id="controlId" :describedby="describedBy" :invalid="!!error" :disabled="isLocked" :labelledby="label ? labelId : undefined"></slot>
</div>
<p v-if="hint || $slots.hint" :id="hintId" class="adm-field__hint"><slot name="hint">{{ hint }}</slot></p>
<p v-if="error" :id="errorId" class="adm-field__error" role="alert">
<Icon icon="fa-solid fa-circle-exclamation" aria-hidden="true"/>
<span>{{ error }}</span>
</p>
</div>
</template>
<script setup>
/**
* Champ de réglage d'administration : libellé, étiquettes (verrou d'env, redémarrage, modifié), aide, erreur.
* Le contrôle va dans le slot par défaut, qui reçoit { id, describedby, invalid, disabled, labelledby } :
* <AdminField label="URL" env="WARP_PROXY" hint="socks5://…" v-slot="{ id, describedby, invalid, disabled }">
* <input type="url" :id="id" :aria-describedby="describedby" :aria-invalid="invalid" :disabled="disabled" v-model="url"/>
* </AdminField>
* Les champs texte prennent toute la largeur et un fond --secondary (lisibles sur une carte --tertiary).
* `inline` : libellé à gauche, contrôle à droite (Switch, petit select).
* `plain` : libellé en <span> (pas de <label for>) quand le contrôle n'est pas un champ :
* bouton, groupe (SegmentedControl / AdminPills via aria-labelledby = slot `labelledby`).
* `group` : implique `plain` ; la racine devient role="group" nommée par le libellé et décrite par l'aide
* (plusieurs interrupteurs / cases sous un même intitulé).
*/
import { computed, useId, useSlots } from 'vue';
import './admin.scss';
const props = defineProps({
label: {
type: String,
default: null
},
for: {
type: String,
default: null
},
hint: {
type: String,
default: null
},
error: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
// Valeur verrouillée (le slot reçoit disabled=true)
locked: {
type: Boolean,
default: false
},
// Nom de la variable d'environnement qui verrouille la valeur (implique locked)
env: {
type: String,
default: null
},
// Modifier cette valeur demande un redémarrage
restart: {
type: Boolean,
default: false
},
// Valeur modifiée, en attente d'un redémarrage (remplace l'étiquette `restart`)
pending: {
type: Boolean,
default: false
},
// Brouillon différent de la valeur enregistrée
dirty: {
type: Boolean,
default: false
},
inline: {
type: Boolean,
default: false
},
plain: {
type: Boolean,
default: false
},
group: {
type: Boolean,
default: false
}
});
const slots = useSlots();
const uid = useId();
const controlId = computed(() => props.for || `adm-field-${uid}`);
const labelId = computed(() => `${controlId.value}-label`);
const hintId = computed(() => `${controlId.value}-hint`);
const errorId = computed(() => `${controlId.value}-error`);
const lockId = computed(() => `${controlId.value}-lock`);
const isPlain = computed(() => props.plain || props.group);
const isLocked = computed(() => props.locked || !!props.env);
const hasBadges = computed(() => isLocked.value || props.restart || props.pending || props.dirty || !!slots.badges);
// À poser sur aria-describedby du contrôle : aide, erreur et verrou
const describedBy = computed(() => {
const ids = [];
if (props.hint || slots.hint) ids.push(hintId.value);
if (props.error) ids.push(errorId.value);
if (isLocked.value) ids.push(lockId.value);
return ids.length ? ids.join(' ') : undefined;
});
const classes = computed(() => [
'adm-field',
{
'adm-field--inline': props.inline,
'adm-field--error': !!props.error,
'adm-field--locked': isLocked.value
}
]);
</script>
+73
View File
@@ -0,0 +1,73 @@
<template>
<!-- Groupe titré : petit intitulé de catégorie au-dessus de la grille (les cartes passent en h4) -->
<section v-if="title" class="adm-group" :aria-labelledby="titleId">
<header class="adm-group__header">
<h3 :id="titleId" class="adm-group__title">
<Icon v-if="icon" class="adm-group__icon" :icon="icon" aria-hidden="true"/>
<span>{{ title }}</span>
</h3>
<p v-if="subtitle" class="adm-group__subtitle">{{ subtitle }}</p>
<div v-if="$slots.actions" class="adm-group__actions">
<slot name="actions"></slot>
</div>
</header>
<component :is="as" :class="gridClasses">
<slot></slot>
</component>
</section>
<component v-else :is="as" :class="gridClasses">
<slot></slot>
</component>
</template>
<script setup>
/**
* Grille 12 colonnes des sections d'administration.
* Les enfants choisissent leur largeur : AdminCard (prop `span`) ou une classe `adm-span-N`
* (+ `adm-span-md-N` / `adm-span-sm-N` pour les conteneurs moyens / petits). Sans classe : pleine largeur.
* Les largeurs suivent la taille de la grille elle-même (requête de conteneur), pas celle de la fenêtre.
* `title` (+ `subtitle`, `icon`, slot `actions`) en fait un groupe thématique : « Services », « Ressources »…
*/
import { computed, inject, provide, unref, useId } from 'vue';
import { ADMIN_HEADING_KEY } from './adminUi';
import './admin.scss';
const props = defineProps({
// Balise de la grille (div, ul, section…)
as: {
type: String,
default: 'div'
},
// 'md' = 14 px (12 sur mobile) ; 'sm' = 10 px (grilles imbriquées dans une carte)
gap: {
type: String,
default: 'md',
validator: (value) => ['sm', 'md'].includes(value)
},
// 'stretch' : cartes d'une même ligne à hauteur égale ; 'start' : hauteur naturelle
align: {
type: String,
default: 'stretch',
validator: (value) => ['stretch', 'start'].includes(value)
},
title: {
type: String,
default: null
},
subtitle: {
type: String,
default: null
},
icon: {
type: String,
default: null
}
});
const titleId = `adm-group-${useId()}`;
const gridClasses = computed(() => ['adm-grid', { 'adm-grid--sm': props.gap === 'sm', 'adm-grid--start': props.align === 'start' }]);
// Un groupe titré (h3) fait descendre les titres de ses cartes d'un niveau ; sinon on transmet celui du parent
const parentLevel = inject(ADMIN_HEADING_KEY, null);
provide(ADMIN_HEADING_KEY, computed(() => (props.title ? 4 : (unref(parentLevel) || 3))));
</script>
+6 -32
View File
@@ -1,11 +1,13 @@
<template>
<div class="kv">
<span class="kv-label">{{ label }}</span>
<span :class="{ 'kv-value': true, mono }"><slot>{{ value }}</slot></span>
<div class="adm-kv">
<span class="adm-kv__label">{{ label }}</span>
<span :class="['adm-kv__value', { 'adm-kv__value--mono': mono }]"><slot>{{ value }}</slot></span>
</div>
</template>
<script setup>
// Ligne « libellé : valeur » des cartes d'administration
// Ligne « libellé : valeur » des cartes d'administration (la valeur peut être remplacée par le slot)
import './admin.scss';
defineProps({
label: {
type: String,
@@ -20,31 +22,3 @@ defineProps({
}
});
</script>
<style scoped>
.kv {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
font-size: 0.9em;
}
.kv-label {
color: var(--text-secondary);
white-space: nowrap;
}
.kv-value {
text-align: right;
word-break: break-word;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 5px;
flex-wrap: wrap;
}
.mono {
font-family: monospace;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
<template>
<component :is="as" :class="['adm-list', { 'adm-list--divided': divided }]" :aria-label="label || undefined">
<slot></slot>
</component>
</template>
<script setup>
/**
* Conteneur de lignes AdminRow.
* `divided` : lignes transparentes séparées par des filets (à placer dans une AdminCard `flush`) ;
* sinon lignes en pavés espacés de 6 px.
* Avec as="ul"/"ol" (défaut ul), les AdminRow enfants deviennent automatiquement des <li>.
*/
import { provide } from 'vue';
import { ADMIN_LIST_KEY } from './adminUi';
import './admin.scss';
const props = defineProps({
as: {
type: String,
default: 'ul'
},
divided: {
type: Boolean,
default: false
},
// Nom accessible de la liste (facultatif)
label: {
type: String,
default: null
}
});
provide(ADMIN_LIST_KEY, { itemTag: () => (props.as === 'ul' || props.as === 'ol' ? 'li' : 'div') });
</script>
+305 -297
View File
@@ -1,97 +1,127 @@
<template>
<AdminSection title="📜 Logs" subtitle="Journaux du backend, lus de la fin vers le début">
<template #actions>
<Button icon="fa-solid fa-rotate" :disabled="filesLoading" @click="loadFiles()">Actualiser</Button>
<Button variant="secondary" icon="fa-solid fa-rotate" :loading="filesLoading" @click="loadFiles()">Actualiser</Button>
</template>
<div class="logs-layout">
<Box level="second" padding="closed" no-shadow class="files">
<p class="files-title"><Icon icon="fa-solid fa-folder-open"/> Fichiers</p>
<AdminToolbar v-model:search="searchFilter" search-label="Rechercher dans les messages" search-placeholder="Rechercher dans les messages…"
label="Filtres du journal">
<AdminPills v-model="levels" multiple label="Niveaux affichés" :options="levelOptions"/>
<div class="logs-type">
<label class="sr-only" :for="typeInputId">Module (nom exact)</label>
<Icon class="logs-type__icon" icon="fa-solid fa-cube" aria-hidden="true"/>
<input :id="typeInputId" type="text" v-model="typeFilter" maxlength="64" spellcheck="false" autocomplete="off"
placeholder="Module exact (ex : Server)" title="Nom exact du module, sans tenir compte des majuscules"/>
</div>
<template #end>
<Button v-if="isCurrent" :variant="live ? 'danger' : 'secondary'" :aria-pressed="live ? 'true' : 'false'"
:icon="live ? 'fa-solid fa-circle-stop' : 'fa-solid fa-tower-broadcast'" @click="toggleLive()">
{{ live ? "Arrêter le direct" : "Temps réel" }}
</Button>
<Button variant="secondary" :disabled="!selected" :loading="downloading" icon="fa-solid fa-download" @click="download()">
{{ downloading ? "Téléchargement…" : "Télécharger" }}
</Button>
</template>
</AdminToolbar>
<AdminGrid>
<!-- Fichiers -->
<AdminCard title="Fichiers" :subtitle="files.length ? files.length + ' fichier(s)' : null" icon="fa-solid fa-folder-open"
:span="3" :span-md="4" :span-sm="12" :flush="files.length > 0" body-class="logs-files">
<AdminState :loading="filesLoading && !files.length" :error="!files.length ? filesError : null"
:empty="files.length === 0" empty-text="Aucun fichier de logs" @retry="loadFiles()">
<div class="files-list">
<div v-for="file in files" :key="file.name" :class="['file', { selected: selected === file.name }]" @click="select(file.name)">
<div class="file-info">
<p class="file-name">{{ file.name }}</p>
<p class="file-meta">{{ formatBytes(file.size) }} · {{ formatRelative(file.modifiedAt) }}</p>
</div>
<Tag v-if="file.current" color="var(--text-success)">Actuel</Tag>
<IconAction v-else title="Supprimer ce fichier" icon="fa-solid fa-trash-can" color="var(--text-tertiary)" size="1em" @click.stop="removeFile(file)"/>
</div>
</div>
<AdminList divided label="Fichiers de logs">
<AdminRow v-for="file in files" :key="file.name" as="li" dense :selected="selected === file.name" class="logs-file">
<template #title>
<button type="button" class="logs-file__select" :aria-current="selected === file.name ? 'true' : undefined"
@click="select(file.name)">{{ file.name }}</button>
</template>
<template #subtitle>{{ formatBytes(file.size) }} · {{ formatRelative(file.modifiedAt, nowDate) }}</template>
<template #actions>
<Tag v-if="file.current" tone="success" icon="fa-solid fa-pen">Actuel</Tag>
<IconAction v-else :label="'Supprimer le fichier ' + file.name" icon="fa-solid fa-trash-can"
color="var(--text-tertiary)" size="0.95em" @click="removeFile(file)"/>
</template>
</AdminRow>
</AdminList>
</AdminState>
</Box>
</AdminCard>
<div class="viewer">
<div class="toolbar">
<AdminPills v-model="levels" multiple :options="levelOptions"/>
<div class="toolbar-inputs">
<input type="text" v-model="typeFilter" placeholder="Type exact (ex : Server)" title="Nom exact du module, sans tenir compte des majuscules"/>
<input type="text" v-model="searchFilter" placeholder="Rechercher dans les messages"/>
</div>
<div class="toolbar-actions">
<Button v-if="isCurrent" :icon="live ? 'fa-solid fa-circle-stop' : 'fa-solid fa-tower-broadcast'" @click="toggleLive()">
{{ live ? "Arrêter le direct" : "Temps réel" }}
</Button>
<Button :disabled="!selected || downloading" icon="fa-solid fa-download" @click="download()">
{{ downloading ? "Téléchargement…" : "Télécharger" }}
</Button>
</div>
</div>
<!-- Lecteur -->
<AdminCard :title="selected || 'Contenu'" :subtitle="viewerSubtitle" icon="fa-solid fa-terminal" tone="main"
:span="9" :span-md="8" :span-sm="12" body-class="logs-viewer">
<template #actions>
<span v-if="live" class="adm-badge adm-tone-success logs-live">
<Icon class="logs-live__dot" icon="fa-solid fa-circle" aria-hidden="true"/> En direct
</span>
</template>
<Error v-if="interrupted" class="banner">
Journal interrompu : la dernière ligne est « END OF LOGS ». Plus rien n'est écrit dans ce fichier.
</Error>
<AdminBanner v-if="interrupted" tone="error" title="Journal interrompu">
La dernière ligne est « END OF LOGS » : plus rien n'est écrit dans ce fichier.
</AdminBanner>
<AdminState :loading="linesLoading && !lines.length" :error="!lines.length ? linesError : null"
:empty="!selected" empty-text="Sélectionne un fichier pour lire son contenu" @retry="reload()">
<div class="console-wrapper">
<div ref="consoleRef" class="console" @scroll="onScroll">
<div v-if="startOffset > 0" class="older">
<Button :disabled="olderLoading" color-lower icon="fa-solid fa-angles-up" @click="loadOlder()">
<div class="logs-console-wrap">
<div ref="consoleRef" class="logs-console" role="region" tabindex="0"
:aria-label="'Contenu du journal ' + (selected || '')" @scroll="onScroll">
<div v-if="startOffset > 0" class="logs-older">
<Button size="sm" variant="secondary" :loading="olderLoading" icon="fa-solid fa-angles-up" @click="loadOlder()">
{{ olderLoading ? "Chargement…" : "Charger les lignes plus anciennes" }}
</Button>
</div>
<p v-else class="console-start">— Début du fichier —</p>
<p v-if="!visibleLines.length" class="console-empty">Aucune ligne ne correspond aux filtres</p>
<div v-for="line in visibleLines" :key="line.key" :class="['line', levelClass(line.level)]">
<p v-else class="logs-console__note">— Début du fichier —</p>
<p v-if="!visibleLines.length" class="logs-console__note">Aucune ligne ne correspond aux filtres</p>
<div v-for="line in visibleLines" :key="line.key" :class="['logs-line', levelClass(line.level)]">
<template v-if="line.level || line.time">
<span class="line-time">{{ line.time }}</span>
<span class="line-level">{{ line.level }}</span>
<span v-if="line.type" class="line-type">[{{ line.type }}]</span>
<span v-if="line.step" class="line-step">STEP</span>
<span class="line-message">{{ line.message }}</span>
<span class="logs-line__time">{{ line.time }}</span>
<span class="logs-line__level">{{ line.level }}</span>
<span v-if="line.type" class="logs-line__type">[{{ line.type }}]</span>
<span v-if="line.step" class="logs-line__step">STEP</span>
<span class="logs-line__message">{{ line.message }}</span>
</template>
<span v-else class="line-raw">{{ line.raw }}</span>
<span v-else class="logs-line__raw">{{ line.raw }}</span>
</div>
</div>
<button v-if="!follow" class="follow" @click="scrollToBottom(true)">
<Icon icon="fa-solid fa-arrow-down"/> Suivre
<button v-if="!follow" type="button" class="logs-follow" @click="scrollToBottom(true)">
<Icon icon="fa-solid fa-arrow-down" aria-hidden="true"/> Suivre la fin
</button>
</div>
<p class="status">
<span>{{ visibleLines.length }} ligne(s) affichée(s)<span v-if="truncated"> (les plus anciennes ont été retirées)</span></span>
<span v-if="live" class="live"><Icon icon="fa-solid fa-circle" class="live-dot"/> En direct</span>
</p>
</AdminState>
</div>
</div>
<template v-if="selected" #footer>
<span class="adm-footer-meta">
{{ visibleLines.length }} ligne(s) affichée(s)<template v-if="lines.length !== visibleLines.length"> sur {{ lines.length }}</template>
<template v-if="truncated"> · les plus anciennes ont été retirées</template>
</span>
<span class="sr-only" aria-live="polite">{{ live ? "Lecture en direct activée" : "" }}</span>
</template>
</AdminCard>
</AdminGrid>
<AdminConfirm ref="confirmRef"/>
</AdminSection>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
/**
* Section « Logs » : liste (/ADMIN/LOGS/LIST), lecture par blocs depuis la fin (/ADMIN/LOGS/READ),
* direct (/ADMIN/LOGS/SUBSCRIBE + poussée /ADMIN/LOGS/APPEND), suppression et téléchargement.
*/
import { computed, nextTick, onMounted, onUnmounted, ref, useId, watch } from 'vue';
import { socket } from '@/socket';
import { useAdminStore } from '@/stores/adminStore';
import { adminRequest, adminListen, describeError } from '@/utils/AdminRequest';
import Box from '@/components/UI/Box.vue';
import Button from '@/components/UI/Button.vue';
import Tag from '@/components/UI/Tag.vue';
import Error from '@/components/UI/Error.vue';
import IconAction from '@/components/UI/IconAction.vue';
import AdminSection from './AdminSection.vue';
import AdminState from './AdminState.vue';
import AdminGrid from './AdminGrid.vue';
import AdminCard from './AdminCard.vue';
import AdminToolbar from './AdminToolbar.vue';
import AdminList from './AdminList.vue';
import AdminRow from './AdminRow.vue';
import AdminBanner from './AdminBanner.vue';
import AdminPills from './AdminPills.vue';
import AdminConfirm from './AdminConfirm.vue';
import { formatBytes, formatRelative } from './adminFormat';
@@ -103,6 +133,7 @@ const ALL_LEVELS = ["INFO", "WARN", "ERROR", "FATAL"];
const adminStore = useAdminStore();
const confirmRef = ref(null);
const consoleRef = ref(null);
const typeInputId = `logs-type-${useId()}`;
// ----- Fichiers -----
const files = ref([]);
@@ -110,7 +141,13 @@ const filesLoading = ref(false);
const filesError = ref(null);
const selected = ref(null);
const isCurrent = computed(() => !!files.value.find(f => f.name === selected.value)?.current);
const selectedFile = computed(() => files.value.find(f => f.name === selected.value) || null);
const isCurrent = computed(() => !!selectedFile.value?.current);
const viewerSubtitle = computed(() => {
if (!selectedFile.value) return null;
return (selectedFile.value.current ? "Fichier courant · " : "") + formatBytes(selectedFile.value.size);
});
async function loadFiles() {
if (filesLoading.value) return;
@@ -125,6 +162,7 @@ async function loadFiles() {
}
} catch (e) {
filesError.value = describeError(e);
if (files.value.length) adminStore.notifyError(e, "Liste des logs");
} finally {
filesLoading.value = false;
}
@@ -142,7 +180,11 @@ async function removeFile(file) {
try {
await adminRequest("/ADMIN/LOGS/DELETE", { name: file.name });
adminStore.notify('success', `Fichier ${file.name} supprimé`);
if (selected.value === file.name) selected.value = null;
if (selected.value === file.name) {
stopLive();
selected.value = null;
lines.value = [];
}
await loadFiles();
} catch (e) {
adminStore.notifyError(e, "Suppression du fichier");
@@ -204,7 +246,7 @@ const interrupted = computed(() => {
});
function levelClass(level) {
return level ? "level-" + level.toLowerCase() : "level-none";
return level ? "logs-line--" + String(level).toLowerCase() : "logs-line--none";
}
function trim() {
@@ -406,291 +448,257 @@ function onAppend(payload) {
}
let offAppend = null;
const nowDate = ref(Date.now());
let clock = null;
onMounted(() => {
offAppend = adminListen("/ADMIN/LOGS/APPEND", onAppend);
loadFiles();
clock = setInterval(() => { nowDate.value = Date.now(); }, 30000);
});
onUnmounted(() => {
offAppend?.();
clearTimeout(filterTimer);
clearInterval(clock);
stopLive();
readToken++;
});
</script>
<style scoped>
.logs-layout {
display: grid;
grid-template-columns: minmax(220px, 0.8fr) 3fr;
gap: 10px;
min-height: 0;
}
p {
margin: 0;
}
.files {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.files-title {
font-weight: 600;
/* Champ « module » de la barre d'outils */
.logs-type {
position: relative;
display: flex;
align-items: center;
gap: 5px;
flex: 0 1 220px;
min-width: 160px;
}
.files-list {
display: flex;
flex-direction: column;
gap: 5px;
max-height: 65vh;
.logs-type__icon {
position: absolute;
left: 11px;
color: var(--text-secondary);
font-size: 0.85em;
pointer-events: none;
}
.logs-type input {
width: 100%;
min-height: 36px;
box-sizing: border-box;
padding-left: 32px;
}
/* Liste des fichiers : défile seule, la sélection est un vrai bouton étiré sur toute la ligne */
:deep(.logs-files) {
max-height: 62vh;
overflow-y: auto;
}
.file {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px;
border-radius: 5px;
background-color: var(--secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.file:hover {
filter: brightness(1.1);
}
.file.selected {
background-color: var(--main);
color: #FFFFFF;
}
.file.selected .file-meta {
color: #FFFFFF;
opacity: 0.8;
}
.file-info {
min-width: 0;
}
.file-name {
font-family: monospace;
font-size: 0.8em;
word-break: break-all;
}
.file-meta {
font-size: 0.7em;
color: var(--text-secondary);
}
.viewer {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.toolbar-inputs {
display: flex;
gap: 10px;
flex: 1;
min-width: 0;
}
.toolbar-inputs input {
flex: 1;
min-width: 0;
}
.toolbar-actions {
display: flex;
gap: 10px;
}
.banner {
background-color: var(--tertiary);
border-radius: 10px;
padding: 10px;
}
.console-wrapper {
.logs-file {
position: relative;
}
.console {
background-color: var(--primary);
border-radius: 10px;
padding: 10px;
height: 60vh;
overflow: auto;
font-family: monospace;
font-size: 0.8em;
line-height: 1.5;
}
.older {
display: flex;
justify-content: center;
margin-bottom: 10px;
}
.console-start,
.console-empty {
text-align: center;
color: var(--text-secondary);
margin-bottom: 10px;
}
.line {
display: flex;
gap: 8px;
white-space: pre-wrap;
word-break: break-word;
padding: 1px 4px;
border-radius: 3px;
}
.line:hover {
background-color: var(--secondary);
}
.line-time {
color: var(--text-secondary);
flex-shrink: 0;
}
.line-level {
font-weight: bold;
flex-shrink: 0;
min-width: 45px;
}
.line-type {
color: var(--text-secondary);
flex-shrink: 0;
}
.line-step {
color: var(--mod-color);
flex-shrink: 0;
}
.line-message {
flex: 1;
min-width: 0;
}
.line-raw {
color: var(--text-secondary);
padding-left: 20px;
}
.level-info .line-level {
color: var(--admin-color);
}
.level-warn .line-level,
.level-warn .line-message {
color: var(--text-warning);
}
.level-error .line-level,
.level-error .line-message {
color: var(--text-error);
}
.level-fatal {
background-color: var(--text-error);
color: #FFFFFF;
}
.level-fatal .line-time,
.level-fatal .line-type {
color: #FFFFFF;
}
.follow {
position: absolute;
bottom: 15px;
right: 20px;
display: flex;
align-items: center;
gap: 5px;
background-color: var(--main);
color: #FFFFFF;
.logs-file__select {
padding: 0;
border: none;
border-radius: 20px;
padding: 5px 12px;
background: none;
color: inherit;
font: inherit;
font-family: var(--font-mono);
font-size: 0.85em;
text-align: left;
cursor: pointer;
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5);
outline: none;
overflow-wrap: anywhere;
white-space: normal;
}
.follow:hover {
background-color: var(--main-hover);
.logs-file__select::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
}
.status {
display: flex;
justify-content: space-between;
font-size: 0.8em;
color: var(--text-secondary);
.logs-file__select:focus-visible::after {
box-shadow: inset 0 0 0 2px var(--main);
}
.live {
display: flex;
align-items: center;
gap: 5px;
color: var(--text-success);
/* Les actions (supprimer) restent cliquables au-dessus du bouton étiré */
.logs-file :deep(.adm-row__actions) {
position: relative;
z-index: 1;
}
.live-dot {
font-size: 0.7em;
animation: blink 1.2s ease-in-out infinite;
.logs-file :deep(.adm-row__title) {
white-space: normal;
}
@keyframes blink {
/* Lecteur */
:deep(.logs-viewer) {
gap: 10px;
}
.logs-live {
font-size: 0.75em;
}
.logs-live__dot {
font-size: 0.65em;
animation: logs-blink 1.2s ease-in-out infinite;
}
@keyframes logs-blink {
50% {
opacity: 0.2;
}
}
.logs-console-wrap {
position: relative;
min-width: 0;
}
.logs-console {
height: 60vh;
overflow: auto;
box-sizing: border-box;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background-color: var(--primary);
font-family: var(--font-mono);
font-size: 0.8em;
line-height: 1.55;
outline: 2px solid transparent;
}
.logs-console:focus-visible {
box-shadow: var(--focus-ring);
}
.logs-older {
display: flex;
justify-content: center;
margin-bottom: 10px;
font-family: var(--font);
}
.logs-console__note {
margin: 0 0 10px;
text-align: center;
color: var(--text-secondary);
}
.logs-line {
display: flex;
gap: 8px;
padding: 1px 4px;
border-radius: 3px;
white-space: pre-wrap;
word-break: break-word;
}
.logs-line:hover {
background-color: var(--secondary);
}
.logs-line__time,
.logs-line__type {
flex-shrink: 0;
color: var(--text-secondary);
}
.logs-line__level {
flex-shrink: 0;
min-width: 45px;
font-weight: 700;
}
.logs-line__step {
flex-shrink: 0;
color: var(--mod-color);
}
.logs-line__message {
flex: 1;
min-width: 0;
}
.logs-line__raw {
padding-left: 20px;
color: var(--text-secondary);
}
.logs-line--info .logs-line__level {
color: var(--admin-color);
}
.logs-line--warn .logs-line__level,
.logs-line--warn .logs-line__message {
color: var(--text-warning);
}
.logs-line--error .logs-line__level,
.logs-line--error .logs-line__message {
color: var(--text-error);
}
.logs-line--fatal,
.logs-line--fatal:hover {
background-color: var(--text-error);
color: var(--main-contrast);
}
.logs-line--fatal .logs-line__time,
.logs-line--fatal .logs-line__type {
color: var(--main-contrast);
}
.logs-follow {
position: absolute;
right: 18px;
bottom: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: none;
border-radius: var(--radius-pill);
background-color: var(--main);
color: var(--main-contrast);
font: inherit;
font-size: 0.85em;
cursor: pointer;
box-shadow: var(--shadow);
outline: 2px solid transparent;
transition: background-color var(--dur-fast) ease;
}
.logs-follow:hover {
background-color: var(--main-hover);
}
.logs-follow:focus-visible {
box-shadow: var(--focus-ring);
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.logs-layout {
grid-template-columns: 1fr;
:deep(.logs-files) {
max-height: 30vh;
}
.files-list {
max-height: 25vh;
.logs-type {
flex: 1 1 100%;
}
.toolbar-inputs {
flex-direction: column;
width: 100%;
}
.console {
.logs-console {
height: 55vh;
}
.line {
.logs-line {
flex-wrap: wrap;
gap: 4px;
}
+130 -93
View File
@@ -1,63 +1,118 @@
<template>
<AdminSection title="🛠️ Maintenance" subtitle="Annonces globales et redémarrage de Subsonics">
<div class="cards">
<AdminCard title="Annonce globale" icon="fa-solid fa-bullhorn" color="var(--admin-color)">
<p class="hint">Le message s'affiche immédiatement sur l'interface de tous les utilisateurs connectés.</p>
<AdminPills v-model="level" :options="levels"/>
<textarea v-model="message" maxlength="500" placeholder="Ex : Une mise à jour arrive ce soir à 22h, le bot sera indisponible quelques minutes."></textarea>
<div class="footer">
<span class="hint">{{ message.trim().length }} / 500</span>
<Button icon="fa-solid fa-paper-plane" :disabled="!canAnnounce || announcing" @click="announce()">
<AdminSection title="🛠️ Maintenance" subtitle="Annonces globales, redémarrage et état des fichiers de données">
<AdminStatRow v-if="databases" :columns="4" label="Fichiers de données">
<AdminStat label="Bases de données" :value="databases.length" icon="fa-solid fa-database" tone="admin"/>
<AdminStat label="Entrées au total" :value="totalEntries" icon="fa-solid fa-list" tone="main"/>
<AdminStat label="Taille totale" :value="formatBytes(totalSize)" icon="fa-solid fa-hard-drive" tone="info" mono/>
<AdminStat label="Dernière écriture" :value="lastWrite ? formatRelative(lastWrite, nowDate) : 'Jamais'"
icon="fa-solid fa-clock-rotate-left" :sub="lastWriteName"/>
</AdminStatRow>
<AdminGrid>
<!-- Annonce -->
<AdminCard title="Annonce globale" subtitle="Affichée immédiatement chez tous les utilisateurs connectés"
icon="fa-solid fa-bullhorn" tone="admin" :span="6" :span-sm="12">
<AdminField plain label="Niveau" v-slot="{ labelledby }">
<AdminPills v-model="level" :options="levels" :aria-labelledby="labelledby"/>
</AdminField>
<AdminField label="Message" required hint="Texte brut, 500 caractères maximum."
v-slot="{ id, describedby }">
<textarea :id="id" v-model="message" class="maint-textarea" maxlength="500" :aria-describedby="describedby"
placeholder="Ex : Une mise à jour arrive ce soir à 22h, le bot sera indisponible quelques minutes."></textarea>
</AdminField>
<div v-if="message.trim()" class="maint-preview">
<p class="adm-hint">Aperçu</p>
<AdminBanner :tone="level === 'warn' ? 'warning' : 'info'" :title="level === 'warn' ? 'Avertissement' : 'Information'">
<p class="maint-preview__text">{{ message.trim() }}</p>
</AdminBanner>
</div>
<template #footer>
<span class="adm-footer-meta" aria-live="polite">{{ message.trim().length }} / 500</span>
<Button icon="fa-solid fa-paper-plane" :loading="announcing" :disabled="!canAnnounce" @click="announce()">
{{ announcing ? "Envoi…" : "Diffuser" }}
</Button>
</div>
</template>
</AdminCard>
<AdminCard title="Redémarrage" icon="fa-solid fa-power-off" color="var(--text-error)">
<p class="hint">
<!-- Redémarrage -->
<AdminCard title="Redémarrage" subtitle="Arrête la musique partout puis relance le processus"
icon="fa-solid fa-power-off" tone="error" :span="6" :span-sm="12">
<p class="adm-text adm-muted">
Les utilisateurs sont prévenus, tous les players quittent leur salon puis le processus redémarre.
L'interface se reconnecte automatiquement dès que Subsonics est de retour.
</p>
<input type="text" v-model="reason" maxlength="200" placeholder="Raison du redémarrage (3 caractères minimum)"/>
<div class="footer">
<span class="hint">{{ reason.trim().length }} / 200</span>
<Button icon="fa-solid fa-power-off" :disabled="!canRestart || restarting" @click="restart()">
<AdminField label="Raison" required :error="reasonError"
hint="3 à 200 caractères, enregistrée dans le journal d'audit."
v-slot="{ id, describedby, invalid }">
<input :id="id" type="text" v-model="reason" maxlength="200" autocomplete="off"
:aria-describedby="describedby" :aria-invalid="invalid ? 'true' : undefined"
placeholder="Ex : mise à jour de yt-dlp" @keyup.enter="restart()"/>
</AdminField>
<AdminBanner v-if="restartAt" tone="success" title="Redémarrage demandé">
Demandé {{ formatRelative(restartAt, nowDate) }}, reconnexion en attente…
</AdminBanner>
<template #footer>
<span class="adm-footer-meta">{{ reason.trim().length }} / 200</span>
<Button variant="danger" icon="fa-solid fa-power-off" :loading="restarting" :disabled="!canRestart" @click="restart()">
{{ restarting ? "Redémarrage…" : "Redémarrer Subsonics" }}
</Button>
</div>
<Success v-if="restartAt">Redémarrage demandé {{ formatRelative(restartAt, nowDate) }}, reconnexion en attente…</Success>
</template>
</AdminCard>
<AdminCard title="Bases de données" icon="fa-solid fa-database" color="var(--owner-color)" class="wide">
<template #header>
<IconAction title="Actualiser" icon="fa-solid fa-rotate" size="1em" @click="loadDatabases()"/>
<!-- Bases de données -->
<AdminCard title="Bases de données" subtitle="Fichiers JSON du dossier data/ (lecture seule)"
icon="fa-solid fa-database" tone="warning" :span="12" :flush="!!databases?.length"
:busy="dbLoading && !!databases" busy-label="Actualisation des bases…">
<template #actions>
<IconAction label="Actualiser les bases de données" icon="fa-solid fa-rotate" size="1em"
:disabled="dbLoading" @click="loadDatabases()"/>
</template>
<AdminState :loading="!databases && !dbError" :error="!databases ? dbError : null"
:empty="!!databases && databases.length === 0" empty-text="Aucune base de données" @retry="loadDatabases()">
<div class="db-list">
<div v-for="db in databases" :key="db.name" class="db">
<p class="db-name"><Icon icon="fa-solid fa-file-code"/> {{ db.name }}</p>
<span class="db-meta">{{ safeNum(db.entries) }} entrée(s)</span>
<span class="db-meta mono">{{ formatBytes(db.sizeBytes) }}</span>
<span class="db-meta">{{ formatRelative(db.modifiedAt, nowDate) }}</span>
</div>
</div>
<AdminList divided label="Bases de données">
<AdminRow v-for="db in sortedDatabases" :key="db.name" as="li"
:title="db.name" :subtitle="db.modifiedAt ? 'Modifiée ' + formatRelative(db.modifiedAt, nowDate) : 'Jamais écrite'">
<template #media>
<span class="maint-db-icon" aria-hidden="true"><Icon icon="fa-solid fa-file-code"/></span>
</template>
<template #meta>
<span class="maint-db-meta">{{ safeNum(db.entries) }} entrée(s)</span>
<span class="adm-chip" :title="'Taille : ' + formatBytes(db.sizeBytes)">{{ formatBytes(db.sizeBytes) }}</span>
</template>
</AdminRow>
</AdminList>
</AdminState>
</AdminCard>
</div>
</AdminGrid>
<AdminConfirm ref="confirmRef"/>
</AdminSection>
</template>
<script setup>
/**
* Section « Maintenance » : annonce globale (/ADMIN/MAINTENANCE/ANNOUNCE {message, level}),
* redémarrage (/ADMIN/MAINTENANCE/RESTART {reason}) et liste des bases (/ADMIN/DATABASES).
*/
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useAdminStore } from '@/stores/adminStore';
import { adminRequest, describeError } from '@/utils/AdminRequest';
import Button from '@/components/UI/Button.vue';
import Success from '@/components/UI/Success.vue';
import IconAction from '@/components/UI/IconAction.vue';
import AdminSection from './AdminSection.vue';
import AdminGrid from './AdminGrid.vue';
import AdminCard from './AdminCard.vue';
import AdminStatRow from './AdminStatRow.vue';
import AdminStat from './AdminStat.vue';
import AdminField from './AdminField.vue';
import AdminBanner from './AdminBanner.vue';
import AdminList from './AdminList.vue';
import AdminRow from './AdminRow.vue';
import AdminPills from './AdminPills.vue';
import AdminState from './AdminState.vue';
import AdminConfirm from './AdminConfirm.vue';
@@ -81,7 +136,7 @@ const canAnnounce = computed(() => {
});
async function announce() {
if (!canAnnounce.value) return;
if (!canAnnounce.value || announcing.value) return;
announcing.value = true;
try {
await adminRequest("/ADMIN/MAINTENANCE/ANNOUNCE", { message: message.value.trim(), level: level.value });
@@ -104,8 +159,14 @@ const canRestart = computed(() => {
return length >= 3 && length <= 200;
});
// Erreur affichée seulement une fois la saisie commencée (pas sur un champ vide)
const reasonError = computed(() => {
const length = reason.value.trim().length;
return length > 0 && length < 3 ? "La raison doit contenir au moins 3 caractères" : null;
});
async function restart() {
if (!canRestart.value) return;
if (!canRestart.value || restarting.value) return;
const ok = await confirmRef.value.ask({
title: "Redémarrer Subsonics",
icon: "fa-solid fa-power-off",
@@ -133,6 +194,22 @@ const databases = ref(null);
const dbLoading = ref(false);
const dbError = ref(null);
const sortedDatabases = computed(() => [...(databases.value || [])].sort((a, b) => String(a.name).localeCompare(String(b.name))));
const totalEntries = computed(() => (databases.value || []).reduce((acc, db) => acc + safeNum(db.entries), 0));
const totalSize = computed(() => (databases.value || []).reduce((acc, db) => acc + safeNum(db.sizeBytes), 0));
// Base modifiée le plus récemment
const lastWritten = computed(() => {
let best = null;
for (const db of databases.value || []) {
const time = Date.parse(db.modifiedAt);
if (Number.isFinite(time) && (!best || time > best.time)) best = { time, name: db.name };
}
return best;
});
const lastWrite = computed(() => lastWritten.value?.time || null);
const lastWriteName = computed(() => lastWritten.value?.name || null);
async function loadDatabases() {
if (dbLoading.value) return;
dbLoading.value = true;
@@ -142,6 +219,7 @@ async function loadDatabases() {
dbError.value = null;
} catch (e) {
dbError.value = describeError(e);
if (databases.value) adminStore.notifyError(e, "Bases de données");
} finally {
dbLoading.value = false;
}
@@ -158,75 +236,34 @@ onMounted(() => {
onUnmounted(() => clearInterval(timer));
</script>
<style scoped>
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 10px;
align-items: start;
.maint-textarea {
min-height: 96px;
}
.wide {
grid-column: 1 / -1;
}
.hint {
margin: 0;
font-size: 0.85em;
color: var(--text-secondary);
}
textarea {
min-height: 10vh;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.db-list {
.maint-preview {
display: flex;
flex-direction: column;
gap: 5px;
gap: 6px;
}
.db {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
align-items: center;
gap: 10px;
background-color: var(--secondary);
border-radius: 5px;
padding: 7px 10px;
font-size: 0.9em;
}
.db-name {
.maint-preview__text {
margin: 0;
font-weight: 600;
display: flex;
white-space: pre-line;
}
.maint-db-icon {
display: inline-flex;
align-items: center;
gap: 5px;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
background-color: color-mix(in srgb, var(--owner-color) 15%, transparent);
color: var(--owner-color);
}
.db-meta {
color: var(--text-secondary);
text-align: right;
}
.mono {
font-family: monospace;
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.cards {
grid-template-columns: 1fr;
}
.db {
grid-template-columns: 1fr 1fr;
}
.maint-db-meta {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
</style>
+67
View File
@@ -0,0 +1,67 @@
<template>
<span :class="['adm-meter', `adm-tone-${toneName}`]">
<span v-if="showValue" class="adm-meter__top" aria-hidden="true">
<span>{{ label }}</span>
<span class="adm-meter__value">{{ valueText || percentText }}</span>
</span>
<span class="adm-meter__track" role="meter" aria-valuemin="0" aria-valuemax="100"
:aria-valuenow="percent" :aria-valuetext="valueText || percentText" :aria-label="label">
<span class="adm-meter__fill" :style="{ width: percent + '%' }"></span>
</span>
</span>
</template>
<script setup>
/**
* Jauge horizontale (mémoire, quota…) : `value` entre 0 et 1.
* Ton 'auto' : rose, orange au-delà de 75 %, rouge au-delà de 90 % (occupation).
* Ton 'auto-inverse' : vert, orange sous 80 %, rouge sous 50 % (taux de réussite).
*/
import { computed } from 'vue';
import { normalizeTone } from './adminUi';
import './admin.scss';
const props = defineProps({
value: {
type: Number,
default: 0
},
// Nom accessible (et libellé visible si showValue)
label: {
type: String,
required: true
},
// Texte remplaçant le pourcentage ("1,2 Go / 4 Go")
valueText: {
type: String,
default: null
},
tone: {
type: String,
default: 'auto'
},
showValue: {
type: Boolean,
default: false
}
});
const ratio = computed(() => {
const n = Number(props.value);
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
});
const percent = computed(() => Math.round(ratio.value * 100));
const percentText = computed(() => percent.value + " %");
const toneName = computed(() => {
if (props.tone === 'auto-inverse') {
if (ratio.value < 0.5) return 'error';
if (ratio.value < 0.8) return 'warning';
return 'success';
}
if (props.tone !== 'auto') return normalizeTone(props.tone, 'main');
if (ratio.value >= 0.9) return 'error';
if (ratio.value >= 0.75) return 'warning';
return 'main';
});
</script>
+174 -137
View File
@@ -1,88 +1,141 @@
<template>
<AdminSection title="📈 Métriques" subtitle="Statistiques d'utilisation enregistrées par Subsonics">
<template #actions>
<Button v-if="invalidCount" icon="fa-solid fa-wrench" :disabled="repairing" @click="repair()">
Réparer {{ invalidCount }} valeur(s) invalide(s)
</Button>
<Button icon="fa-solid fa-rotate" :disabled="loading" @click="load()">Actualiser</Button>
<Button variant="secondary" icon="fa-solid fa-rotate" :loading="loading" @click="load()">Actualiser</Button>
</template>
<AdminState :loading="loading && !data" :error="!data ? error : null" :empty="!data" empty-text="Aucune métrique" @retry="load()">
<div class="stats">
<AdminStat label="Commandes" :value="safeNum(data.global?.commands)" icon="fa-solid fa-terminal" color="var(--main)"/>
<AdminStat label="Titres joués" :value="safeNum(data.global?.musicPlayed)" icon="fa-solid fa-music" color="var(--admin-color)"/>
<AdminStat label="Temps d'écoute" :value="formatUptime(data.global?.secondsPlayed)" icon="fa-solid fa-clock" color="var(--owner-color)"/>
<AdminBanner v-if="invalidCount" tone="warning" :title="invalidCount + ' valeur(s) invalide(s)'">
Des compteurs contiennent une valeur non numérique (fichier modifié à la main ou écriture interrompue).
La réparation les remet à zéro.
<template #action>
<Button size="sm" icon="fa-solid fa-wrench" :loading="repairing" @click="repair()">Réparer</Button>
</template>
</AdminBanner>
<AdminStatRow :columns="4" label="Totaux">
<AdminStat label="Commandes" :value="formatCount(data.global?.commands)" icon="fa-solid fa-terminal" tone="main"/>
<AdminStat label="Titres joués" :value="formatCount(data.global?.musicPlayed)" icon="fa-solid fa-music" tone="admin"
:sub="byGuild.length + ' serveur(s) suivi(s)'"/>
<AdminStat label="Temps d'écoute" :value="formatUptime(data.global?.secondsPlayed)" icon="fa-solid fa-clock" tone="info"/>
<AdminStat label="Valeurs invalides" :value="invalidCount" icon="fa-solid fa-triangle-exclamation"
:color="invalidCount ? 'var(--text-error)' : 'var(--text-success)'"/>
</div>
:tone="invalidCount ? 'error' : 'success'" :sub="safeNum(data.raw?.length) + ' compteur(s) au total'"/>
</AdminStatRow>
<Glider v-model="tab">
<span>Par serveur</span>
<span>Par utilisateur</span>
<span>Brutes</span>
</Glider>
<AdminGrid>
<!-- Par serveur -->
<AdminCard title="Par serveur" subtitle="Classés par nombre de commandes" icon="fa-solid fa-server" tone="warning"
:span="7" :span-md="12" :flush="byGuild.length > 0">
<p v-if="!byGuild.length" class="adm-hint">Aucune statistique par serveur.</p>
<AdminList v-else divided label="Statistiques par serveur">
<AdminRow v-for="(guild, index) in shownGuilds" :key="guild.guildId" as="li"
:title="guild.guildName || 'Serveur inconnu (quitté ?)'" :subtitle="guild.guildId">
<template #media>
<span class="met-rank" aria-hidden="true">{{ index + 1 }}</span>
</template>
<AdminMeter class="met-share" :value="ratio(guild.commands, maxGuildCommands)" tone="main"
:label="'Part des commandes de ' + (guild.guildName || guild.guildId)"/>
<template #meta>
<span class="met-value" title="Commandes"><Icon icon="fa-solid fa-terminal" aria-hidden="true"/><span class="sr-only">Commandes :</span> {{ formatCount(guild.commands) }}</span>
<span class="met-value" title="Titres joués"><Icon icon="fa-solid fa-music" aria-hidden="true"/><span class="sr-only">Titres joués :</span> {{ formatCount(guild.musicPlayed) }}</span>
<span class="met-value" title="Temps d'écoute"><Icon icon="fa-solid fa-clock" aria-hidden="true"/><span class="sr-only">Temps d'écoute :</span> {{ formatUptime(guild.secondsPlayed) }}</span>
</template>
</AdminRow>
</AdminList>
<template v-if="byGuild.length > LIMIT" #footer>
<span class="adm-footer-meta">{{ shownGuilds.length }} / {{ byGuild.length }} serveur(s)</span>
<Button size="sm" variant="ghost" :icon="allGuilds ? 'fa-solid fa-chevron-up' : 'fa-solid fa-chevron-down'"
:aria-expanded="allGuilds ? 'true' : 'false'" @click="allGuilds = !allGuilds">
{{ allGuilds ? "Réduire" : "Tout afficher" }}
</Button>
</template>
</AdminCard>
<div v-if="tab === 0" class="list">
<Info v-if="!byGuild.length" secondary>Aucune statistique par serveur</Info>
<div v-for="guild in byGuild" :key="guild.guildId" class="metric">
<div class="metric-info">
<p class="metric-name">{{ guild.guildName || "Serveur inconnu (quitté ?)" }}</p>
<p class="metric-id">{{ guild.guildId }}</p>
</div>
<div class="metric-values">
<span title="Commandes"><Icon icon="fa-solid fa-terminal"/> {{ safeNum(guild.commands) }}</span>
<span title="Titres joués"><Icon icon="fa-solid fa-music"/> {{ safeNum(guild.musicPlayed) }}</span>
<span title="Temps d'écoute"><Icon icon="fa-solid fa-clock"/> {{ formatUptime(guild.secondsPlayed) }}</span>
</div>
</div>
</div>
<!-- Par utilisateur -->
<AdminCard title="Par utilisateur" subtitle="Commandes envoyées" icon="fa-solid fa-user" tone="admin"
:span="5" :span-md="12" :flush="byUser.length > 0">
<p v-if="!byUser.length" class="adm-hint">Aucune statistique par utilisateur.</p>
<AdminList v-else divided label="Commandes par utilisateur">
<AdminRow v-for="(user, index) in shownUsers" :key="user.key" as="li" dense>
<template #media>
<span class="met-rank" aria-hidden="true">{{ index + 1 }}</span>
</template>
<template #title><span class="adm-mono">{{ user.key }}</span></template>
<template #meta>
<span class="met-value"><Icon icon="fa-solid fa-terminal" aria-hidden="true"/><span class="sr-only">Commandes :</span> {{ formatCount(user.commands) }}</span>
</template>
</AdminRow>
</AdminList>
<template v-if="byUser.length > LIMIT" #footer>
<span class="adm-footer-meta">{{ shownUsers.length }} / {{ byUser.length }} utilisateur(s)</span>
<Button size="sm" variant="ghost" :icon="allUsers ? 'fa-solid fa-chevron-up' : 'fa-solid fa-chevron-down'"
:aria-expanded="allUsers ? 'true' : 'false'" @click="allUsers = !allUsers">
{{ allUsers ? "Réduire" : "Tout afficher" }}
</Button>
</template>
</AdminCard>
<div v-else-if="tab === 1" class="list">
<Info v-if="!byUser.length" secondary>Aucune statistique par utilisateur</Info>
<div v-for="user in byUser" :key="user.key" class="metric">
<div class="metric-info">
<p class="metric-name">{{ user.key }}</p>
</div>
<p class="metric-value"><Icon icon="fa-solid fa-terminal"/> {{ safeNum(user.commands) }}</p>
</div>
</div>
<template v-else>
<input type="text" v-model="rawSearch" placeholder="Filtrer les métriques brutes"/>
<div class="list">
<Info v-if="!raw.length" secondary>Aucune métrique brute</Info>
<div v-for="metric in raw" :key="metric.name" :class="['metric', { invalid: metric.invalid }]">
<Icon class="metric-icon" :icon="iconOf(metric.name)"/>
<div class="metric-info">
<p class="metric-name">{{ metric.description || metric.name }}</p>
<p class="metric-id">{{ metric.name }}</p>
</div>
<Tag v-if="metric.invalid" color="var(--text-error)" icon="fa-solid fa-triangle-exclamation">Invalide</Tag>
<p class="metric-value">{{ metric.name.includes('Seconds') ? formatUptime(metric.value) : safeNum(metric.value) }}</p>
<IconAction title="Supprimer cette métrique" icon="fa-solid fa-trash-can" color="var(--text-tertiary)" size="1em" @click="removeMetric(metric)"/>
</div>
</div>
</template>
<!-- Valeurs brutes -->
<AdminCard title="Compteurs bruts" subtitle="Toutes les valeurs de metrics.json, invalides en premier"
icon="fa-solid fa-table-list" :span="12" collapsible persist-key="metrics-raw" :default-open="invalidCount > 0">
<AdminToolbar v-model:search="rawSearch" search-label="Filtrer les compteurs" search-placeholder="Filtrer par nom ou description…">
<AdminPills v-model="rawFilter" label="Afficher" :options="rawFilterOptions"/>
</AdminToolbar>
<p v-if="!raw.length" class="adm-hint" role="status">
{{ data.raw?.length ? "Aucun compteur ne correspond au filtre." : "Aucune métrique brute." }}
</p>
<AdminList v-else label="Compteurs bruts">
<AdminRow v-for="metric in raw" :key="metric.name" as="li" dense :tone="metric.invalid ? 'error' : 'default'"
:title="metric.description || metric.name" :subtitle="metric.description && metric.description !== metric.name ? metric.name : null">
<template #media>
<span class="met-icon" aria-hidden="true"><Icon :icon="iconOf(metric.name)"/></span>
</template>
<template #meta>
<Tag v-if="metric.invalid" tone="error" icon="fa-solid fa-triangle-exclamation">Invalide</Tag>
<span class="adm-chip">{{ String(metric.name).includes('Seconds') ? formatUptime(metric.value) : formatCount(metric.value) }}</span>
</template>
<template #actions>
<IconAction :label="'Supprimer la métrique ' + metric.name" icon="fa-solid fa-trash-can"
color="var(--text-tertiary)" size="1em" @click="removeMetric(metric)"/>
</template>
</AdminRow>
</AdminList>
</AdminCard>
</AdminGrid>
</AdminState>
<AdminConfirm ref="confirmRef"/>
</AdminSection>
</template>
<script setup>
/**
* Section « Métriques » : /ADMIN/METRICS ({ global, byGuild, byUser, raw }),
* /ADMIN/METRICS/REPAIR et /ADMIN/METRICS/DELETE {name}.
*/
import { computed, onMounted, ref } from 'vue';
import { useAdminStore } from '@/stores/adminStore';
import { adminRequest, describeError } from '@/utils/AdminRequest';
import Button from '@/components/UI/Button.vue';
import Glider from '@/components/UI/Glider.vue';
import Tag from '@/components/UI/Tag.vue';
import Info from '@/components/UI/Info.vue';
import IconAction from '@/components/UI/IconAction.vue';
import AdminSection from './AdminSection.vue';
import AdminGrid from './AdminGrid.vue';
import AdminCard from './AdminCard.vue';
import AdminState from './AdminState.vue';
import AdminStatRow from './AdminStatRow.vue';
import AdminStat from './AdminStat.vue';
import AdminMeter from './AdminMeter.vue';
import AdminBanner from './AdminBanner.vue';
import AdminToolbar from './AdminToolbar.vue';
import AdminList from './AdminList.vue';
import AdminRow from './AdminRow.vue';
import AdminPills from './AdminPills.vue';
import AdminConfirm from './AdminConfirm.vue';
import { safeNum, formatUptime } from './adminFormat';
// Lignes affichées par défaut dans les classements
const LIMIT = 10;
const adminStore = useAdminStore();
const confirmRef = ref(null);
@@ -90,25 +143,46 @@ const data = ref(null);
const loading = ref(false);
const error = ref(null);
const repairing = ref(false);
const tab = ref(0);
const rawSearch = ref("");
const rawFilter = ref("all");
const allGuilds = ref(false);
const allUsers = ref(false);
const byGuild = computed(() => [...(data.value?.byGuild || [])].sort((a, b) => safeNum(b.commands) - safeNum(a.commands)));
const byUser = computed(() => [...(data.value?.byUser || [])].sort((a, b) => safeNum(b.commands) - safeNum(a.commands)));
const shownGuilds = computed(() => (allGuilds.value ? byGuild.value : byGuild.value.slice(0, LIMIT)));
const shownUsers = computed(() => (allUsers.value ? byUser.value : byUser.value.slice(0, LIMIT)));
const maxGuildCommands = computed(() => Math.max(1, ...byGuild.value.map(g => safeNum(g.commands))));
const invalidCount = computed(() => (data.value?.raw || []).filter(m => m.invalid).length);
const rawFilterOptions = computed(() => [
{ value: "all", label: "Tous", count: safeNum(data.value?.raw?.length) },
{ value: "invalid", label: "Invalides", icon: "fa-solid fa-triangle-exclamation", color: "var(--text-error)", count: invalidCount.value }
]);
const raw = computed(() => {
const query = rawSearch.value.trim().toLowerCase();
// Les valeurs invalides en premier, pour les repérer immédiatement
const list = [...(data.value?.raw || [])].sort((a, b) => Number(!!b.invalid) - Number(!!a.invalid) || a.name.localeCompare(b.name));
let list = [...(data.value?.raw || [])].sort((a, b) => Number(!!b.invalid) - Number(!!a.invalid) || String(a.name).localeCompare(String(b.name)));
if (rawFilter.value === "invalid") list = list.filter(m => m.invalid);
if (!query) return list;
return list.filter(m => m.name.toLowerCase().includes(query) || (m.description || "").toLowerCase().includes(query));
return list.filter(m => String(m.name).toLowerCase().includes(query) || String(m.description || "").toLowerCase().includes(query));
});
const numberFormat = new Intl.NumberFormat("fr-FR");
function formatCount(value) {
return numberFormat.format(safeNum(value));
}
function ratio(value, max) {
return max > 0 ? safeNum(value) / max : 0;
}
function iconOf(name) {
if (name.includes("Commands") || name.includes("Command")) return "fa-solid fa-terminal";
if (name.includes("Music")) return "fa-solid fa-music";
if (name.includes("Seconds")) return "fa-solid fa-clock";
const text = String(name);
if (text.includes("Command")) return "fa-solid fa-terminal";
if (text.includes("Music")) return "fa-solid fa-music";
if (text.includes("Seconds")) return "fa-solid fa-clock";
return "fa-solid fa-chart-bar";
}
@@ -127,6 +201,7 @@ async function load() {
}
async function repair() {
if (repairing.value) return;
repairing.value = true;
try {
const result = await adminRequest("/ADMIN/METRICS/REPAIR");
@@ -160,88 +235,50 @@ async function removeMetric(metric) {
onMounted(() => load());
</script>
<style scoped>
.stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
p {
margin: 0;
}
.metric {
display: flex;
justify-content: space-between;
.met-rank {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px;
background-color: var(--tertiary);
border-radius: 10px;
}
.metric.invalid {
box-shadow: inset 3px 0 0 var(--text-error);
}
.metric-icon {
background-color: var(--secondary);
padding: 15px;
border-radius: 100%;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.metric-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.metric-name {
font-weight: bold;
color: var(--text);
}
.metric-id {
justify-content: center;
min-width: 26px;
height: 26px;
padding: 0 4px;
box-sizing: border-box;
border-radius: var(--radius-pill);
background-color: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--text-secondary);
font-size: 0.7em;
word-break: break-all;
font-size: 0.8em;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.metric-value {
background-color: var(--secondary);
padding: 5px;
border-radius: 5px;
font-family: monospace;
.met-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
background-color: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--text-secondary);
font-size: 0.85em;
}
.met-value {
display: inline-flex;
align-items: center;
gap: 5px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.metric-values {
display: flex;
gap: 15px;
font-family: monospace;
font-size: 0.9em;
flex-wrap: wrap;
justify-content: flex-end;
.met-value > svg {
color: var(--text-tertiary);
font-size: 0.85em;
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.metric {
flex-wrap: wrap;
}
.metric-values {
width: 100%;
justify-content: space-between;
}
/* Jauge de part sous le nom du serveur */
.met-share {
max-width: 260px;
margin-top: 2px;
}
</style>
+211 -190
View File
@@ -1,167 +1,200 @@
<template>
<AdminSection title="📊 Vue d'ensemble" subtitle="État général de Subsonics, rafraîchi automatiquement toutes les 10 secondes">
<AdminSection title="Vue d'ensemble" subtitle="État général de Subsonics, rafraîchi automatiquement toutes les 10 secondes">
<template #actions>
<Button icon="fa-solid fa-rotate" :disabled="adminStore.overviewLoading" @click="adminStore.fetchOverview()">Actualiser</Button>
<Button variant="secondary" icon="fa-solid fa-rotate" :loading="adminStore.overviewLoading" @click="adminStore.fetchOverview()">Actualiser</Button>
</template>
<AdminState :loading="!data && adminStore.overviewLoading" :error="!data ? adminStore.overviewError : null"
:empty="!data" empty-text="Aucune donnée reçue pour le moment" @retry="adminStore.fetchOverview()">
<Error v-if="data.logs?.interrupted" class="banner">
Le journal « {{ data.logs.current }} » est interrompu : plus rien n'est écrit dans le fichier de logs depuis la dernière erreur fatale.
</Error>
<!-- Alertes : ce qui demande une action, avec un raccourci vers la section concernée -->
<AdminBanner v-for="alert in alerts" :key="alert.id" :tone="alert.tone" :title="alert.title">
{{ alert.text }}
<template v-if="alert.to" #action>
<Button size="sm" variant="secondary" icon-right="fa-solid fa-arrow-right" @click="adminStore.setSection(alert.to)">{{ alert.action }}</Button>
</template>
</AdminBanner>
<div class="stats">
<AdminStat label="Disponibilité" :value="formatUptime(uptime)" icon="fa-solid fa-clock" color="var(--text-success)"
<AdminStatRow :columns="4" label="Chiffres clés">
<AdminStat label="Disponibilité" :value="formatUptime(uptime)" icon="fa-solid fa-clock" tone="success"
:sub="'Depuis le ' + formatDate(data.app?.startedAt)"/>
<AdminStat label="Players connectés" :value="safeNum(data.players?.connected)" icon="fa-solid fa-music" color="var(--main)"
<AdminStat label="Players connectés" :value="safeNum(data.players?.connected)" icon="fa-solid fa-music" tone="main" to="players"
:sub="safeNum(data.players?.playing) + ' en lecture · ' + safeNum(data.players?.paused) + ' en pause'"/>
<AdminStat label="Utilisateurs en ligne" :value="safeNum(data.web?.usersOnline)" icon="fa-solid fa-users" color="var(--admin-color)"
:sub="safeNum(data.web?.sockets) + ' socket(s) ouvert(s)'"/>
<AdminStat label="Serveurs" :value="safeNum(data.discord?.guildCount)" icon="fa-solid fa-server" color="var(--owner-color)"
<AdminStat label="Utilisateurs en ligne" :value="safeNum(data.web?.usersOnline)" icon="fa-solid fa-users" tone="admin" to="users"
:sub="safeNum(data.web?.sockets) + ' connexion(s) web · ' + safeNum(data.users?.registered) + ' inscrit(s)'"/>
<AdminStat label="Serveurs" :value="safeNum(data.discord?.guildCount)" icon="fa-solid fa-server" tone="warning" to="servers"
:sub="safeNum(data.discord?.voiceConnections) + ' connexion(s) vocale(s)'"/>
</div>
</AdminStatRow>
<div class="cards">
<AdminCard title="Application" icon="fa-solid fa-cube" color="var(--main)">
<AdminKv label="Nom" :value="data.app?.name"/>
<!-- Services externes : un coup d'œil sur ce qui fait tourner la musique -->
<AdminGrid title="Services" icon="fa-solid fa-plug">
<AdminCard title="Discord" icon="fa-brands fa-discord" color="#5865F2" :span="4" :span-md="6" :span-sm="12"
:subtitle="data.discord?.ready ? 'Passerelle connectée' : 'Passerelle indisponible'">
<template #actions>
<Tag :tone="data.discord?.ready ? 'success' : 'error'" :icon="data.discord?.ready ? 'fa-solid fa-circle-check' : 'fa-solid fa-circle-xmark'">
{{ data.discord?.ready ? "Prêt" : "Non prêt" }}
</Tag>
</template>
<div v-if="data.discord?.tag" class="bot">
<img v-if="data.discord?.avatar" class="adm-avatar" :src="data.discord.avatar" alt="" referrerpolicy="no-referrer"/>
<span class="bot__info">
<span class="bot__tag">{{ data.discord.tag }}</span>
<span class="adm-hint adm-mono">{{ data.discord.id }}</span>
</span>
</div>
<AdminKv label="Latence" mono :value="safeNum(data.discord?.ping) + ' ms'"/>
<AdminKv label="Connecté" :value="formatRelative(data.discord?.readyAt, nowDate)"/>
<AdminKv label="Activité" :value="data.discord?.activity || 'Aucune'"/>
</AdminCard>
<AdminCard title="WARP" icon="fa-solid fa-cloud" tone="warning" :span="4" :span-md="6" :span-sm="12"
subtitle="Proxy Cloudflare des lectures YouTube">
<template #actions>
<Tag :tone="warpState.tone" :icon="warpState.icon">{{ warpState.label }}</Tag>
</template>
<AdminKv label="Mode" mono :value="data.warp?.warp || 'N/A'"/>
<AdminKv label="IP de sortie" mono :value="data.warp?.ip || 'Inconnue'"/>
<AdminKv label="Dernière vérification" :value="formatRelative(data.warp?.lastCheck, nowDate)"/>
<template #footer>
<Button size="sm" variant="ghost" icon-right="fa-solid fa-arrow-right" @click="adminStore.setSection('warp')">Gérer WARP</Button>
</template>
</AdminCard>
<AdminCard title="YouTube / yt-dlp" icon="fa-brands fa-youtube" tone="error" :span="4" :span-md="12" :span-sm="12"
:subtitle="data.ytdlp?.version ? 'yt-dlp ' + data.ytdlp.version : 'Version inconnue'">
<template #actions>
<Tag v-if="data.ytdlp?.updating" tone="info" icon="fa-solid fa-spinner">Mise à jour…</Tag>
<Tag v-else :color="ytAccount.color" :icon="ytAccount.icon">{{ ytAccount.label }}</Tag>
</template>
<AdminMeter v-if="ytSuccess !== null" :value="ytSuccess" show-value tone="auto-inverse"
label="Lectures récentes réussies" :value-text="Math.round(ytSuccess * 100) + ' % sur ' + safeNum(data.ytdlp?.recentCount)"/>
<AdminKv v-if="data.ytdlp?.cookies?.present" label="Cookies importés" :value="formatRelative(data.ytdlp?.cookies?.importedAt ?? data.ytdlp?.cookies?.updatedAt, nowDate)"/>
<AdminKv label="Processus actifs" mono :value="safeNum(data.ytdlp?.activeProcesses)"/>
<AdminKv label="Dernier échec">
<template v-if="data.ytdlp?.lastFailure">
<span>{{ youtubeClass(data.ytdlp.lastFailure.cls) }}</span>
<span class="adm-muted">· {{ formatRelative(data.ytdlp.lastFailure.at, nowDate) }}</span>
</template>
<span v-else class="adm-muted">Aucun</span>
</AdminKv>
<template #footer>
<Button size="sm" variant="ghost" icon-right="fa-solid fa-arrow-right" @click="adminStore.setSection('youtube')">
{{ ytAccount.key === 'valid' ? "Gérer YouTube" : "Connecter un compte YouTube" }}
</Button>
</template>
</AdminCard>
</AdminGrid>
<!-- Ressources du processus et de la machine -->
<AdminGrid title="Ressources" icon="fa-solid fa-microchip">
<AdminCard title="Mémoire du processus" icon="fa-solid fa-memory" tone="info" :span="4" :span-md="6" :span-sm="12">
<!-- Le tas V8 est souvent presque plein (il grandit à la demande) : pas de couleur d'alerte -->
<AdminMeter :value="ratio(data.memory?.heapUsed, data.memory?.heapTotal)" show-value tone="info" label="Tas JavaScript"
:value-text="formatBytes(data.memory?.heapUsed) + ' / ' + formatBytes(data.memory?.heapTotal)"/>
<AdminKv label="RSS" mono :value="formatBytes(data.memory?.rss)"/>
<AdminKv label="Externe" mono :value="formatBytes(data.memory?.external)"/>
</AdminCard>
<AdminCard title="Système" icon="fa-solid fa-microchip" tone="warning" :span="4" :span-md="6" :span-sm="12"
:subtitle="data.system?.hostname || null">
<AdminMeter :value="ratio(usedMem, data.system?.totalmem)" show-value label="Mémoire système"
:value-text="formatBytes(usedMem) + ' / ' + formatBytes(data.system?.totalmem)"/>
<!-- os.loadavg() vaut toujours 0 sous Windows -->
<AdminMeter v-if="data.app?.platform !== 'win32'" :value="loadRatio" show-value label="Charge (1 min)"
:value-text="loadText"/>
<AdminKv label="Charge 1 / 5 / 15 min" mono :value="(data.system?.loadavg || []).map(v => safeNum(v).toFixed(2)).join(' · ') || 'N/A'"/>
</AdminCard>
<AdminCard title="Application" icon="fa-solid fa-cube" tone="main" :span="4" :span-md="12" :span-sm="12"
:subtitle="data.app?.name || null">
<AdminKv label="Version" mono :value="data.app?.version"/>
<AdminKv label="Node.js" mono :value="data.app?.node"/>
<AdminKv label="Plateforme" :value="data.app?.platform"/>
<AdminKv label="PID" mono :value="data.app?.pid"/>
<AdminKv label="Gestionnaire">
<Tag :color="data.app?.pm2 ? 'var(--text-success)' : 'var(--text-secondary)'">{{ data.app?.pm2 ? "pm2" : "Docker / node" }}</Tag>
<Tag :tone="data.app?.pm2 ? 'success' : 'default'">{{ data.app?.pm2 ? "pm2" : "Docker / node" }}</Tag>
</AdminKv>
</AdminCard>
</AdminGrid>
<AdminCard title="Mémoire" icon="fa-solid fa-memory" color="var(--admin-color)">
<AdminKv label="RSS" mono :value="formatBytes(data.memory?.rss)"/>
<AdminKv label="Tas utilisé" mono :value="formatBytes(data.memory?.heapUsed) + ' / ' + formatBytes(data.memory?.heapTotal)"/>
<div class="gauge"><div class="gauge-fill" :style="{ width: percent(data.memory?.heapUsed, data.memory?.heapTotal) + '%' }"></div></div>
<AdminKv label="Externe" mono :value="formatBytes(data.memory?.external)"/>
</AdminCard>
<AdminCard title="Système" icon="fa-solid fa-microchip" color="var(--text-warning)">
<AdminKv label="Hôte" mono :value="data.system?.hostname"/>
<AdminKv label="Processeurs" :value="safeNum(data.system?.cpus) + ' cœur(s)'"/>
<AdminKv label="Charge (1/5/15 min)" mono :value="(data.system?.loadavg || []).map(v => safeNum(v).toFixed(2)).join(' · ') || 'N/A'"/>
<AdminKv label="Mémoire système" mono :value="formatBytes(usedMem) + ' / ' + formatBytes(data.system?.totalmem)"/>
<div class="gauge"><div class="gauge-fill" :style="{ width: percent(usedMem, data.system?.totalmem) + '%' }"></div></div>
</AdminCard>
<AdminCard title="Discord" icon="fa-brands fa-discord" color="#5865F2">
<template #header>
<Tag :color="data.discord?.ready ? 'var(--text-success)' : 'var(--text-error)'">{{ data.discord?.ready ? "Prêt" : "Non prêt" }}</Tag>
</template>
<div v-if="data.discord?.tag" class="bot-line">
<img v-if="data.discord?.avatar" class="bot-avatar" :src="data.discord.avatar" alt="Avatar du bot"/>
<div>
<p class="bot-tag">{{ data.discord.tag }}</p>
<p class="bot-id">{{ data.discord.id }}</p>
</div>
</div>
<AdminKv label="Latence" mono :value="safeNum(data.discord?.ping) + ' ms'"/>
<AdminKv label="Connecté depuis" :value="formatDate(data.discord?.readyAt)"/>
<AdminKv label="Activité" :value="data.discord?.activity || 'Aucune'"/>
</AdminCard>
<AdminCard title="Web" icon="fa-solid fa-globe" color="var(--admin-color)">
<AdminKv label="Sockets ouverts" mono :value="safeNum(data.web?.sockets)"/>
<AdminKv label="Utilisateurs en ligne" mono :value="safeNum(data.web?.usersOnline)"/>
<AdminKv label="Administrateurs en ligne" mono :value="safeNum(data.web?.adminsOnline)"/>
<AdminKv label="Sessions OAuth en attente" mono :value="safeNum(data.web?.pendingSessions)"/>
</AdminCard>
<AdminCard title="Players" icon="fa-solid fa-music" color="var(--main)">
<AdminKv label="Connectés" mono :value="safeNum(data.players?.connected)"/>
<AdminKv label="En lecture" mono :value="safeNum(data.players?.playing)"/>
<AdminKv label="En pause" mono :value="safeNum(data.players?.paused)"/>
<AdminKv label="Inactifs" mono :value="safeNum(data.players?.idle)"/>
</AdminCard>
<AdminCard title="Comptes" icon="fa-solid fa-id-card" color="var(--mod-color)">
<!-- Activité et données -->
<AdminGrid title="Activité et données" icon="fa-solid fa-chart-simple">
<AdminCard title="Comptes" icon="fa-solid fa-id-card" tone="admin" :span="4" :span-md="6" :span-sm="12">
<AdminKv label="Inscrits" mono :value="safeNum(data.users?.registered)"/>
<AdminKv label="Administrateurs" mono :value="safeNum(data.users?.admins)"/>
<AdminKv label="Administrateurs en ligne" mono :value="safeNum(data.web?.adminsOnline)"/>
<AdminKv label="Bannis" mono :value="safeNum(data.users?.fullBanned)"/>
<AdminKv label="Supprimés" mono :value="safeNum(data.users?.deleted)"/>
</AdminCard>
<AdminCard title="WARP" icon="fa-solid fa-cloud" color="var(--owner-color)">
<template #header>
<Tag v-if="!data.warp?.configured" color="var(--text-secondary)">Non configuré</Tag>
<Tag v-else :color="data.warp?.healthy ? 'var(--text-success)' : 'var(--text-error)'">{{ data.warp?.healthy ? "Opérationnel" : "Injoignable" }}</Tag>
<AdminKv label="Sessions OAuth en attente" mono :value="safeNum(data.web?.pendingSessions)"/>
<template #footer>
<Button size="sm" variant="ghost" icon-right="fa-solid fa-arrow-right" @click="adminStore.setSection('users')">Voir les comptes</Button>
</template>
<AdminKv label="Mode" mono :value="data.warp?.warp || 'N/A'"/>
<AdminKv label="IP de sortie" mono :value="data.warp?.ip || 'Inconnue'"/>
<AdminKv label="Dernière vérification" :value="formatRelative(data.warp?.lastCheck, nowDate)"/>
</AdminCard>
<AdminCard title="YouTube / yt-dlp" icon="fa-brands fa-youtube" color="var(--text-error)">
<template #header>
<Tag v-if="data.ytdlp?.updating" color="var(--admin-color)" icon="fa-solid fa-spinner">Mise à jour…</Tag>
</template>
<AdminKv label="Version" mono :value="data.ytdlp?.version || 'Inconnue'"/>
<AdminKv label="Compte YouTube">
<Tag :color="ytAccount.color" :icon="ytAccount.icon">{{ ytAccount.label }}</Tag>
</AdminKv>
<AdminKv v-if="data.ytdlp?.cookies?.present" label="Cookies importés" :value="formatRelative(data.ytdlp?.cookies?.importedAt ?? data.ytdlp?.cookies?.updatedAt, nowDate)"/>
<AdminKv label="Processus actifs" mono :value="safeNum(data.ytdlp?.activeProcesses)"/>
<AdminKv v-if="data.ytdlp?.lastFailure" label="Dernier échec">
<span>{{ youtubeClass(data.ytdlp.lastFailure.cls) }}</span>
<span class="db-date">· {{ formatRelative(data.ytdlp.lastFailure.at, nowDate) }}</span>
</AdminKv>
<p v-if="data.ytdlp?.cookies?.invalidReason" class="yt-warning">{{ data.ytdlp.cookies.invalidReason }}</p>
<Button class="small-btn" icon="fa-brands fa-youtube" @click="adminStore.setSection('youtube')">
{{ ytAccount.key === 'valid' ? "Gérer YouTube" : "Connecter un compte YouTube" }}
</Button>
</AdminCard>
<AdminCard title="Médias" icon="fa-solid fa-photo-film" color="var(--text-secondary)">
<AdminKv label="Serveur de médias">
<Tag :color="data.media?.configured ? 'var(--text-success)' : 'var(--text-secondary)'">{{ data.media?.configured ? "Configuré" : "Non configuré" }}</Tag>
</AdminKv>
<AdminKv label="Connexion">
<Tag :color="data.media?.connected ? 'var(--text-success)' : 'var(--text-error)'">{{ data.media?.connected ? "Connecté" : "Déconnecté" }}</Tag>
</AdminKv>
</AdminCard>
<AdminCard title="Statistiques" icon="fa-solid fa-chart-simple" color="var(--main)">
<AdminCard title="Statistiques" icon="fa-solid fa-chart-simple" tone="main" :span="4" :span-md="6" :span-sm="12"
subtitle="Depuis la création des métriques">
<AdminKv label="Commandes" mono :value="safeNum(data.stats?.commands)"/>
<AdminKv label="Titres joués" mono :value="safeNum(data.stats?.musicPlayed)"/>
<AdminKv label="Temps d'écoute" mono :value="formatUptime(data.stats?.secondsPlayed)"/>
</AdminCard>
<AdminCard title="Logs" icon="fa-solid fa-scroll" color="var(--text-secondary)">
<template #header>
<Tag :color="data.logs?.interrupted ? 'var(--text-error)' : 'var(--text-success)'">{{ data.logs?.interrupted ? "Interrompu" : "Actif" }}</Tag>
<template #footer>
<Button size="sm" variant="ghost" icon-right="fa-solid fa-arrow-right" @click="adminStore.setSection('metrics')">Métriques</Button>
</template>
<AdminKv label="Fichier courant" mono :value="data.logs?.current || 'Inconnu'"/>
<Button class="small-btn" icon="fa-solid fa-scroll" @click="adminStore.setSection('logs')">Ouvrir les logs</Button>
</AdminCard>
<AdminCard title="Bases de données" icon="fa-solid fa-database" color="var(--admin-color)" class="wide">
<Info v-if="!data.databases?.length" secondary>Aucune base de données</Info>
<AdminKv v-for="db in data.databases" :key="db.name" :label="db.name">
<span class="mono">{{ formatBytes(db.sizeBytes) }}</span>
<span class="db-date">· {{ formatRelative(db.modifiedAt, nowDate) }}</span>
<AdminCard title="Journal et médias" icon="fa-solid fa-scroll" :span="4" :span-md="12" :span-sm="12">
<AdminKv label="Journal">
<Tag :tone="data.logs?.interrupted ? 'error' : 'success'">{{ data.logs?.interrupted ? "Interrompu" : "Actif" }}</Tag>
</AdminKv>
<AdminKv label="Fichier courant" mono :value="data.logs?.current || 'Inconnu'"/>
<AdminKv label="Serveur de médias">
<Tag :tone="data.media?.configured ? 'success' : 'default'">{{ data.media?.configured ? "Configuré" : "Non configuré" }}</Tag>
</AdminKv>
<AdminKv v-if="data.media?.configured" label="Connexion">
<Tag :tone="data.media?.connected ? 'success' : 'error'">{{ data.media?.connected ? "Connecté" : "Déconnecté" }}</Tag>
</AdminKv>
<template #footer>
<Button size="sm" variant="ghost" icon-right="fa-solid fa-arrow-right" @click="adminStore.setSection('logs')">Ouvrir les logs</Button>
</template>
</AdminCard>
</div>
<AdminCard title="Bases de données" icon="fa-solid fa-database" tone="info" :span="12" flush
:subtitle="databases.length + ' fichier(s) · ' + formatBytes(databasesSize)">
<AdminState :empty="!databases.length" :retry="false" empty-text="Aucune base de données" empty-icon="fa-solid fa-database">
<AdminList divided label="Bases de données">
<AdminRow v-for="db in databases" :key="db.name" dense :title="db.name">
<template #media><span class="adm-cover db-icon"><Icon icon="fa-solid fa-file-lines" aria-hidden="true"/></span></template>
<template #meta>
<span class="adm-mono">{{ formatBytes(db.sizeBytes) }}</span>
<span>modifiée {{ formatRelative(db.modifiedAt, nowDate) }}</span>
</template>
</AdminRow>
</AdminList>
</AdminState>
</AdminCard>
</AdminGrid>
</AdminState>
</AdminSection>
</template>
<script setup>
/**
* Vue d'ensemble : alertes en tête, chiffres clés (cliquables vers leur section),
* puis trois catégories de widgets — Services, Ressources, Activité et données.
*/
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useAdminStore } from '@/stores/adminStore';
import Button from '@/components/UI/Button.vue';
import Tag from '@/components/UI/Tag.vue';
import Info from '@/components/UI/Info.vue';
import Error from '@/components/UI/Error.vue';
import AdminSection from './AdminSection.vue';
import AdminState from './AdminState.vue';
import AdminBanner from './AdminBanner.vue';
import AdminGrid from './AdminGrid.vue';
import AdminCard from './AdminCard.vue';
import AdminKv from './AdminKv.vue';
import AdminStat from './AdminStat.vue';
import AdminStatRow from './AdminStatRow.vue';
import AdminMeter from './AdminMeter.vue';
import AdminList from './AdminList.vue';
import AdminRow from './AdminRow.vue';
import { safeNum, formatBytes, formatUptime, formatDate, formatRelative, youtubeAccount, youtubeClass } from './adminFormat';
const adminStore = useAdminStore();
@@ -180,12 +213,59 @@ const uptime = computed(() => safeNum(data.value?.app?.uptime) + Math.max(0, now
const usedMem = computed(() => safeNum(data.value?.system?.totalmem) - safeNum(data.value?.system?.freemem));
function percent(value, total) {
function ratio(value, total) {
const t = safeNum(total);
if (t <= 0) return 0;
return Math.min(100, Math.max(0, safeNum(value) / t * 100));
return t > 0 ? Math.min(1, Math.max(0, safeNum(value) / t)) : 0;
}
// Charge sur 1 min rapportée au nombre de cœurs (0 sous Windows, où os.loadavg() n'existe pas)
const loadRatio = computed(() => ratio(data.value?.system?.loadavg?.[0], data.value?.system?.cpus));
const loadText = computed(() => {
const cpus = safeNum(data.value?.system?.cpus);
return safeNum(data.value?.system?.loadavg?.[0]).toFixed(2) + " / " + cpus + " cœur(s)";
});
const warpState = computed(() => {
const warp = data.value?.warp;
if (!warp?.configured) return { label: "Non configuré", tone: 'default', icon: "fa-solid fa-circle-minus" };
return warp.healthy
? { label: "Opérationnel", tone: 'success', icon: "fa-solid fa-circle-check" }
: { label: "Injoignable", tone: 'error', icon: "fa-solid fa-circle-xmark" };
});
// Part de lectures réussies parmi les tentatives récentes (null tant qu'il n'y en a pas)
const ytSuccess = computed(() => {
const rate = Number(data.value?.ytdlp?.recentFailureRate);
if (!safeNum(data.value?.ytdlp?.recentCount) || !Number.isFinite(rate)) return null;
return Math.min(1, Math.max(0, 1 - rate));
});
const databases = computed(() => (Array.isArray(data.value?.databases) ? data.value.databases : []));
const databasesSize = computed(() => databases.value.reduce((total, db) => total + safeNum(db.sizeBytes), 0));
// Problèmes à traiter, du plus grave au moins grave
const alerts = computed(() => {
const d = data.value;
if (!d) return [];
const list = [];
if (d.discord && !d.discord.ready) {
list.push({ id: 'discord', tone: 'error', title: "Discord n'est pas prêt", text: "Le bot n'est pas connecté à la passerelle Discord : les commandes et la lecture sont indisponibles." });
}
if (d.logs?.interrupted) {
list.push({ id: 'logs', tone: 'error', title: "Journal interrompu", text: `Plus rien n'est écrit dans « ${d.logs.current} » depuis la dernière erreur fatale.`, to: 'logs', action: "Ouvrir les logs" });
}
const yt = d.ytdlp;
if (safeNum(yt?.recentCount) >= 4 && Number(yt?.recentFailureRate) >= 0.5) {
list.push({ id: 'youtube-failing', tone: 'error', title: "La lecture YouTube échoue", text: `${Math.round(Number(yt.recentFailureRate) * 100)} % des ${safeNum(yt.recentCount)} dernières tentatives ont échoué.`, to: 'youtube', action: "Diagnostiquer" });
} else if (yt?.cookies?.present && !yt.cookies.valid) {
list.push({ id: 'youtube-cookies', tone: 'warning', title: "Compte YouTube à reconnecter", text: yt.cookies.invalidReason || "Les cookies YouTube ne sont plus valides.", to: 'youtube', action: "Reconnecter" });
}
if (d.warp?.configured && !d.warp.healthy) {
list.push({ id: 'warp', tone: 'warning', title: "WARP injoignable", text: "Les lectures passent par la route directe tant que le proxy ne répond pas.", to: 'warp', action: "Vérifier WARP" });
}
return list;
});
onMounted(() => {
adminStore.fetchOverview();
timer = setInterval(() => { nowDate.value = Date.now(); }, 1000);
@@ -196,87 +276,28 @@ onUnmounted(() => {
});
</script>
<style scoped>
.banner {
background-color: var(--tertiary);
border-radius: 10px;
padding: 10px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 10px;
align-items: start;
}
.wide {
grid-column: 1 / -1;
}
.gauge {
height: 6px;
background-color: var(--secondary);
border-radius: 5px;
overflow: hidden;
}
.gauge-fill {
height: 100%;
background-color: var(--main);
border-radius: 5px;
transition: width 0.3s ease;
}
.bot-line {
.bot {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 5px;
min-width: 0;
}
.bot-line p {
margin: 0;
.bot__info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.bot-avatar {
width: 40px;
height: 40px;
border-radius: 100%;
background-color: var(--main);
}
.bot-tag {
.bot__tag {
font-weight: 600;
overflow-wrap: anywhere;
}
.bot-id {
font-size: 0.7em;
color: var(--text-secondary);
font-family: monospace;
}
.db-icon {
--adm-cover-size: 32px;
.mono {
font-family: monospace;
}
.db-date {
color: var(--text-secondary);
font-size: 0.85em;
}
.small-btn {
margin-top: 5px;
}
.yt-warning {
margin: 0;
font-size: 0.8em;
color: var(--text-error);
font-size: 0.9em;
}
</style>
+24 -38
View File
@@ -1,16 +1,25 @@
<template>
<div class="pills">
<button v-for="option in options" :key="option.value"
:class="['pill', { selected: isSelected(option.value) }]"
:style="isSelected(option.value) && option.color ? { backgroundColor: option.color, borderColor: option.color } : {}"
<div class="adm-pills" role="group" :aria-label="label || undefined">
<button v-for="option in options" :key="String(option.value)" type="button"
:class="['adm-pill', { 'adm-pill--selected': isSelected(option.value) }]"
:style="option.color ? { '--adm-pill-color': option.color } : null"
:aria-pressed="isSelected(option.value) ? 'true' : 'false'"
:disabled="option.disabled"
@click="toggle(option.value)">
<Icon v-if="option.icon" :icon="option.icon"/>
{{ option.label }}
<Icon v-if="option.icon" :icon="option.icon" aria-hidden="true"/>
<span>{{ option.label }}</span>
<span v-if="hasCount(option)" class="adm-pill__count">{{ option.count }}</span>
</button>
</div>
</template>
<script setup>
// Filtres en pilule (choix unique, ou multiple avec la prop `multiple`)
/**
* Filtres en pilule (boutons bascule aria-pressed) : choix unique, ou multiple avec `multiple`.
* options : [{ value, label, icon?, color?, count?, disabled? }] ; `label` = nom accessible du groupe.
* Pour un choix exclusif de vue (onglets, portée), préférer UI/SegmentedControl.
*/
import './admin.scss';
const props = defineProps({
modelValue: {
default: null
@@ -22,6 +31,10 @@ const props = defineProps({
multiple: {
type: Boolean,
default: false
},
label: {
type: String,
default: null
}
});
@@ -31,6 +44,10 @@ function isSelected(value) {
return props.multiple ? (props.modelValue || []).includes(value) : props.modelValue === value;
}
function hasCount(option) {
return option.count !== undefined && option.count !== null && option.count !== '';
}
function toggle(value) {
if (!props.multiple) return emit('update:modelValue', value);
const current = [...(props.modelValue || [])];
@@ -39,34 +56,3 @@ function toggle(value) {
emit('update:modelValue', current);
}
</script>
<style scoped>
.pills {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.pill {
display: flex;
align-items: center;
gap: 5px;
border: 1px solid var(--quaternary);
background-color: transparent;
color: var(--text);
border-radius: 20px;
padding: 5px 10px;
font-size: 0.8em;
cursor: pointer;
transition: all 0.2s ease;
}
.pill:hover {
background-color: var(--tertiary);
}
.pill.selected {
background-color: var(--main);
border-color: var(--main);
color: #FFFFFF;
}
</style>
+211 -242
View File
@@ -1,110 +1,136 @@
<template>
<Box level="second" padding="closed" no-shadow class="player-card">
<div class="card-header">
<div class="guild">
<ServerAvatar class="guild-avatar" :src="player.guildIcon" :server-id="player.guildId"/>
<div class="guild-info">
<p class="guild-name">{{ player.guildName || "Serveur inconnu" }}</p>
<p class="guild-id">{{ player.guildId }}</p>
</div>
</div>
<AdminCard as="article" :span="4" :span-md="6" :span-sm="12" :tone="issue ? 'error' : 'default'"
:title="player.guildName || 'Serveur inconnu'" :subtitle="channelName ? 'Salon « ' + channelName + ' »' : 'Aucun salon vocal'" :busy="busy" busy-label="Commande en cours…">
<template #media>
<ServerAvatar class="adm-avatar" :src="player.guildIcon" :server-id="player.guildId" alt=""/>
</template>
<template #actions>
<Tag :color="status.color" :icon="status.icon">{{ status.label }}</Tag>
</div>
</template>
<div class="current">
<img v-if="player.current?.thumbnail" class="thumbnail" :src="player.current.thumbnail" alt="Miniature"/>
<div v-else class="thumbnail placeholder"><Icon icon="fa-solid fa-music"/></div>
<div class="current-info">
<div class="now">
<span class="adm-cover now__cover">
<img v-if="player.current?.thumbnail && !brokenCover" :src="player.current.thumbnail" alt="" referrerpolicy="no-referrer" @error="brokenCover = true"/>
<Icon v-else icon="fa-solid fa-music" aria-hidden="true"/>
</span>
<div class="now__info">
<template v-if="player.current">
<a v-if="player.current.url" class="title no-decoration" :href="player.current.url" target="_blank" rel="noopener">{{ player.current.title }}</a>
<p v-else class="title">{{ player.current.title }}</p>
<p class="author">{{ player.current.author || "Auteur inconnu" }}</p>
<a v-if="safeUrl(player.current.url)" class="now__title" :href="player.current.url" target="_blank" rel="noopener noreferrer">
{{ player.current.title || "Titre inconnu" }}<span class="sr-only"> (nouvel onglet)</span>
</a>
<p v-else class="now__title">{{ player.current.title || "Titre inconnu" }}</p>
<p class="now__author">{{ player.current.author || "Auteur inconnu" }}</p>
</template>
<template v-else>
<p class="title secondary">Aucun titre en cours</p>
<p class="author">La scène est vide</p>
<p class="now__title now__title--empty">Aucun titre en cours</p>
<p class="now__author">La scène est vide</p>
</template>
</div>
</div>
<div v-if="player.current" class="progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
</div>
<div class="times">
<AdminMeter v-if="!isLive" :value="progress" tone="main" label="Progression du titre"
:value-text="formatClock(positionMs) + ' sur ' + formatClock(durationMs)"/>
<div class="progress__times adm-mono">
<span>{{ formatClock(positionMs) }}</span>
<span v-if="isLive"><Tag color="var(--text-error)">LIVE</Tag></span>
<Tag v-if="isLive" tone="error" icon="fa-solid fa-tower-broadcast">En direct</Tag>
<span v-else>{{ formatClock(durationMs) }}</span>
</div>
</div>
<div class="tags">
<Tag :color="player.connection?.channelId || player.channelId ? 'var(--text-success)' : 'var(--text-error)'" icon="fa-solid fa-volume-high">
{{ player.connection?.channelName || player.channelName || "Aucun salon" }}
</Tag>
<Tag color="var(--text-secondary)" icon="fa-solid fa-headphones" title="Auditeurs dans le salon">{{ safeNum(player.listeners) }} auditeur(s)</Tag>
<Tag color="var(--text-secondary)" icon="fa-solid fa-globe" title="Utilisateurs sur l'interface web">{{ safeNum(player.webListeners) }} web</Tag>
<div class="adm-tags">
<Tag tone="default" icon="fa-solid fa-headphones" title="Auditeurs dans le salon vocal">{{ safeNum(player.listeners) }} auditeur(s)</Tag>
<Tag tone="default" icon="fa-solid fa-globe" title="Utilisateurs sur l'interface web">{{ safeNum(player.webListeners) }} web</Tag>
<Tag v-if="route" :color="route === 'warp' ? 'var(--owner-color)' : 'var(--admin-color)'" icon="fa-solid fa-route"
:title="player.ytdlp?.pid ? 'yt-dlp PID ' + player.ytdlp.pid : 'Route yt-dlp'">
yt-dlp : {{ route === 'warp' ? "WARP" : "Direct" }}<span v-if="player.ytdlp?.pid" class="mono"> · {{ player.ytdlp.pid }}</span>
yt-dlp : {{ route === 'warp' ? "WARP" : "Direct" }}<span v-if="player.ytdlp?.pid" class="adm-mono">&nbsp;· {{ player.ytdlp.pid }}</span>
</Tag>
<Tag v-if="player.loop" color="var(--main)" icon="fa-solid fa-repeat">Boucle</Tag>
<Tag v-if="player.shuffle" color="var(--main)" icon="fa-solid fa-shuffle">Aléatoire</Tag>
<Tag v-if="player.pendingPause" color="var(--text-warning)" icon="fa-solid fa-hourglass-half">Pause en attente</Tag>
<Tag v-if="player.autoLeaveAt" color="var(--text-warning)" icon="fa-solid fa-door-open">Départ {{ formatRelative(player.autoLeaveAt) }}</Tag>
<Tag v-if="player.loop" tone="main" icon="fa-solid fa-repeat">Boucle</Tag>
<Tag v-if="player.shuffle" tone="main" icon="fa-solid fa-shuffle">Aléatoire</Tag>
<Tag v-if="player.pendingPause" tone="warning" icon="fa-solid fa-hourglass-half">Pause en attente</Tag>
<Tag v-if="player.autoLeaveAt" tone="warning" icon="fa-solid fa-door-open">Départ {{ formatRelative(player.autoLeaveAt) }}</Tag>
</div>
<p v-if="player.status === 'loading' && player.loading" class="loading-info">
<Icon icon="fa-solid fa-spinner" spin-pulse/> Chargement (essai {{ safeNum(player.loading.attempt, 1) }}<span v-if="player.loading.route">, route {{ player.loading.route }}</span>)
<p v-if="player.status === 'loading' && player.loading" class="adm-hint loading">
<Icon icon="fa-solid fa-spinner" spin-pulse aria-hidden="true"/>
Chargement (essai {{ safeNum(player.loading.attempt, 1) }}<span v-if="player.loading.route">, route {{ player.loading.route }}</span>)
</p>
<Error v-if="player.lastError && !player.lastErrorStale" class="last-error">
{{ player.lastError.message || "Erreur inconnue" }} ({{ player.lastError.code }}) · {{ formatRelative(player.lastError.at) }}
</Error>
<p v-else-if="player.lastError" class="last-error stale" title="La lecture a repris depuis sur une autre musique">
<Icon icon="fa-solid fa-clock-rotate-left"/> Erreur précédente : {{ player.lastError.message || player.lastError.code }} · {{ formatRelative(player.lastError.at) }}
<AdminBanner v-if="player.lastError && !player.lastErrorStale" tone="error" class="error">
{{ player.lastError.message || "Erreur inconnue" }} <span class="adm-mono">({{ player.lastError.code }})</span> · {{ formatRelative(player.lastError.at) }}
</AdminBanner>
<p v-else-if="player.lastError" class="adm-hint loading" title="La lecture a repris depuis sur une autre musique">
<Icon icon="fa-solid fa-clock-rotate-left" aria-hidden="true"/>
Erreur précédente : {{ player.lastError.message || player.lastError.code }} · {{ formatRelative(player.lastError.at) }}
</p>
<div :class="['actions', { busy }]">
<IconAction title="Précédent" icon="fa-solid fa-backward-step" @click="act('previous')"/>
<IconAction v-if="player.status === 'playing' || player.status === 'loading'" title="Pause" icon="fa-solid fa-pause" @click="act('pause')"/>
<IconAction v-else title="Reprendre" icon="fa-solid fa-play" @click="act('resume')"/>
<IconAction title="Suivant" icon="fa-solid fa-forward-step" @click="act('skip')"/>
<IconAction title="Arrêter" icon="fa-solid fa-stop" @click="act('stop')"/>
<IconAction title="Vider la file d'attente" icon="fa-solid fa-trash-can" @click="act('clear')"/>
<IconAction title="Boucle" icon="fa-solid fa-repeat" :active="player.loop" :color="player.loop ? 'var(--main)' : 'var(--text)'" @click="act('loop', !player.loop)"/>
<IconAction title="Aléatoire" icon="fa-solid fa-shuffle" :active="player.shuffle" :color="player.shuffle ? 'var(--main)' : 'var(--text)'" @click="act('shuffle', !player.shuffle)"/>
<IconAction title="Quitter le salon" icon="fa-solid fa-right-from-bracket" color="var(--text-error)" @click="emit('leave', player)"/>
</div>
<div class="queue">
<button class="queue-toggle" @click="showQueue = !showQueue">
<Icon :icon="showQueue ? 'fa-solid fa-angle-up' : 'fa-solid fa-angle-down'"/>
File d'attente : {{ nextCount }} titre(s)
<button type="button" class="queue__toggle" :aria-expanded="showQueue ? 'true' : 'false'" :aria-controls="queueId" @click="showQueue = !showQueue">
<Icon class="queue__chevron" icon="fa-solid fa-chevron-down" aria-hidden="true"/>
File d'attente
<span class="adm-badge">{{ nextCount }}</span>
</button>
<div v-if="showQueue" class="queue-list">
<Info v-if="!player.next?.length" secondary>La file d'attente est vide</Info>
<div v-for="(song, index) in player.next" :key="(song.id || '') + index" class="queue-item">
<span class="queue-index">{{ index + 1 }}</span>
<div class="queue-info">
<p class="queue-title">{{ song.title }}</p>
<p class="queue-author">{{ song.author }}</p>
</div>
<span class="mono queue-duration">{{ song.duration ? formatClock(song.duration * 1000) : "LIVE" }}</span>
</div>
<p v-if="nextCount > (player.next?.length || 0)" class="queue-more">… et {{ nextCount - player.next.length }} autre(s)</p>
<div v-show="showQueue" :id="queueId" class="queue__body">
<p v-if="!player.next?.length" class="adm-hint">La file d'attente est vide.</p>
<ol v-else class="queue__list adm-scroll" :aria-label="'File d\'attente de ' + (player.guildName || player.guildId)">
<li v-for="(song, index) in player.next" :key="(song.id || '') + index" class="queue__item">
<span class="queue__index adm-mono" aria-hidden="true">{{ index + 1 }}</span>
<span class="queue__info">
<span class="queue__title">{{ song.title || "Titre inconnu" }}</span>
<span class="queue__author">{{ song.author || "Auteur inconnu" }}</span>
</span>
<span class="adm-mono adm-muted">{{ song.duration ? formatClock(song.duration * 1000) : "Direct" }}</span>
</li>
</ol>
<p v-if="nextCount > (player.next?.length || 0)" class="adm-hint queue__more">… et {{ nextCount - (player.next?.length || 0) }} autre(s)</p>
</div>
</div>
</Box>
<template #footer>
<div class="adm-iconbar" role="toolbar" :aria-label="'Commandes du player de ' + (player.guildName || player.guildId)">
<div class="adm-iconbar__group">
<button type="button" class="adm-iconbtn" aria-label="Titre précédent" title="Titre précédent" :disabled="busy" @click="act('previous')">
<Icon icon="fa-solid fa-backward-step" aria-hidden="true"/>
</button>
<button v-if="player.status === 'playing' || player.status === 'loading'" type="button" class="adm-iconbtn" aria-label="Mettre en pause" title="Mettre en pause" :disabled="busy" @click="act('pause')">
<Icon icon="fa-solid fa-pause" aria-hidden="true"/>
</button>
<button v-else type="button" class="adm-iconbtn" aria-label="Reprendre la lecture" title="Reprendre la lecture" :disabled="busy" @click="act('resume')">
<Icon icon="fa-solid fa-play" aria-hidden="true"/>
</button>
<button type="button" class="adm-iconbtn" aria-label="Titre suivant" title="Titre suivant" :disabled="busy" @click="act('skip')">
<Icon icon="fa-solid fa-forward-step" aria-hidden="true"/>
</button>
<button type="button" class="adm-iconbtn" aria-label="Arrêter la lecture" title="Arrêter la lecture" :disabled="busy" @click="act('stop')">
<Icon icon="fa-solid fa-stop" aria-hidden="true"/>
</button>
</div>
<div class="adm-iconbar__group">
<button type="button" class="adm-iconbtn" aria-label="Boucle" title="Boucle" :aria-pressed="player.loop ? 'true' : 'false'" :disabled="busy" @click="act('loop', !player.loop)">
<Icon icon="fa-solid fa-repeat" aria-hidden="true"/>
</button>
<button type="button" class="adm-iconbtn" aria-label="Lecture aléatoire" title="Lecture aléatoire" :aria-pressed="player.shuffle ? 'true' : 'false'" :disabled="busy" @click="act('shuffle', !player.shuffle)">
<Icon icon="fa-solid fa-shuffle" aria-hidden="true"/>
</button>
<button type="button" class="adm-iconbtn" aria-label="Vider la file d'attente" title="Vider la file d'attente" :disabled="busy || !nextCount" @click="act('clear')">
<Icon icon="fa-solid fa-trash-can" aria-hidden="true"/>
</button>
<button type="button" class="adm-iconbtn adm-iconbtn--danger" aria-label="Quitter le salon vocal" title="Quitter le salon vocal" :disabled="busy" @click="emit('leave', player)">
<Icon icon="fa-solid fa-right-from-bracket" aria-hidden="true"/>
</button>
</div>
</div>
</template>
</AdminCard>
</template>
<script setup>
import { computed, ref } from 'vue';
// Carte d'un player connecté : titre en cours, progression extrapolée, état, file d'attente et commandes
import { computed, ref, useId, watch } from 'vue';
import { useAdminStore } from '@/stores/adminStore';
import Box from '@/components/UI/Box.vue';
import Tag from '@/components/UI/Tag.vue';
import Info from '@/components/UI/Info.vue';
import Error from '@/components/UI/Error.vue';
import IconAction from '@/components/UI/IconAction.vue';
import ServerAvatar from '@/components/UI/ServerAvatar.vue';
import AdminCard from './AdminCard.vue';
import AdminMeter from './AdminMeter.vue';
import AdminBanner from './AdminBanner.vue';
import { safeNum, formatClock, formatRelative, playerStatus } from './adminFormat';
const props = defineProps({
@@ -124,8 +150,15 @@ const emit = defineEmits(['leave']);
const adminStore = useAdminStore();
const busy = ref(false);
const showQueue = ref(false);
const brokenCover = ref(false);
const queueId = `adm-queue-${useId()}`;
// Nouvelle musique : on retente l'affichage de la miniature
watch(() => props.player.current?.thumbnail, () => { brokenCover.value = false; });
const status = computed(() => playerStatus(props.player.status || (props.player.playing ? 'playing' : props.player.paused ? 'paused' : 'idle')));
const issue = computed(() => props.player.status === 'stalled' || !!(props.player.lastError && !props.player.lastErrorStale));
const channelName = computed(() => props.player.connection?.channelName || props.player.channelName || null);
const durationMs = computed(() => {
if (props.player.durationMs !== undefined && props.player.durationMs !== null) return safeNum(props.player.durationMs);
@@ -143,268 +176,204 @@ const positionMs = computed(() => {
return Math.max(0, Math.min(durationMs.value ?? Infinity, value));
});
const progress = computed(() => {
if (!durationMs.value) return 0;
return Math.min(100, positionMs.value / durationMs.value * 100);
});
const progress = computed(() => (durationMs.value ? Math.min(1, positionMs.value / durationMs.value) : 0));
const route = computed(() => props.player.ytdlp?.route || null);
const nextCount = computed(() => safeNum(props.player.nextCount, props.player.next?.length || 0));
function safeUrl(url) {
return typeof url === 'string' && /^https?:\/\//i.test(url);
}
// value : état voulu pour loop / shuffle (envoyé explicitement, sans bascule côté serveur)
async function act(action, value) {
if (busy.value) return;
busy.value = true;
await adminStore.playerAction(props.player.guildId, action, value);
busy.value = false;
try {
await adminStore.playerAction(props.player.guildId, action, value);
} finally {
busy.value = false;
}
}
</script>
<style scoped>
.player-card {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
p {
margin: 0;
}
.card-header {
.now {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.guild {
display: flex;
align-items: center;
gap: 10px;
gap: 12px;
min-width: 0;
padding: 8px;
border-radius: var(--adm-radius);
background-color: var(--adm-inset);
}
.guild-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
flex-shrink: 0;
}
.now__cover {
--adm-cover-size: 64px;
.guild-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.guild-name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.guild-id {
font-size: 0.7em;
color: var(--text-secondary);
font-family: monospace;
}
.current {
display: flex;
align-items: center;
gap: 10px;
background-color: var(--secondary);
border-radius: 10px;
padding: 5px;
}
.thumbnail {
width: 30%;
min-width: 90px;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: 10px;
width: 96px;
aspect-ratio: 16 / 9;
height: auto;
background-color: var(--quaternary);
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.current-info {
.now__info {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
gap: 4px;
flex: 1;
min-width: 0;
}
.title {
font-weight: bold;
color: var(--text);
overflow: hidden;
.now__title {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
font-weight: 600;
line-height: 1.3;
color: var(--text);
text-decoration: none;
overflow-wrap: anywhere;
}
.title.secondary {
opacity: 0.8;
a.now__title:hover {
color: var(--main);
text-decoration: underline;
}
.author {
.now__title--empty {
color: var(--text-secondary);
}
.now__author {
font-size: 0.8em;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress {
display: flex;
flex-direction: column;
gap: 5px;
gap: 4px;
}
.progress-bar {
height: 6px;
background-color: var(--secondary);
border-radius: 5px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--main);
border-radius: 5px;
transition: width 0.5s linear;
}
.times {
.progress__times {
display: flex;
justify-content: space-between;
align-items: center;
font-family: monospace;
font-size: 0.8em;
font-size: 0.75em;
color: var(--text-secondary);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.mono {
font-family: monospace;
}
.loading-info {
font-size: 0.8em;
color: var(--text-secondary);
.loading {
display: flex;
align-items: center;
gap: 5px;
gap: 6px;
}
.last-error {
font-size: 0.8em;
.error {
padding: 8px 10px;
font-size: 0.9em;
}
.last-error.stale {
margin: 0;
color: var(--text-secondary);
display: flex;
.queue__toggle {
display: inline-flex;
align-items: center;
gap: 5px;
}
.actions {
display: flex;
align-items: center;
justify-content: space-around;
gap: 10px;
padding: 10px;
background-color: var(--secondary);
border-radius: 10px;
transition: opacity 0.2s ease;
}
.actions.busy {
opacity: 0.5;
pointer-events: none;
}
.queue-toggle {
display: flex;
align-items: center;
gap: 5px;
background: none;
gap: 8px;
min-height: 32px;
padding: 0 8px;
margin-left: -8px;
border: none;
border-radius: 8px;
background: none;
color: var(--text-secondary);
cursor: pointer;
font: inherit;
font-size: 0.85em;
padding: 0;
cursor: pointer;
outline: 2px solid transparent;
}
.queue-toggle:hover {
.queue__toggle:hover {
color: var(--text);
background-color: var(--adm-hover);
}
.queue-list {
.queue__toggle:focus-visible {
box-shadow: var(--focus-ring);
}
.queue__chevron {
font-size: 0.8em;
transform: rotate(-90deg);
transition: transform var(--dur) var(--ease);
}
.queue__toggle[aria-expanded="true"] .queue__chevron {
transform: none;
}
.queue__body {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 5px;
max-height: 25vh;
overflow-y: auto;
animation: unfold 0.3s ease-in-out;
gap: 6px;
margin-top: 6px;
}
.queue-item {
.queue__list {
--adm-scroll-max: 240px;
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.queue__item {
display: flex;
align-items: center;
gap: 10px;
background-color: var(--secondary);
border-radius: 5px;
padding: 5px;
padding: 6px 8px;
border-radius: 8px;
background-color: var(--adm-inset);
font-size: 0.8em;
}
.queue-index {
color: var(--text-secondary);
font-family: monospace;
.queue__index {
width: 20px;
flex-shrink: 0;
text-align: center;
color: var(--text-secondary);
}
.queue-info {
.queue__info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.queue-title {
.queue__title,
.queue__author {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-author {
.queue__author {
color: var(--text-secondary);
font-size: 0.9em;
}
.queue-duration {
color: var(--text-secondary);
}
.queue-more {
font-size: 0.8em;
color: var(--text-secondary);
.queue__more {
text-align: center;
}
</style>
+70 -33
View File
@@ -1,18 +1,36 @@
<template>
<AdminSection title="🎵 Players" subtitle="Tous les players connectés, en temps réel">
<template #actions>
<AdminPills v-model="filter" :options="filters"/>
<Button icon="fa-solid fa-rotate" :disabled="adminStore.playersLoading" @click="adminStore.fetchPlayers()">Actualiser</Button>
</template>
<AdminSection title="Players" subtitle="Tous les players connectés à un salon vocal, en temps réel">
<AdminStatRow :columns="4" label="Résumé des players">
<AdminStat label="Connectés" :value="counts.all" icon="fa-solid fa-plug" tone="main"
:sub="counts.listeners + ' auditeur(s) dans les salons'"/>
<AdminStat label="En lecture" :value="counts.playing" icon="fa-solid fa-play" tone="success"/>
<AdminStat label="En pause" :value="counts.paused" icon="fa-solid fa-pause" tone="warning"/>
<AdminStat label="En difficulté" :value="counts.issues" icon="fa-solid fa-triangle-exclamation"
:tone="counts.issues ? 'error' : 'default'" :sub="counts.issues ? 'Bloqués ou en erreur' : 'Aucun problème signalé'"/>
</AdminStatRow>
<AdminToolbar v-model:search="search" search-label="Rechercher un player" search-placeholder="Serveur, salon ou titre…" label="Filtres des players">
<AdminPills v-model="filter" :options="filterOptions" label="Statut"/>
<template #end>
<Button variant="secondary" icon="fa-solid fa-rotate" :loading="adminStore.playersLoading" @click="adminStore.fetchPlayers()">Actualiser</Button>
</template>
</AdminToolbar>
<p class="sr-only" aria-live="polite">{{ liveSummary }}</p>
<AdminState :loading="!adminStore.playersLoaded && adminStore.playersLoading"
:error="!adminStore.playersLoaded ? adminStore.playersError : null"
:empty="visible.length === 0"
:empty-text="adminStore.players.length ? 'Aucun player ne correspond à ce filtre' : 'Aucun player connecté : silence radio sur tous les serveurs !'"
:empty-icon="adminStore.players.length ? 'fa-solid fa-filter' : 'fa-solid fa-volume-xmark'"
:empty-title="adminStore.players.length ? 'Aucun résultat' : 'Silence radio'"
:empty-text="adminStore.players.length ? 'Aucun player ne correspond à ces critères' : 'Aucun player n\'est connecté à un salon vocal pour le moment.'"
@retry="adminStore.fetchPlayers()">
<div class="players-grid">
<template v-if="adminStore.players.length" #empty>
<Button size="sm" variant="secondary" icon="fa-solid fa-filter-circle-xmark" @click="resetFilters()">Réinitialiser les filtres</Button>
</template>
<AdminGrid>
<AdminPlayerCard v-for="player in visible" :key="player.guildId" :player="player" :now="now" @leave="leave"/>
</div>
</AdminGrid>
</AdminState>
<AdminConfirm ref="confirmRef"/>
@@ -25,28 +43,61 @@ import Button from '@/components/UI/Button.vue';
import AdminSection from './AdminSection.vue';
import AdminState from './AdminState.vue';
import AdminPills from './AdminPills.vue';
import AdminGrid from './AdminGrid.vue';
import AdminStat from './AdminStat.vue';
import AdminStatRow from './AdminStatRow.vue';
import AdminToolbar from './AdminToolbar.vue';
import AdminConfirm from './AdminConfirm.vue';
import AdminPlayerCard from './AdminPlayerCard.vue';
import { safeNum } from './adminFormat';
const adminStore = useAdminStore();
const confirmRef = ref(null);
const filter = ref('all');
const filters = [
{ value: 'all', label: "Tous" },
{ value: 'playing', label: "En lecture", icon: "fa-solid fa-play" },
{ value: 'paused', label: "En pause", icon: "fa-solid fa-pause" },
{ value: 'issues', label: "Problèmes", icon: "fa-solid fa-triangle-exclamation" }
];
const search = ref("");
function hasIssue(player) {
return player.status === 'stalled' || !!(player.lastError && !player.lastErrorStale);
}
const counts = computed(() => {
const list = adminStore.players;
return {
all: list.length,
playing: list.filter(p => p.status === 'playing').length,
paused: list.filter(p => p.status === 'paused').length,
issues: list.filter(hasIssue).length,
listeners: list.reduce((total, p) => total + safeNum(p.listeners), 0)
};
});
const filterOptions = computed(() => [
{ value: 'all', label: "Tous", count: counts.value.all },
{ value: 'playing', label: "En lecture", icon: "fa-solid fa-play", count: counts.value.playing },
{ value: 'paused', label: "En pause", icon: "fa-solid fa-pause", count: counts.value.paused },
{ value: 'issues', label: "Problèmes", icon: "fa-solid fa-triangle-exclamation", count: counts.value.issues }
]);
const visible = computed(() => {
const list = [...adminStore.players].sort((a, b) => (a.guildName || "").localeCompare(b.guildName || ""));
if (filter.value === 'playing') return list.filter(p => p.status === 'playing');
if (filter.value === 'paused') return list.filter(p => p.status === 'paused');
if (filter.value === 'issues') return list.filter(p => p.status === 'stalled' || (p.lastError && !p.lastErrorStale));
return list;
const query = search.value.trim().toLowerCase();
let list = [...adminStore.players].sort((a, b) => (a.guildName || "").localeCompare(b.guildName || ""));
if (filter.value === 'playing') list = list.filter(p => p.status === 'playing');
else if (filter.value === 'paused') list = list.filter(p => p.status === 'paused');
else if (filter.value === 'issues') list = list.filter(hasIssue);
if (!query) return list;
return list.filter(p => [p.guildName, p.guildId, p.connection?.channelName, p.channelName, p.current?.title, p.current?.author]
.some(value => String(value || "").toLowerCase().includes(query)));
});
// Annoncé poliment aux lecteurs d'écran quand le nombre de résultats change
const liveSummary = computed(() => (adminStore.playersLoaded ? `${visible.value.length} player(s) affiché(s) sur ${counts.value.all}` : ""));
function resetFilters() {
filter.value = 'all';
search.value = "";
}
// Horloge partagée par toutes les cartes : la position affichée est recalculée depuis l'ancre (pas d'accumulation)
const now = ref(performance.now());
let timer = null;
@@ -74,17 +125,3 @@ onUnmounted(() => {
clearInterval(timer);
});
</script>
<style scoped>
.players-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
gap: 10px;
align-items: start;
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.players-grid {
grid-template-columns: 1fr;
}
}
</style>
+312 -189
View File
@@ -1,82 +1,190 @@
<template>
<AdminSection title="📚 Playlists" :subtitle="subtitle">
<AdminSection title="Playlists" subtitle="Playlists personnelles des comptes et playlists partagées des serveurs">
<template #actions>
<input type="text" class="search" v-model="search" placeholder="Rechercher (titre, propriétaire, ID)"/>
<Button icon="fa-solid fa-rotate" :disabled="loading" @click="load()">Actualiser</Button>
<Button variant="secondary" icon="fa-solid fa-rotate" :loading="currentLoading" @click="reload()">Actualiser</Button>
</template>
<AdminPills v-model="filter" :options="filterOptions"/>
<SegmentedControl v-model="scope" :options="scopeOptions" label="Type de playlists" class="scope"/>
<AdminState :loading="!owners && !error" :error="!owners ? error : null"
:empty="!!owners && filtered.length === 0"
:empty-text="search || filter !== 'all' ? 'Aucune playlist ne correspond à ces critères' : 'Aucune playlist enregistrée'"
@retry="load()">
<div class="list">
<div v-for="row in shown" :key="row.key" class="row">
<div class="main-info">
<div class="cover">
<img v-if="row.thumbnail && !brokenCovers.has(row.key)" :src="row.thumbnail" alt="" loading="lazy" referrerpolicy="no-referrer" @error="markBroken(row.key)"/>
<Icon v-else :icon="typeInfo(row).icon" :style="{ color: typeInfo(row).color }"/>
</div>
<div class="text">
<p class="title" :title="row.title">{{ row.title || "Sans titre" }}</p>
<div class="tags">
<Tag :color="typeInfo(row).color" :icon="typeInfo(row).icon">{{ typeInfo(row).label }}</Tag>
<Tag v-if="row.source === 'google'" color="var(--text-error)" icon="fa-brands fa-google">
Synchronisée<span v-if="row.syncedAt"> {{ formatRelative(row.syncedAt, nowDate) }}</span>
</Tag>
<span class="meta"><Icon icon="fa-solid fa-music"/> {{ safeNum(row.songCount) }} titre(s)</span>
<span v-if="safeNum(row.duration)" class="meta"><Icon icon="fa-solid fa-clock"/> {{ formatUptime(row.duration) }}</span>
<span class="meta mono">#{{ row.playlistId }}</span>
<!-- ----- Playlists personnelles ----- -->
<template v-if="scope === 'personal'">
<AdminStatRow :columns="4" label="Résumé des playlists personnelles">
<AdminStat label="Playlists" :value="personalTotals.playlists" icon="fa-solid fa-list" tone="main"/>
<AdminStat label="Propriétaires" :value="personalTotals.owners" icon="fa-solid fa-users" tone="admin"/>
<AdminStat label="Titres" :value="personalTotals.songs" icon="fa-solid fa-music" tone="info"/>
<AdminStat label="Synchronisées Google" :value="personalTotals.google" icon="fa-brands fa-google" tone="error"/>
</AdminStatRow>
<AdminToolbar v-model:search="search" search-label="Rechercher une playlist" search-placeholder="Titre, propriétaire ou ID…" label="Filtres des playlists">
<AdminPills v-model="filter" :options="filterOptions" label="Type"/>
</AdminToolbar>
<p class="sr-only" aria-live="polite">{{ owners ? filtered.length + ' playlist(s) trouvée(s)' : '' }}</p>
<AdminState :loading="!owners && !error" :error="!owners ? error : null" @retry="load()">
<AdminCard title="Playlists des comptes" icon="fa-solid fa-user" :span="12" flush :busy="loading"
:subtitle="filtered.length + ' playlist(s) affichée(s) sur ' + rows.length">
<AdminState :empty="filtered.length === 0" :retry="false" :empty-icon="search || filter !== 'all' ? 'fa-solid fa-filter' : 'fa-solid fa-list'"
:empty-text="search || filter !== 'all' ? 'Aucune playlist ne correspond à ces critères' : 'Aucune playlist enregistrée'">
<AdminList divided label="Playlists personnelles">
<AdminRow v-for="row in shown" :key="row.key">
<template #media>
<span class="adm-cover">
<img v-if="row.thumbnail && !brokenCovers.has(row.key)" :src="row.thumbnail" alt="" loading="lazy" referrerpolicy="no-referrer" @error="markBroken(row.key)"/>
<Icon v-else :icon="typeInfo(row).icon" :style="{ color: typeInfo(row).color }" aria-hidden="true"/>
</span>
</template>
<template #title>{{ row.title || "Sans titre" }}</template>
<template #subtitle>
{{ safeNum(row.songCount) }} titre(s)<span v-if="safeNum(row.duration)"> · {{ formatUptime(row.duration) }}</span>
<span class="adm-mono"> · #{{ row.playlistId }}</span>
</template>
<div class="adm-tags">
<Tag :color="typeInfo(row).color" :icon="typeInfo(row).icon">{{ typeInfo(row).label }}</Tag>
<Tag v-if="row.source === 'google'" tone="error" icon="fa-brands fa-google">
Synchronisée<span v-if="row.syncedAt">&nbsp;{{ formatRelative(row.syncedAt, nowDate) }}</span>
</Tag>
</div>
<template #meta>
<AdminUserLine :user="row.owner" :online="adminStore.onlineIds.has(row.userId)" size="28px"/>
</template>
<template #actions>
<a v-if="safeUrl(row.url)" class="adm-iconbtn" :href="row.url" target="_blank" rel="noopener noreferrer"
:aria-label="'Ouvrir la source de « ' + (row.title || 'Sans titre') + ' » (nouvel onglet)'" title="Ouvrir la source">
<Icon icon="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"/>
</a>
<button type="button" class="adm-iconbtn adm-iconbtn--danger" :aria-label="'Supprimer la playlist « ' + (row.title || 'Sans titre') + ' »'"
title="Supprimer" @click="removePersonal(row)">
<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 }} restante(s))
</Button>
</template>
</AdminCard>
</AdminState>
</template>
<!-- ----- Playlists partagées des serveurs ----- -->
<template v-else>
<AdminStatRow :columns="4" label="Résumé des playlists partagées">
<AdminStat label="Serveurs concernés" :value="guildTotals.guilds" icon="fa-solid fa-server" tone="warning"/>
<AdminStat label="Option activée" :value="guildTotals.enabled" icon="fa-solid fa-toggle-on" tone="success"/>
<AdminStat label="Playlists partagées" :value="guildTotals.playlists" icon="fa-solid fa-users-rectangle" tone="main"/>
<AdminStat label="Titres" :value="guildTotals.songs" icon="fa-solid fa-music" tone="info"/>
</AdminStatRow>
<AdminToolbar v-model:search="search" search-label="Rechercher une playlist partagée" search-placeholder="Serveur, titre ou créateur…" label="Filtres des playlists partagées">
<AdminPills v-model="guildFilter" :options="guildFilterOptions" label="Option du serveur"/>
</AdminToolbar>
<p class="sr-only" aria-live="polite">{{ guildsData ? filteredGuilds.length + ' serveur(s) affiché(s)' : '' }}</p>
<AdminState :loading="!guildsData && !guildsError" :error="!guildsData ? guildsError : null"
:empty="!!guildsData && filteredGuilds.length === 0"
:empty-icon="search || guildFilter !== 'all' ? 'fa-solid fa-filter' : 'fa-solid fa-users-rectangle'"
:empty-text="search || guildFilter !== 'all' ? 'Aucun serveur ne correspond à ces critères' : 'Aucun serveur n\'a activé les playlists partagées'"
@retry="loadGuilds()">
<AdminGrid align="start">
<AdminCard v-for="entry in filteredGuilds" :key="entry.guildId" :span="6" :span-sm="12" flush
:title="entry.guildName || 'Serveur quitté'" :tone="entry.settings?.enabled ? 'default' : 'warning'"
:subtitle="entry.playlists.length + ' playlist(s) · modifiables par ' + (entry.settings?.editors === 'mods' ? 'les modérateurs' : 'tous les membres')">
<template #media>
<ServerAvatar class="adm-avatar" :src="entry.guildIcon" :server-id="entry.guildId" alt=""/>
</template>
<template #actions>
<Tag :tone="entry.settings?.enabled ? 'success' : 'default'" :icon="entry.settings?.enabled ? 'fa-solid fa-toggle-on' : 'fa-solid fa-toggle-off'">
{{ entry.settings?.enabled ? "Activées" : "Désactivées" }}
</Tag>
</template>
<AdminState :empty="!entry.visible.length" :retry="false" empty-icon="fa-solid fa-list"
:empty-text="entry.playlists.length ? 'Aucune playlist ne correspond à la recherche' : 'Aucune playlist créée sur ce serveur'">
<div class="adm-scroll guild-list">
<AdminList divided :label="'Playlists partagées de ' + (entry.guildName || entry.guildId)">
<AdminRow v-for="playlist in entry.visible" :key="playlist.playlistId" dense>
<template #media>
<span class="adm-cover guild-cover">
<img v-if="playlist.thumbnail && !brokenCovers.has(entry.guildId + ':' + playlist.playlistId)" :src="playlist.thumbnail" alt=""
loading="lazy" referrerpolicy="no-referrer" @error="markBroken(entry.guildId + ':' + playlist.playlistId)"/>
<Icon v-else icon="fa-solid fa-list" aria-hidden="true"/>
</span>
</template>
<template #title>{{ playlist.title || "Sans titre" }}</template>
<template #subtitle>
{{ safeNum(playlist.songCount) }} titre(s)<span v-if="playlist.createdBy"> · par {{ playlist.createdBy.global_name || playlist.createdBy.username || playlist.createdBy.id }}</span>
</template>
<template #actions>
<button type="button" class="adm-iconbtn adm-iconbtn--danger" title="Supprimer"
:aria-label="'Supprimer la playlist partagée « ' + (playlist.title || 'Sans titre') + ' »'" @click="removeShared(entry, playlist)">
<Icon icon="fa-solid fa-trash" aria-hidden="true"/>
</button>
</template>
</AdminRow>
</AdminList>
</div>
</div>
</div>
<div class="side">
<AdminUserLine :user="row.owner" :online="adminStore.onlineIds.has(row.userId)" size="32px"/>
<a v-if="safeUrl(row.url)" class="link" :href="row.url" target="_blank" rel="noopener noreferrer" title="Ouvrir la source">
<Icon icon="fa-solid fa-arrow-up-right-from-square"/>
</a>
<IconAction title="Supprimer" icon="fa-solid fa-trash" color="var(--text-error)" @click="remove(row)"/>
</div>
</div>
</div>
<div v-if="filtered.length > shown.length" class="more">
<Button color-lower icon="fa-solid fa-chevron-down" @click="limit += PAGE_SIZE">
Afficher plus ({{ filtered.length - shown.length }} restante(s))
</Button>
</div>
</AdminState>
</AdminState>
<template v-if="entry.guildName" #footer>
<Button size="sm" variant="ghost" icon="fa-solid fa-sliders" :aria-label="'Gérer l\'option du serveur ' + entry.guildName"
@click="serverDetail.open({ id: entry.guildId, name: entry.guildName })">Gérer l'option</Button>
</template>
</AdminCard>
</AdminGrid>
</AdminState>
</template>
<AdminConfirm ref="confirmRef"/>
<AdminServerDetail ref="serverDetail"/>
</AdminSection>
</template>
<script setup>
/**
* Section « Playlists » : toutes les playlists enregistrées (/ADMIN/PLAYLISTS/LIST), filtrées côté client,
* suppression par /ADMIN/PLAYLISTS/DELETE {userId, playlistId}.
* Section « Playlists » :
* - personnelles : /ADMIN/PLAYLISTS/LIST, filtrées côté client, suppression {userId, playlistId} ;
* - partagées des serveurs : /ADMIN/PLAYLISTS/GUILDS, suppression {guildId, playlistId}, réglage via la fiche du serveur.
*/
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useAdminStore } from '@/stores/adminStore';
import { adminRequest, describeError } from '@/utils/AdminRequest';
import { adminRequest, adminListen, describeError } from '@/utils/AdminRequest';
import Button from '@/components/UI/Button.vue';
import Tag from '@/components/UI/Tag.vue';
import IconAction from '@/components/UI/IconAction.vue';
import SegmentedControl from '@/components/UI/SegmentedControl.vue';
import ServerAvatar from '@/components/UI/ServerAvatar.vue';
import AdminSection from './AdminSection.vue';
import AdminState from './AdminState.vue';
import AdminStat from './AdminStat.vue';
import AdminStatRow from './AdminStatRow.vue';
import AdminToolbar from './AdminToolbar.vue';
import AdminPills from './AdminPills.vue';
import AdminGrid from './AdminGrid.vue';
import AdminCard from './AdminCard.vue';
import AdminList from './AdminList.vue';
import AdminRow from './AdminRow.vue';
import AdminUserLine from './AdminUserLine.vue';
import AdminConfirm from './AdminConfirm.vue';
import AdminServerDetail from './AdminServerDetail.vue';
import { safeNum, formatUptime, formatRelative } from './adminFormat';
import { readLocal, writeLocal } from './adminUi';
const PAGE_SIZE = 50;
const SCOPE_KEY = "adminPlaylistsScope";
const adminStore = useAdminStore();
const confirmRef = ref(null);
const serverDetail = ref(null);
const scope = ref(readLocal(SCOPE_KEY) === 'guild' ? 'guild' : 'personal');
const search = ref("");
// ----- Playlists personnelles -----
const owners = ref(null);
const loading = ref(false);
const error = ref(null);
const search = ref("");
const filter = ref('all');
const limit = ref(PAGE_SIZE);
@@ -99,15 +207,6 @@ function typeInfo(row) {
return TYPES[kind(row)];
}
const filterOptions = [
{ value: 'all', label: "Toutes", icon: "fa-solid fa-layer-group" },
{ value: 'custom', label: "Personnalisées", icon: TYPES.custom.icon, color: TYPES.custom.color },
{ value: 'youtube', label: "YouTube", icon: TYPES.youtube.icon, color: TYPES.youtube.color },
{ value: 'google', label: "Synchro Google", icon: "fa-brands fa-google", color: TYPES.google.color },
{ value: 'spotify', label: "Spotify", icon: TYPES.spotify.icon, color: TYPES.spotify.color },
{ value: 'soundcloud', label: "SoundCloud", icon: TYPES.soundcloud.icon, color: TYPES.soundcloud.color }
];
// Une ligne par playlist, avec l'identité de son propriétaire
const rows = computed(() => {
const result = [];
@@ -121,6 +220,18 @@ const rows = computed(() => {
|| (a.title || "").localeCompare(b.title || ""));
});
const filterOptions = computed(() => {
const count = (type) => rows.value.filter(row => kind(row) === type).length;
return [
{ value: 'all', label: "Toutes", icon: "fa-solid fa-layer-group", count: rows.value.length },
{ value: 'custom', label: "Personnalisées", icon: TYPES.custom.icon, count: count('custom') },
{ value: 'youtube', label: "YouTube", icon: TYPES.youtube.icon, count: count('youtube') },
{ value: 'google', label: "Synchro Google", icon: "fa-brands fa-google", count: count('google') },
{ value: 'spotify', label: "Spotify", icon: TYPES.spotify.icon, count: count('spotify') },
{ value: 'soundcloud', label: "SoundCloud", icon: TYPES.soundcloud.icon, count: count('soundcloud') }
];
});
const filtered = computed(() => {
const query = search.value.trim().toLowerCase();
return rows.value.filter(row => {
@@ -136,25 +247,15 @@ const filtered = computed(() => {
const shown = computed(() => filtered.value.slice(0, limit.value));
const subtitle = computed(() => {
if (!owners.value) return "Playlists enregistrées par les utilisateurs";
const songs = rows.value.reduce((total, row) => total + safeNum(row.songCount), 0);
return `${rows.value.length} playlist(s), ${owners.value.length} propriétaire(s), ${songs} titre(s)`;
});
const personalTotals = computed(() => ({
playlists: rows.value.length,
owners: (owners.value || []).filter(o => o.playlists?.length).length,
songs: rows.value.reduce((total, row) => total + safeNum(row.songCount), 0),
google: rows.value.filter(row => row.source === 'google').length
}));
watch([search, filter], () => { limit.value = PAGE_SIZE; });
// Miniatures introuvables : remplacées par l'icône du type
const brokenCovers = ref(new Set());
function markBroken(key) {
brokenCovers.value = new Set(brokenCovers.value).add(key);
}
function safeUrl(url) {
return typeof url === 'string' && /^https?:\/\//i.test(url);
}
async function load() {
if (loading.value) return;
loading.value = true;
@@ -170,7 +271,7 @@ async function load() {
}
}
async function remove(row) {
async function removePersonal(row) {
const ownerName = row.owner.global_name || row.owner.username || row.userId;
const ok = await confirmRef.value.ask({
title: "Supprimer la playlist",
@@ -193,135 +294,157 @@ async function remove(row) {
}
}
// ----- Playlists partagées des serveurs -----
const guildsData = ref(null);
const guildsLoading = ref(false);
const guildsError = ref(null);
const guildFilter = ref('all');
const guildFilterOptions = computed(() => {
const list = guildsData.value || [];
return [
{ value: 'all', label: "Tous", count: list.length },
{ value: 'enabled', label: "Activées", icon: "fa-solid fa-toggle-on", count: list.filter(g => g.settings?.enabled).length },
{ value: 'disabled', label: "Désactivées", icon: "fa-solid fa-toggle-off", count: list.filter(g => !g.settings?.enabled).length }
];
});
// Chaque serveur garde ses playlists filtrées par la recherche (titre, créateur) ; un serveur dont le nom correspond les montre toutes
const filteredGuilds = computed(() => {
const query = search.value.trim().toLowerCase();
const result = [];
for (const entry of guildsData.value || []) {
if (guildFilter.value === 'enabled' && !entry.settings?.enabled) continue;
if (guildFilter.value === 'disabled' && entry.settings?.enabled) continue;
const playlists = Array.isArray(entry.playlists) ? entry.playlists : [];
const guildMatch = !query || (entry.guildName || "").toLowerCase().includes(query) || entry.guildId.includes(query);
const visible = guildMatch ? playlists : playlists.filter(p => (p.title || "").toLowerCase().includes(query)
|| (p.createdBy?.username || "").toLowerCase().includes(query)
|| (p.createdBy?.global_name || "").toLowerCase().includes(query));
if (!guildMatch && !visible.length) continue;
result.push({ ...entry, playlists, visible });
}
return result;
});
const guildTotals = computed(() => {
const list = guildsData.value || [];
const playlists = list.flatMap(g => g.playlists || []);
return {
guilds: list.length,
enabled: list.filter(g => g.settings?.enabled).length,
playlists: playlists.length,
songs: playlists.reduce((total, p) => total + safeNum(p.songCount), 0)
};
});
async function loadGuilds() {
if (guildsLoading.value) return;
guildsLoading.value = true;
try {
const result = await adminRequest("/ADMIN/PLAYLISTS/GUILDS", {}, 20000);
guildsData.value = Array.isArray(result) ? result : [];
guildsError.value = null;
} catch (e) {
guildsError.value = describeError(e);
if (guildsData.value) adminStore.notifyError(e, "Playlists partagées");
} finally {
guildsLoading.value = false;
}
}
async function removeShared(entry, playlist) {
const ok = await confirmRef.value.ask({
title: "Supprimer la playlist partagée",
icon: "fa-solid fa-trash",
message: `« ${playlist.title || "Sans titre"} » (${safeNum(playlist.songCount)} titre(s)) sera définitivement supprimée du serveur « ${entry.guildName || entry.guildId} » pour tous ses membres.`,
confirmLabel: "Supprimer",
confirmIcon: "fa-solid fa-trash"
});
if (!ok) return;
try {
await adminRequest("/ADMIN/PLAYLISTS/DELETE", { guildId: entry.guildId, playlistId: playlist.playlistId });
guildsData.value = (guildsData.value || []).map(g => g.guildId !== entry.guildId ? g
: { ...g, playlists: (g.playlists || []).filter(p => p.playlistId !== playlist.playlistId) });
adminStore.notify('success', `Playlist partagée « ${playlist.title || "Sans titre"} » supprimée`);
} catch (e) {
adminStore.notifyError(e, "Suppression de la playlist partagée");
if (e?.code === 'NOT_FOUND') loadGuilds();
}
}
// ----- Commun -----
const scopeOptions = computed(() => [
{ value: 'personal', label: "Personnelles", icon: "fa-solid fa-user", badge: owners.value ? rows.value.length : null },
{ value: 'guild', label: "Partagées des serveurs", icon: "fa-solid fa-users-rectangle", badge: guildsData.value ? guildTotals.value.playlists : null }
]);
const currentLoading = computed(() => (scope.value === 'personal' ? loading.value : guildsLoading.value));
function reload() {
if (scope.value === 'personal') load();
else loadGuilds();
}
watch(scope, (value) => {
writeLocal(SCOPE_KEY, value);
search.value = "";
if (value === 'guild' && !guildsData.value) loadGuilds();
if (value === 'personal' && !owners.value) load();
});
// L'option a pu changer depuis la fiche d'un serveur (poussée "/ADMIN/GUILDS/UPDATE") : on relit la liste partagée
watch(() => adminStore.guilds, () => {
if (scope.value === 'guild' && guildsData.value) loadGuilds();
});
// Miniatures introuvables : remplacées par l'icône du type
const brokenCovers = ref(new Set());
function markBroken(key) {
brokenCovers.value = new Set(brokenCovers.value).add(key);
}
function safeUrl(url) {
return typeof url === 'string' && /^https?:\/\//i.test(url);
}
const nowDate = ref(Date.now());
let timer = null;
// Poussée "/ADMIN/PLAYLISTS/UPDATE" {scopes} : on relit seulement la portée affichée, si elle est concernée et déjà chargée
let stopListening = null;
function onPlaylistsPushed(data) {
const scopes = Array.isArray(data?.scopes) ? data.scopes : ['personal', 'guild'];
if (scope.value === 'guild' && scopes.includes('guild') && guildsData.value) loadGuilds();
if (scope.value === 'personal' && scopes.includes('personal') && owners.value) load();
}
onMounted(() => {
load();
if (scope.value === 'guild') loadGuilds();
else load();
timer = setInterval(() => { nowDate.value = Date.now(); }, 30000);
stopListening = adminListen("/ADMIN/PLAYLISTS/UPDATE", onPlaylistsPushed);
});
onUnmounted(() => clearInterval(timer));
onUnmounted(() => {
clearInterval(timer);
stopListening?.();
});
</script>
<style scoped>
p {
margin: 0;
.scope {
align-self: flex-start;
max-width: 100%;
}
.search {
min-width: 260px;
.guild-list {
--adm-scroll-max: 320px;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
.guild-cover {
--adm-cover-size: 34px;
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background-color: var(--tertiary);
padding: 5px 10px 5px 5px;
border-radius: 10px;
}
.main-info {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.cover {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: 5px;
background-color: var(--secondary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
font-size: 1.3em;
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.text {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
}
.meta {
color: var(--text-secondary);
font-size: 0.8em;
display: flex;
align-items: center;
gap: 5px;
}
.mono {
font-family: monospace;
}
.side {
display: flex;
align-items: center;
gap: 15px;
flex-shrink: 0;
}
.link {
color: var(--text);
font-size: 1.1em;
}
.link:hover {
color: var(--main);
}
.more {
display: flex;
justify-content: center;
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.search {
min-width: 0;
flex: 1;
}
.row {
flex-direction: column;
align-items: stretch;
padding: 10px;
}
.side {
justify-content: space-between;
}
font-size: 0.9em;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<template>
<component :is="tag" :class="classes">
<div v-if="$slots.media" class="adm-row__media">
<slot name="media"></slot>
</div>
<div class="adm-row__main">
<p v-if="title || $slots.title" class="adm-row__title" :title="title || undefined">
<slot name="title">{{ title }}</slot>
</p>
<p v-if="subtitle || $slots.subtitle" class="adm-row__subtitle" :title="subtitle || undefined">
<slot name="subtitle">{{ subtitle }}</slot>
</p>
<slot></slot>
</div>
<div v-if="$slots.meta" class="adm-row__meta">
<slot name="meta"></slot>
</div>
<div v-if="$slots.actions" class="adm-row__actions">
<slot name="actions"></slot>
</div>
</component>
</template>
<script setup>
/**
* Ligne de liste (comptes, serveurs, playlists, métriques…) : media | principal | meta | actions.
* Sur une largeur réduite, meta et actions passent à la ligne, alignés à droite.
* Dans une AdminList `divided` (carte flush), les lignes sont séparées par des filets.
* Une ligne n'est jamais elle-même cliquable : mettre un bouton « Détails » dans `actions`.
*/
import { computed, inject } from 'vue';
import { ADMIN_LIST_KEY, normalizeTone, toneValidator } from './adminUi';
import './admin.scss';
const props = defineProps({
// Par défaut : 'li' dans une AdminList ul/ol, 'div' ailleurs
as: {
type: String,
default: null
},
title: {
type: String,
default: null
},
subtitle: {
type: String,
default: null
},
// Liseré coloré à gauche (ex. 'error' pour une valeur invalide) ; 'default' = aucun
tone: {
type: String,
default: 'default',
validator: toneValidator
},
selected: {
type: Boolean,
default: false
},
dense: {
type: Boolean,
default: false
},
// Fond --secondary (ligne posée directement sur une surface --tertiary)
inset: {
type: Boolean,
default: false
}
});
const list = inject(ADMIN_LIST_KEY, null);
const tag = computed(() => props.as || list?.itemTag() || 'div');
const classes = computed(() => {
const tone = normalizeTone(props.tone);
return [
'adm-row',
`adm-tone-${tone}`,
{
'adm-row--toned': tone !== 'default',
'adm-row--selected': props.selected,
'adm-row--dense': props.dense,
'adm-row--inset': props.inset
}
];
});
</script>
+30 -59
View File
@@ -1,20 +1,32 @@
<template>
<section class="admin-section">
<div class="section-header">
<div class="section-title">
<h2>{{ title }}</h2>
<p v-if="subtitle" class="subtitle">{{ subtitle }}</p>
<section class="adm-section" :aria-labelledby="ADMIN_SECTION_TITLE_ID">
<header class="adm-section__header">
<span v-if="resolvedIcon" class="adm-section__chip" aria-hidden="true"><Icon :icon="resolvedIcon"/></span>
<div class="adm-section__heading">
<h2 :id="ADMIN_SECTION_TITLE_ID" class="adm-section__title" tabindex="-1">{{ displayTitle }}</h2>
<p v-if="subtitle" class="adm-section__subtitle">{{ subtitle }}</p>
</div>
<div class="section-actions">
<div v-if="$slots.actions" class="adm-section__actions">
<slot name="actions"></slot>
</div>
</header>
<div class="adm-section__body">
<slot></slot>
</div>
<slot></slot>
</section>
</template>
<script setup>
// En-tête commun des sections d'administration (titre h2 avec emoji, comme Historique / Conseils)
defineProps({
/**
* En-tête commun des sections d'administration : pastille d'icône, titre h2, sous-titre, actions globales.
* Sans prop `icon`, l'icône de la section active (navigation) est reprise et un éventuel emoji
* en tête du titre est retiré (anciens titres « 📊 Vue d'ensemble »).
* Contenu : AdminBanner / AdminStatRow / AdminToolbar / AdminGrid, empilés avec un écart de 14 px.
*/
import { computed, inject } from 'vue';
import { ADMIN_NAV_KEY, ADMIN_SECTION_TITLE_ID, stripLeadingEmoji } from './adminUi';
import './admin.scss';
const props = defineProps({
title: {
type: String,
required: true
@@ -22,56 +34,15 @@ defineProps({
subtitle: {
type: String,
default: null
},
icon: {
type: String,
default: null
}
});
const nav = inject(ADMIN_NAV_KEY, null);
const resolvedIcon = computed(() => props.icon || nav?.current?.value?.icon || null);
const displayTitle = computed(() => (resolvedIcon.value ? stripLeadingEmoji(props.title) : props.title));
</script>
<style scoped>
.admin-section {
display: flex;
flex-direction: column;
gap: 15px;
min-height: 100%;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.section-title {
display: flex;
flex-direction: column;
gap: 5px;
}
h2 {
font-size: 1.7em;
margin: 0;
}
.subtitle {
margin: 0;
font-size: 0.9em;
color: var(--text-secondary);
}
.section-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
h2 {
font-size: 1.4em;
}
.section-actions {
width: 100%;
}
}
</style>
+246 -244
View File
@@ -1,134 +1,180 @@
<template>
<Modal ref="modal" icon="fa-solid fa-server" :title="guild ? guild.name : 'Serveur'">
<Modal ref="modal" size="lg" icon="fa-solid fa-server" :title="guild ? (guild.name || 'Serveur') : 'Serveur'" @close="onClose">
<AdminState :loading="loading && !detail" :error="!detail ? error : null" :empty="!detail" empty-text="Aucun détail disponible" @retry="load()">
<ModalTree title="Informations" icon="fa-solid fa-circle-info">
<div class="tree-content">
<div class="head">
<ServerAvatar :src="detail.icon" :server-id="detail.id"/>
<div class="head-info">
<p class="name">{{ detail.name }}</p>
<p class="id">{{ detail.id }}</p>
<p v-if="detail.description" class="description">{{ detail.description }}</p>
<div class="detail">
<header class="head">
<ServerAvatar class="adm-avatar adm-avatar--lg" :src="detail.icon" :server-id="detail.id" alt=""/>
<div class="head__info">
<p class="head__name">{{ detail.name || "Serveur sans nom" }}</p>
<p class="adm-hint adm-mono">{{ detail.id }}</p>
<p v-if="detail.description" class="adm-hint">{{ detail.description }}</p>
</div>
<div class="adm-tags head__tags">
<Tag :tone="detail.player ? 'success' : 'default'" :icon="detail.player ? 'fa-solid fa-music' : 'fa-solid fa-volume-xmark'">
{{ detail.player ? playerStatus(detail.player.status).label : "Player hors ligne" }}
</Tag>
<Tag v-if="detail.isMediaGuild" color="var(--mod-color)" icon="fa-solid fa-photo-film">Serveur de médias</Tag>
</div>
</header>
<AdminBanner v-if="detail.missingPermissions?.length" tone="warning" title="Permissions manquantes">
Subsonics ne dispose pas de : {{ detail.missingPermissions.join(", ") }}. Certaines fonctions peuvent échouer sur ce serveur.
</AdminBanner>
<AdminStatRow :columns="4" label="Chiffres du serveur">
<AdminStat label="Membres" :value="safeNum(detail.memberCount)" icon="fa-solid fa-users"/>
<AdminStat label="Inscrits" :value="safeNum(detail.registeredMembers)" icon="fa-solid fa-id-card" tone="admin"/>
<AdminStat label="Commandes" :value="safeNum(detail.stats?.commands)" icon="fa-solid fa-terminal" tone="info"/>
<AdminStat label="Écoute" :value="formatUptime(detail.stats?.secondsPlayed)" icon="fa-solid fa-headphones" tone="main"
:sub="safeNum(detail.stats?.musicPlayed) + ' titre(s) joué(s)'"/>
</AdminStatRow>
<AdminGrid>
<AdminCard title="Informations" icon="fa-solid fa-circle-info" :span="6" :span-sm="12">
<AdminKv label="Propriétaire">
<AdminUserLine v-if="detail.owner?.username" :user="detail.owner" size="28px"/>
<span v-else class="adm-mono">{{ detail.owner?.id || "Inconnu" }}</span>
</AdminKv>
<AdminKv label="En ligne sur le web" mono :value="safeNum(detail.webUsersOnline)"/>
<AdminKv label="Bot arrivé le" :value="formatDate(detail.joinedAt)"/>
<AdminKv label="Serveur créé le" :value="formatDate(detail.createdAt)"/>
<div v-if="detail.features?.length" class="adm-chips" aria-label="Fonctionnalités Discord">
<span v-for="feature in detail.features" :key="feature" class="adm-chip">{{ feature }}</span>
</div>
</div>
<AdminKv label="Propriétaire">
<AdminUserLine v-if="detail.owner?.username" :user="detail.owner" size="30px"/>
<span v-else class="mono">{{ detail.owner?.id || "Inconnu" }}</span>
</AdminKv>
<AdminKv label="Membres" mono :value="safeNum(detail.memberCount)"/>
<AdminKv label="Membres inscrits" mono :value="safeNum(detail.registeredMembers)"/>
<AdminKv label="En ligne sur le web" mono :value="safeNum(detail.webUsersOnline)"/>
<AdminKv label="Bot arrivé le" :value="formatDate(detail.joinedAt)"/>
<AdminKv label="Serveur créé le" :value="formatDate(detail.createdAt)"/>
<AdminKv v-if="detail.player" label="Player">
<Tag :color="playerStatus(detail.player.status).color">{{ playerStatus(detail.player.status).label }}</Tag>
</AdminKv>
<Error v-if="detail.missingPermissions?.length">Permissions manquantes : {{ detail.missingPermissions.join(", ") }}</Error>
<div v-if="detail.features?.length" class="tags">
<Tag v-for="feature in detail.features" :key="feature" color="var(--text-secondary)" icon="fa-solid fa-tag">{{ feature }}</Tag>
</div>
</div>
</ModalTree>
</AdminCard>
<ModalTree title="Statistiques" icon="fa-solid fa-chart-simple">
<div class="tree-content">
<div class="stats">
<AdminStat label="Commandes" :value="safeNum(detail.stats?.commands)" icon="fa-solid fa-terminal"/>
<AdminStat label="Titres joués" :value="safeNum(detail.stats?.musicPlayed)" icon="fa-solid fa-music"/>
<AdminStat label="Temps d'écoute" :value="formatUptime(detail.stats?.secondsPlayed)" icon="fa-solid fa-clock"/>
</div>
<div v-if="detail.metrics?.length" class="metrics">
<div v-for="metric in detail.metrics" :key="metric.name" class="metric">
<div class="metric-info">
<p class="metric-name">{{ metric.description || metric.name }}</p>
<p class="metric-id">{{ metric.name }}</p>
<AdminCard title="Accès" icon="fa-solid fa-shield-halved" color="var(--owner-color)" :span="6" :span-sm="12"
subtitle="Restreindre Subsonics à un rôle">
<AdminField label="Rôle sécurisé" hint="Seuls les membres possédant ce rôle peuvent utiliser Subsonics sur ce serveur."
:dirty="roleDirty" v-slot="{ id, describedby }">
<select :id="id" v-model="selectedRole" :aria-describedby="describedby" :disabled="savingRole">
<option :value="null">Aucun rôle (tout le monde)</option>
<!-- @everyone équivaut à « aucun rôle » côté serveur : pas d'option en double -->
<option v-for="role in sortedRoles" :key="role.id" :value="role.id">{{ role.name }}</option>
</select>
</AdminField>
<template #footer>
<Button size="sm" variant="ghost" :disabled="!roleDirty || savingRole" @click="selectedRole = detail.secureRole?.id ?? null">Annuler</Button>
<Button size="sm" icon="fa-solid fa-check" :loading="savingRole" :disabled="!roleDirty" @click="saveRole()">Enregistrer</Button>
</template>
</AdminCard>
<AdminCard title="Playlists partagées" icon="fa-solid fa-users-rectangle" tone="main" :span="6" :span-sm="12"
:subtitle="safeNum(shared.count) + ' playlist(s) enregistrée(s) sur ce serveur'" :busy="savingShared">
<Switch :model-value="shared.enabled" :disabled="savingShared" label="Activer les playlists du serveur"
description="Les membres voient et lisent des playlists communes à ce serveur. Désactiver les masque sans rien supprimer."
@update:model-value="saveShared({ enabled: $event })"/>
<AdminField plain label="Qui peut les modifier ?" v-slot="{ labelledby }">
<SegmentedControl :model-value="shared.editors" :options="editorOptions" size="sm" :aria-labelledby="labelledby"
@update:model-value="saveShared({ editors: $event })"/>
</AdminField>
</AdminCard>
<AdminCard title="Salons vocaux" icon="fa-solid fa-volume-high" tone="info" :span="6" :span-sm="12" flush
:subtitle="(detail.voiceChannels?.length || 0) + ' salon(s)'">
<AdminState :empty="!detail.voiceChannels?.length" :retry="false" empty-icon="fa-solid fa-volume-xmark" empty-text="Aucun salon vocal visible">
<div class="adm-scroll channels">
<AdminList divided label="Salons vocaux">
<AdminRow v-for="channel in detail.voiceChannels" :key="channel.id" dense :selected="isPlayerChannel(channel)">
<template #title><Icon icon="fa-solid fa-volume-high" aria-hidden="true"/> {{ channel.name }}</template>
<template #meta>
<Tag v-if="isPlayerChannel(channel)" tone="main" icon="fa-solid fa-music">Subsonics</Tag>
<span class="adm-mono">{{ safeNum(channel.members) }} membre(s)</span>
</template>
</AdminRow>
</AdminList>
</div>
<Tag v-if="metric.invalid" color="var(--text-error)" icon="fa-solid fa-triangle-exclamation">Invalide</Tag>
<p class="metric-value">{{ metric.name.includes('Seconds') ? formatUptime(metric.value) : safeNum(metric.value) }}</p>
</AdminState>
</AdminCard>
<AdminCard title="Membres inscrits" icon="fa-solid fa-users" tone="admin" :span="12" flush
:subtitle="filteredMembers.length + ' sur ' + (detail.members?.length || 0)">
<div class="members-toolbar">
<AdminToolbar v-model:search="memberSearch" search-label="Rechercher un membre" search-placeholder="Nom, identifiant ou ID…"/>
</div>
</div>
</div>
</ModalTree>
<ModalTree title="Rôle sécurisé" icon="fa-solid fa-shield-halved">
<div class="tree-content">
<p class="hint">Seuls les membres possédant ce rôle peuvent utiliser Subsonics sur ce serveur.</p>
<select v-model="selectedRole" class="select">
<option :value="null">Aucun rôle (tout le monde)</option>
<!-- @everyone équivaut à « aucun rôle » côté serveur : pas d'option en double -->
<option v-for="role in sortedRoles" :key="role.id" :value="role.id">{{ role.name }}</option>
</select>
<Button icon="fa-solid fa-rotate" :disabled="savingRole || selectedRole === (detail.secureRole?.id ?? null)" @click="saveRole()">Mettre à jour</Button>
</div>
</ModalTree>
<ModalTree title="Salons vocaux" icon="fa-solid fa-volume-high">
<div class="tree-content">
<Info v-if="!detail.voiceChannels?.length" secondary>Aucun salon vocal</Info>
<div v-for="channel in detail.voiceChannels" :key="channel.id" class="row">
<span><Icon icon="fa-solid fa-volume-high"/> {{ channel.name }}</span>
<span class="row-side">
<Tag v-if="detail.player && (detail.player.connection?.channelId || detail.player.channelId) === channel.id" color="var(--main)" icon="fa-solid fa-music">Subsonics</Tag>
<span class="mono">{{ safeNum(channel.members) }} membre(s)</span>
</span>
</div>
</div>
</ModalTree>
<ModalTree title="Membres inscrits" icon="fa-solid fa-users">
<div class="tree-content">
<input type="text" v-model="memberSearch" placeholder="Rechercher un membre"/>
<Info v-if="!filteredMembers.length" secondary>Aucun membre inscrit</Info>
<div class="members">
<div v-for="member in filteredMembers" :key="member.id" class="member">
<div class="member-main">
<AdminUserLine :user="member" :online="member.online"/>
<div class="tags">
<Tag v-if="member.isAdmin" color="var(--admin-color)">Administrateur</Tag>
<Tag v-else-if="member.isOwner" color="var(--owner-color)">Propriétaire</Tag>
<Tag v-else-if="member.isMod" color="var(--mod-color)">Modérateur</Tag>
<Tag v-else color="var(--text-secondary)">Membre</Tag>
<Tag v-if="member.isBanned" color="var(--text-error)">Banni</Tag>
</div>
</div>
<div v-if="!member.isAdmin && !member.isOwner && member.id !== myId" class="actions">
<IconAction title="Bannir / Débannir de ce serveur" icon="fa-solid fa-hammer"
:color="member.isBanned ? 'var(--text-error)' : 'var(--text-tertiary)'" @click="toggleMember(member, 'ban')"/>
<IconAction title="Nommer / Retirer modérateur" icon="fa-solid fa-user-shield"
:color="member.isMod ? 'var(--mod-color)' : 'var(--text-tertiary)'" @click="toggleMember(member, 'mod')"/>
<AdminState :empty="!filteredMembers.length" :retry="false" empty-icon="fa-solid fa-user-slash"
:empty-text="memberSearch ? 'Aucun membre ne correspond à cette recherche' : 'Aucun membre inscrit sur Subsonics'">
<div class="adm-scroll members">
<AdminList divided label="Membres inscrits">
<AdminRow v-for="member in filteredMembers" :key="member.id" dense>
<AdminUserLine :user="member" :online="member.online" size="34px"/>
<template #meta>
<Tag v-if="member.isAdmin" color="var(--admin-color)">Administrateur</Tag>
<Tag v-else-if="member.isOwner" color="var(--owner-color)">Propriétaire</Tag>
<Tag v-else-if="member.isMod" color="var(--mod-color)">Modérateur</Tag>
<Tag v-if="member.isBanned" tone="error" icon="fa-solid fa-ban">Banni</Tag>
</template>
<template v-if="canManage(member)" #actions>
<Button size="sm" variant="secondary" icon="fa-solid fa-user-shield" :aria-pressed="member.isMod ? 'true' : 'false'"
:disabled="memberBusy.has(member.id) || member.isBanned"
:aria-label="(member.isMod ? 'Retirer le rôle de modérateur à ' : 'Nommer modérateur ') + memberName(member)"
@click="toggleMember(member, 'mod')">{{ member.isMod ? "Retirer modo" : "Modérateur" }}</Button>
<Button size="sm" :variant="member.isBanned ? 'secondary' : 'danger'" :icon="member.isBanned ? 'fa-solid fa-unlock' : 'fa-solid fa-ban'"
:disabled="memberBusy.has(member.id)"
:aria-label="(member.isBanned ? 'Débannir ' : 'Bannir ') + memberName(member) + ' de ce serveur'"
@click="toggleMember(member, 'ban')">{{ member.isBanned ? "Débannir" : "Bannir" }}</Button>
</template>
</AdminRow>
</AdminList>
</div>
</AdminState>
</AdminCard>
<AdminCard v-if="detail.metrics?.length" title="Statistiques détaillées" icon="fa-solid fa-chart-simple" :span="12" flush
collapsible :default-open="false" persist-key="server-metrics" :subtitle="detail.metrics.length + ' métrique(s)'">
<div class="adm-scroll">
<AdminList divided label="Métriques du serveur">
<AdminRow v-for="metric in detail.metrics" :key="metric.name" dense :tone="metric.invalid ? 'error' : 'default'"
:title="metric.description || metric.name" :subtitle="metric.name">
<template #meta>
<Tag v-if="metric.invalid" tone="error" icon="fa-solid fa-triangle-exclamation">Invalide</Tag>
<span class="adm-mono metric-value">{{ metric.name.includes('Seconds') ? formatUptime(metric.value) : safeNum(metric.value) }}</span>
</template>
</AdminRow>
</AdminList>
</div>
</div>
</div>
</ModalTree>
</AdminCard>
<ModalTree title="Zone dangereuse" icon="fa-solid fa-triangle-exclamation">
<div class="tree-content">
<p class="hint">Faire quitter le bot supprime sa présence sur le serveur : il faudra le réinviter pour l'utiliser à nouveau.</p>
<Info v-if="detail.isMediaGuild" secondary>Le serveur de médias est protégé et ne peut pas être quitté.</Info>
<Button icon="fa-solid fa-right-from-bracket" :disabled="detail.isMediaGuild || leaving" @click="leaveGuild()">Faire quitter le bot</Button>
</div>
</ModalTree>
<AdminCard title="Zone dangereuse" icon="fa-solid fa-triangle-exclamation" tone="error" :span="12">
<p class="adm-text">Faire quitter le bot supprime sa présence sur le serveur : il faudra le réinviter pour l'utiliser à nouveau.</p>
<p v-if="detail.isMediaGuild" class="adm-hint">Le serveur de médias est protégé et ne peut pas être quitté.</p>
<template #footer>
<Button variant="danger" icon="fa-solid fa-right-from-bracket" :loading="leaving" :disabled="detail.isMediaGuild" @click="leaveGuild()">Faire quitter le bot</Button>
</template>
</AdminCard>
</AdminGrid>
</div>
</AdminState>
</Modal>
<AdminConfirm ref="confirmRef"/>
</template>
<script setup>
/**
* Fenêtre de gestion d'un serveur : chiffres, informations, rôle sécurisé, playlists partagées,
* salons vocaux, membres inscrits (modération), métriques, départ du bot.
* open(guild) avec au moins { id, name }.
*/
import { computed, ref } from 'vue';
import { useAdminStore } from '@/stores/adminStore';
import { useUserStore } from '@/stores/userStore';
import { adminRequest, describeError } from '@/utils/AdminRequest';
import Modal from '@/components/UI/Modal.vue';
import ModalTree from '@/components/UI/ModalTree.vue';
import Button from '@/components/UI/Button.vue';
import Tag from '@/components/UI/Tag.vue';
import Info from '@/components/UI/Info.vue';
import Error from '@/components/UI/Error.vue';
import IconAction from '@/components/UI/IconAction.vue';
import Switch from '@/components/UI/Switch.vue';
import SegmentedControl from '@/components/UI/SegmentedControl.vue';
import ServerAvatar from '@/components/UI/ServerAvatar.vue';
import AdminState from './AdminState.vue';
import AdminBanner from './AdminBanner.vue';
import AdminGrid from './AdminGrid.vue';
import AdminCard from './AdminCard.vue';
import AdminKv from './AdminKv.vue';
import AdminField from './AdminField.vue';
import AdminStat from './AdminStat.vue';
import AdminStatRow from './AdminStatRow.vue';
import AdminToolbar from './AdminToolbar.vue';
import AdminList from './AdminList.vue';
import AdminRow from './AdminRow.vue';
import AdminUserLine from './AdminUserLine.vue';
import AdminConfirm from './AdminConfirm.vue';
import { safeNum, formatDate, formatUptime, playerStatus } from './adminFormat';
@@ -145,18 +191,42 @@ const loading = ref(false);
const error = ref(null);
const selectedRole = ref(null);
const savingRole = ref(false);
const savingShared = ref(false);
const leaving = ref(false);
const memberSearch = ref("");
const memberBusy = ref(new Set());
const editorOptions = [
{ value: 'members', label: "Tous les membres", icon: "fa-solid fa-users" },
{ value: 'mods', label: "Modérateurs", icon: "fa-solid fa-user-shield" }
];
const sortedRoles = computed(() => (detail.value?.roles || []).filter(role => role.name !== "@everyone").sort((a, b) => safeNum(b.position) - safeNum(a.position)));
const roleDirty = computed(() => !!detail.value && selectedRole.value !== (detail.value.secureRole?.id ?? null));
const shared = computed(() => detail.value?.sharedPlaylists || { enabled: false, editors: 'members', count: 0 });
const filteredMembers = computed(() => {
const query = memberSearch.value.trim().toLowerCase();
const list = detail.value?.members || [];
// Rôles importants d'abord, puis en ligne, puis ordre alphabétique
const rank = (m) => (m.isAdmin ? 0 : m.isOwner ? 1 : m.isMod ? 2 : 3);
const list = [...(detail.value?.members || [])].sort((a, b) => rank(a) - rank(b) || (b.online - a.online) || memberName(a).localeCompare(memberName(b)));
if (!query) return list;
return list.filter(m => [m.id, m.username, m.global_name].some(v => (v || "").toLowerCase().includes(query)));
});
function memberName(member) {
return member.global_name || member.username || member.id;
}
function canManage(member) {
return !member.isAdmin && !member.isOwner && member.id !== myId.value;
}
function isPlayerChannel(channel) {
const player = detail.value?.player;
return !!player && (player.connection?.channelId || player.channelId) === channel.id;
}
async function load() {
if (!guild.value || loading.value) return;
loading.value = true;
@@ -176,10 +246,15 @@ function open(target) {
detail.value = null;
error.value = null;
memberSearch.value = "";
memberBusy.value = new Set();
modal.value?.open();
load();
}
function onClose() {
memberSearch.value = "";
}
async function saveRole() {
savingRole.value = true;
try {
@@ -194,19 +269,52 @@ async function saveRole() {
}
}
// patch : { enabled } ou { editors } ; l'état affiché suit la réponse du serveur
async function saveShared(patch) {
if (savingShared.value || !detail.value) return;
savingShared.value = true;
try {
const result = await adminRequest("/ADMIN/GUILDS/SET_SHARED_PLAYLISTS", { guildId: detail.value.id, ...patch });
const next = result?.sharedPlaylists;
if (next) detail.value = { ...detail.value, sharedPlaylists: next };
if ('enabled' in patch) adminStore.notify('success', next?.enabled ? "Playlists partagées activées" : "Playlists partagées désactivées (rien n'est supprimé)");
else adminStore.notify('success', next?.editors === 'mods' ? "Seuls les modérateurs modifient les playlists partagées" : "Tous les membres peuvent modifier les playlists partagées");
} catch (e) {
adminStore.notifyError(e, "Playlists partagées");
} finally {
savingShared.value = false;
}
}
async function toggleMember(member, kind) {
const event = kind === 'ban' ? "/ADMIN/USERS/SET_GUILD_BAN" : "/ADMIN/USERS/SET_GUILD_MOD";
const value = kind === 'ban' ? !member.isBanned : !member.isMod;
const field = kind === 'ban' ? 'isBanned' : 'isMod';
const value = !member[field];
if (kind === 'ban' && value) {
const ok = await confirmRef.value.ask({
title: "Bannir du serveur",
icon: "fa-solid fa-ban",
message: `${memberName(member)} ne pourra plus utiliser Subsonics sur « ${detail.value.name} ».`,
confirmLabel: "Bannir",
confirmIcon: "fa-solid fa-ban"
});
if (!ok) return;
}
memberBusy.value = new Set(memberBusy.value).add(member.id);
try {
await adminRequest(event, { userId: member.id, guildId: detail.value.id, value });
// Mise à jour locale : le résumé renvoyé est global, on ne garde que l'état de ce serveur
detail.value = {
...detail.value,
members: detail.value.members.map(m => m.id === member.id ? { ...m, [kind === 'ban' ? 'isBanned' : 'isMod']: value } : m)
members: detail.value.members.map(m => m.id === member.id ? { ...m, [field]: value } : m)
};
adminStore.notify('success', `${member.global_name || member.username} : ${kind === 'ban' ? (value ? "banni du serveur" : "débanni du serveur") : (value ? "nommé modérateur" : "n'est plus modérateur")}`);
adminStore.notify('success', `${memberName(member)} : ${kind === 'ban' ? (value ? "banni du serveur" : "débanni du serveur") : (value ? "nommé modérateur" : "n'est plus modérateur")}`);
} catch (e) {
adminStore.notifyError(e, kind === 'ban' ? "Bannissement" : "Modération");
} finally {
const next = new Set(memberBusy.value);
next.delete(member.id);
memberBusy.value = next;
}
}
@@ -237,165 +345,59 @@ async function leaveGuild() {
defineExpose({ open });
</script>
<style scoped>
.tree-content {
display: flex;
flex-direction: column;
gap: 10px;
margin: 10px 0 15px 0;
}
p {
margin: 0;
}
.detail {
display: flex;
flex-direction: column;
gap: var(--adm-gap);
min-width: 0;
}
.head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
gap: 12px;
}
.head-info {
.head__info {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1 1 220px;
min-width: 0;
}
.name {
font-weight: 600;
.head__name {
font-size: 1.1em;
font-weight: 700;
overflow-wrap: anywhere;
}
.id,
.metric-id {
font-size: 0.7em;
color: var(--text-secondary);
font-family: monospace;
word-break: break-all;
.head__tags {
margin-left: auto;
}
.description,
.hint {
font-size: 0.85em;
color: var(--text-secondary);
}
.mono {
font-family: monospace;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.metrics {
display: flex;
flex-direction: column;
gap: 5px;
max-height: 30vh;
overflow-y: auto;
}
.metric {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px;
background-color: var(--tertiary);
border-radius: 10px;
}
.metric-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.metric-name {
font-weight: bold;
font-size: 0.9em;
}
.metric-value {
background-color: var(--secondary);
padding: 5px;
border-radius: 5px;
font-family: monospace;
}
.select {
background-color: var(--tertiary);
color: var(--text);
border: none;
border-radius: 5px;
padding: 7px;
font-family: 'Inter', sans-serif;
outline: none;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background-color: var(--tertiary);
border-radius: 10px;
padding: 7px 10px;
font-size: 0.9em;
}
.row-side {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-secondary);
.channels {
--adm-scroll-max: 260px;
}
.members {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 35vh;
overflow-y: auto;
--adm-scroll-max: 380px;
}
.member {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background-color: var(--tertiary);
padding: 5px 10px 5px 5px;
border-radius: 10px;
.members-toolbar {
padding: 10px var(--adm-pad);
border-bottom: 1px solid var(--adm-border);
}
.member-main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.member-main {
flex-direction: column;
align-items: flex-start;
}
.metric-value {
padding: 2px 8px;
border-radius: var(--radius-sm);
background-color: var(--adm-inset);
color: var(--text);
}
</style>
+97 -122
View File
@@ -1,158 +1,133 @@
<template>
<AdminSection title="🏠 Serveurs" :subtitle="adminStore.guilds.length + ' serveur(s) Discord accueillent Subsonics'">
<template #actions>
<input type="text" class="search" v-model="search" placeholder="Rechercher un serveur (nom ou ID)"/>
<Button icon="fa-solid fa-rotate" :disabled="adminStore.guildsLoading" @click="adminStore.fetchGuilds(true)">Rafraîchir</Button>
</template>
<AdminSection title="Serveurs" subtitle="Les serveurs Discord qui accueillent Subsonics">
<AdminStatRow :columns="4" label="Résumé des serveurs">
<AdminStat label="Serveurs" :value="adminStore.guilds.length" icon="fa-solid fa-server" tone="warning"
:sub="totals.members + ' membre(s) au total'"/>
<AdminStat label="Players actifs" :value="totals.players" icon="fa-solid fa-music" tone="main" to="players"/>
<AdminStat label="Membres inscrits" :value="totals.registered" icon="fa-solid fa-id-card" tone="admin"
:sub="totals.online + ' en ligne sur le web'"/>
<AdminStat label="Permissions manquantes" :value="counts.issues" icon="fa-solid fa-triangle-exclamation"
:tone="counts.issues ? 'error' : 'success'" :sub="counts.issues ? 'Serveur(s) à corriger' : 'Tout est en ordre'"/>
</AdminStatRow>
<AdminToolbar v-model:search="search" search-label="Rechercher un serveur" search-placeholder="Nom ou ID du serveur…" label="Filtres des serveurs">
<AdminPills v-model="filter" :options="filterOptions" label="Afficher"/>
<template #end>
<Button variant="secondary" icon="fa-solid fa-rotate" :loading="adminStore.guildsLoading" @click="adminStore.fetchGuilds(true)">Rafraîchir</Button>
</template>
</AdminToolbar>
<AdminState :loading="!adminStore.guildsLoaded && adminStore.guildsLoading"
:error="!adminStore.guildsLoaded ? adminStore.guildsError : null"
:empty="filtered.length === 0"
:empty-text="search ? 'Aucun serveur ne correspond à cette recherche' : 'Subsonics n\'est présent sur aucun serveur'"
:empty="adminStore.guildsLoaded && filtered.length === 0"
:empty-icon="adminStore.guilds.length ? 'fa-solid fa-filter' : 'fa-solid fa-server'"
:empty-text="adminStore.guilds.length ? 'Aucun serveur ne correspond à ces critères' : 'Subsonics n\'est présent sur aucun serveur'"
@retry="adminStore.fetchGuilds()">
<div class="servers-list">
<Box v-for="guild in filtered" :key="guild.id" level="second" padding="closed" no-shadow class="server-item">
<div class="server-main">
<ServerAvatar :src="guild.icon" :server-id="guild.id"/>
<div class="server-info">
<p class="name">{{ guild.name }}</p>
<p class="data">
<Icon style="font-size: 10px;" :color="guild.player ? 'var(--mod-color)' : 'var(--text-error)'" icon="fa-solid fa-circle"/>
{{ guild.player ? "Player connecté" : "Player hors ligne" }} - {{ safeNum(guild.memberCount) }} membres
</p>
<div class="tags">
<Tag color="var(--admin-color)" icon="fa-solid fa-id-card">{{ safeNum(guild.registeredMembers) }} inscrit(s)</Tag>
<Tag v-if="guild.webUsersOnline" color="var(--text-success)" icon="fa-solid fa-globe">{{ guild.webUsersOnline }} en ligne</Tag>
<Tag v-if="guild.secureRole" color="var(--owner-color)" icon="fa-solid fa-shield-halved">{{ guild.secureRole.name }}</Tag>
<Tag v-if="guild.isMediaGuild" color="var(--mod-color)" icon="fa-solid fa-photo-film">Serveur de médias</Tag>
<Tag v-if="guild.missingPermissions?.length" color="var(--text-error)" icon="fa-solid fa-triangle-exclamation">{{ guild.missingPermissions.length }} permission(s) manquante(s)</Tag>
</div>
<template v-if="adminStore.guilds.length" #empty>
<Button size="sm" variant="secondary" icon="fa-solid fa-filter-circle-xmark" @click="resetFilters()">Réinitialiser les filtres</Button>
</template>
<AdminCard title="Liste des serveurs" icon="fa-solid fa-list" :span="12" flush :busy="adminStore.guildsLoading"
:subtitle="filtered.length + ' serveur(s) affiché(s) sur ' + adminStore.guilds.length">
<AdminList divided label="Serveurs">
<AdminRow v-for="guild in filtered" :key="guild.id" :tone="guild.missingPermissions?.length ? 'warning' : 'default'">
<template #media>
<ServerAvatar class="adm-avatar" :src="guild.icon" :server-id="guild.id" alt=""/>
</template>
<template #title>{{ guild.name || "Serveur sans nom" }}</template>
<template #subtitle>
{{ safeNum(guild.memberCount) }} membre(s) · {{ safeNum(guild.registeredMembers) }} inscrit(s)<span class="adm-mono"> · {{ guild.id }}</span>
</template>
<div class="adm-tags">
<Tag :tone="guild.player ? 'success' : 'default'" :icon="guild.player ? 'fa-solid fa-music' : 'fa-solid fa-volume-xmark'">
{{ guild.player ? playerStatus(guild.player.status).label : "Player hors ligne" }}
</Tag>
<Tag v-if="guild.webUsersOnline" tone="success" icon="fa-solid fa-globe">{{ guild.webUsersOnline }} en ligne</Tag>
<Tag v-if="guild.sharedPlaylists?.enabled" tone="main" icon="fa-solid fa-users-rectangle">
{{ safeNum(guild.sharedPlaylists.count) }} playlist(s) partagée(s)
</Tag>
<Tag v-if="guild.secureRole" color="var(--owner-color)" icon="fa-solid fa-shield-halved">Rôle {{ guild.secureRole.name }}</Tag>
<Tag v-if="guild.isMediaGuild" color="var(--mod-color)" icon="fa-solid fa-photo-film">Serveur de médias</Tag>
<Tag v-if="guild.missingPermissions?.length" tone="warning" icon="fa-solid fa-triangle-exclamation">
{{ guild.missingPermissions.length }} permission(s) manquante(s)
</Tag>
</div>
</div>
<div class="server-side">
<p class="server-stats">
<span title="Commandes"><Icon icon="fa-solid fa-terminal"/> {{ safeNum(guild.stats?.commands) }}</span>
<span title="Titres joués"><Icon icon="fa-solid fa-music"/> {{ safeNum(guild.stats?.musicPlayed) }}</span>
</p>
<Button color-lower icon="fa-solid fa-magnifying-glass" @click="detail.open(guild)">Détails</Button>
</div>
</Box>
</div>
<template #meta>
<span title="Commandes exécutées"><Icon icon="fa-solid fa-terminal" aria-hidden="true"/> <span class="sr-only">Commandes :</span> {{ safeNum(guild.stats?.commands) }}</span>
<span title="Titres joués"><Icon icon="fa-solid fa-music" aria-hidden="true"/> <span class="sr-only">Titres joués :</span> {{ safeNum(guild.stats?.musicPlayed) }}</span>
</template>
<template #actions>
<Button size="sm" variant="secondary" icon="fa-solid fa-sliders" :aria-label="'Gérer le serveur ' + (guild.name || guild.id)" @click="detail.open(guild)">Gérer</Button>
</template>
</AdminRow>
</AdminList>
</AdminCard>
</AdminState>
<AdminServerDetail ref="detail"/>
</AdminSection>
</template>
<script setup>
// Section « Serveurs » : chiffres clés, recherche locale + filtres, liste et fenêtre de gestion d'un serveur
import { computed, onMounted, ref } from 'vue';
import { useAdminStore } from '@/stores/adminStore';
import Box from '@/components/UI/Box.vue';
import Button from '@/components/UI/Button.vue';
import Tag from '@/components/UI/Tag.vue';
import ServerAvatar from '@/components/UI/ServerAvatar.vue';
import AdminSection from './AdminSection.vue';
import AdminState from './AdminState.vue';
import AdminStat from './AdminStat.vue';
import AdminStatRow from './AdminStatRow.vue';
import AdminToolbar from './AdminToolbar.vue';
import AdminPills from './AdminPills.vue';
import AdminCard from './AdminCard.vue';
import AdminList from './AdminList.vue';
import AdminRow from './AdminRow.vue';
import AdminServerDetail from './AdminServerDetail.vue';
import { safeNum } from './adminFormat';
import { safeNum, playerStatus } from './adminFormat';
const adminStore = useAdminStore();
const search = ref("");
const filter = ref('all');
const detail = ref(null);
const totals = computed(() => adminStore.guilds.reduce((acc, g) => {
acc.members += safeNum(g.memberCount);
acc.registered += safeNum(g.registeredMembers);
acc.online += safeNum(g.webUsersOnline);
if (g.player) acc.players++;
return acc;
}, { members: 0, registered: 0, online: 0, players: 0 }));
const counts = computed(() => ({
player: adminStore.guilds.filter(g => g.player).length,
shared: adminStore.guilds.filter(g => g.sharedPlaylists?.enabled).length,
issues: adminStore.guilds.filter(g => g.missingPermissions?.length).length
}));
const filterOptions = computed(() => [
{ value: 'all', label: "Tous", count: adminStore.guilds.length },
{ value: 'player', label: "Player actif", icon: "fa-solid fa-music", count: counts.value.player },
{ value: 'shared', label: "Playlists partagées", icon: "fa-solid fa-users-rectangle", count: counts.value.shared },
{ value: 'issues', label: "À corriger", icon: "fa-solid fa-triangle-exclamation", count: counts.value.issues }
]);
// Recherche locale : la liste complète est déjà poussée par "/ADMIN/GUILDS/UPDATE"
const filtered = computed(() => {
const query = search.value.trim().toLowerCase();
const list = [...adminStore.guilds].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
let list = [...adminStore.guilds].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
if (filter.value === 'player') list = list.filter(g => g.player);
else if (filter.value === 'shared') list = list.filter(g => g.sharedPlaylists?.enabled);
else if (filter.value === 'issues') list = list.filter(g => g.missingPermissions?.length);
if (!query) return list;
return list.filter(g => (g.name || "").toLowerCase().includes(query) || String(g.id).includes(query));
});
function resetFilters() {
search.value = "";
filter.value = 'all';
}
onMounted(() => {
adminStore.fetchGuilds();
});
</script>
<style scoped>
.search {
min-width: 260px;
}
.servers-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.server-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
}
.server-main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.server-info {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
p {
margin: 0;
}
.name {
font-weight: 600;
}
.data {
color: var(--text-secondary);
font-size: 0.8em;
display: flex;
align-items: center;
gap: 3px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.server-side {
display: flex;
align-items: center;
gap: 15px;
flex-shrink: 0;
}
.server-stats {
display: flex;
gap: 10px;
color: var(--text-secondary);
font-size: 0.85em;
font-family: monospace;
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.search {
min-width: 0;
flex: 1;
}
.server-item {
flex-direction: column;
align-items: stretch;
}
.server-side {
justify-content: space-between;
}
}
</style>
+79 -58
View File
@@ -1,16 +1,33 @@
<template>
<div class="stat">
<Icon class="stat-icon" :icon="icon" :style="{ color: color }"/>
<div class="stat-info">
<p class="stat-label">{{ label }}</p>
<p class="stat-value">{{ value }}</p>
<p v-if="sub" class="stat-sub">{{ sub }}</p>
</div>
</div>
<component :is="isClickable() ? 'button' : 'div'" :type="isClickable() ? 'button' : undefined"
:class="['adm-stat', `adm-tone-${toneName}`, { 'adm-tone-custom': !!color }]"
:style="color ? { '--adm-tone': color } : null"
@click="onClick">
<span v-if="icon" class="adm-stat__chip" aria-hidden="true"><Icon :icon="icon"/></span>
<span class="adm-stat__info">
<span class="adm-stat__label">{{ label }}</span>
<span :class="['adm-stat__value', { 'adm-stat__value--mono': mono }]"><slot>{{ value }}</slot></span>
<span v-if="sub || $slots.sub" class="adm-stat__sub"><slot name="sub">{{ sub }}</slot></span>
<AdminMeter v-if="hasProgress" :value="progress" :label="progressLabel || label" :tone="progressTone"/>
</span>
<template v-if="isClickable()">
<Icon class="adm-stat__go" icon="fa-solid fa-chevron-right" aria-hidden="true"/>
<span v-if="to" class="sr-only">, ouvrir la section {{ targetLabel }}</span>
</template>
</component>
</template>
<script setup>
// Tuile de chiffre clé (même hiérarchie de surfaces que Metric.vue)
defineProps({
/**
* Tuile de chiffre clé. `to` (id de section) la rend cliquable (bouton → adminStore.setSection),
* tout comme un écouteur @click. `progress` (0..1) ajoute une jauge sous la valeur.
*/
import { computed, inject, useAttrs } from 'vue';
import { useAdminStore } from '@/stores/adminStore';
import AdminMeter from './AdminMeter.vue';
import { ADMIN_NAV_KEY, normalizeTone, toneValidator } from './adminUi';
import './admin.scss';
const props = defineProps({
label: {
type: String,
required: true
@@ -26,56 +43,60 @@ defineProps({
type: String,
default: "fa-solid fa-chart-bar"
},
// Historique : couleur libre de la pastille (prioritaire sur `tone`)
color: {
type: String,
default: "var(--text)"
default: null
},
tone: {
type: String,
default: 'default',
validator: toneValidator
},
// Id de section ('players', 'users'…) : la tuile ouvre cette section
to: {
type: String,
default: null
},
// Libellé lisible de la section cible, pour les lecteurs d'écran
toLabel: {
type: String,
default: null
},
progress: {
type: Number,
default: null
},
progressLabel: {
type: String,
default: null
},
// 'auto' : rose, orange au-delà de 75 %, rouge au-delà de 90 %
progressTone: {
type: String,
default: 'auto'
},
mono: {
type: Boolean,
default: false
}
});
const attrs = useAttrs();
const adminStore = useAdminStore();
const nav = inject(ADMIN_NAV_KEY, null);
const toneName = computed(() => normalizeTone(props.tone));
// Pas de defineEmits('click') : l'écouteur du parent reste dans attrs et passe au bouton racine.
// useAttrs() n'est pas réactif : fonction relue à chaque rendu plutôt qu'un computed
function isClickable() {
return !!props.to || !!attrs.onClick;
}
const hasProgress = computed(() => typeof props.progress === 'number' && Number.isFinite(props.progress));
const targetLabel = computed(() => props.toLabel || nav?.sections?.find(s => s.id === props.to)?.label || props.to);
function onClick() {
if (props.to) adminStore.setSection(props.to);
}
</script>
<style scoped>
.stat {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background-color: var(--tertiary);
border-radius: 10px;
min-width: 0;
}
.stat-icon {
background-color: var(--secondary);
padding: 15px;
border-radius: 100%;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.stat-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.stat-info p {
margin: 0;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.8em;
}
.stat-value {
font-weight: bold;
font-size: 1.3em;
font-family: monospace;
word-break: break-word;
}
.stat-sub {
color: var(--text-secondary);
font-size: 0.7em;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
<template>
<div :class="['adm-statrow', `adm-statrow--cols-${cols}`]" :role="label ? 'group' : undefined" :aria-label="label || undefined">
<div class="adm-statrow__grid">
<slot></slot>
</div>
</div>
</template>
<script setup>
/**
* Rangée de chiffres clés (AdminStat) : `columns` colonnes, puis 2 et 1 quand la place manque
* (requête de conteneur : suit la largeur réelle de la section).
* Dans une AdminGrid, elle occupe toute la ligne.
*/
import { computed } from 'vue';
import './admin.scss';
const props = defineProps({
// 2 à 6 colonnes sur grand écran
columns: {
type: [Number, String],
default: 4
},
// Nom accessible du groupe (facultatif)
label: {
type: String,
default: null
}
});
const cols = computed(() => {
const n = Math.round(Number(props.columns));
return Number.isFinite(n) ? Math.min(6, Math.max(2, n)) : 4;
});
</script>
+34 -34
View File
@@ -1,17 +1,30 @@
<template>
<p v-if="loading" class="second"><Icon icon="fa-solid fa-spinner" spin-pulse/> {{ loadingText }}</p>
<div v-else-if="error" class="state-error">
<Error>{{ error }}</Error>
<Button v-if="retry" icon="fa-solid fa-rotate" @click="$emit('retry')">Réessayer</Button>
<div v-if="loading" class="adm-state-loading">
<Spinner :label="loadingText" visible-label/>
</div>
<Info v-else-if="empty" secondary class="state-empty">{{ emptyText }}</Info>
<AdminBanner v-else-if="error" tone="error" :title="errorTitle">
{{ error }}
<template v-if="retry" #action>
<Button size="sm" variant="secondary" icon="fa-solid fa-rotate" @click="$emit('retry')">Réessayer</Button>
</template>
</AdminBanner>
<EmptyState v-else-if="empty" :icon="emptyIcon" :title="emptyTitle" compact>
{{ emptyText }}
<template v-if="$slots.empty" #actions><slot name="empty"></slot></template>
</EmptyState>
<slot v-else></slot>
</template>
<script setup>
// États de chargement / erreur / vide communs à toutes les sections d'administration
import Error from '@/components/UI/Error.vue';
import Info from '@/components/UI/Info.vue';
/**
* États de chargement / erreur / vide communs aux sections d'administration.
* Le contenu (slot par défaut) n'est rendu que si aucun de ces états n'est actif.
* Slot `empty` : boutons affichés sous le message vide.
*/
import Button from '@/components/UI/Button.vue';
import Spinner from '@/components/UI/Spinner.vue';
import EmptyState from '@/components/UI/EmptyState.vue';
import AdminBanner from './AdminBanner.vue';
import './admin.scss';
defineEmits(['retry']);
defineProps({
@@ -29,40 +42,27 @@ defineProps({
},
loadingText: {
type: String,
default: "Chargement en cours"
default: "Chargement en cours…"
},
errorTitle: {
type: String,
default: "Chargement impossible"
},
emptyText: {
type: String,
default: "Rien à afficher pour le moment"
},
emptyTitle: {
type: String,
default: null
},
emptyIcon: {
type: String,
default: "fa-solid fa-inbox"
},
retry: {
type: Boolean,
default: true
}
});
</script>
<style scoped>
.second {
color: var(--text-secondary);
font-size: 0.9em;
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
}
.state-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
flex-wrap: wrap;
background-color: var(--tertiary);
border-radius: 10px;
padding: 10px;
}
.state-empty {
padding: 10px 0;
}
</style>
+46
View File
@@ -0,0 +1,46 @@
<template>
<li :class="['adm-step', `adm-step--${state}`]" :aria-current="state === 'current' ? 'step' : undefined">
<span class="adm-step__num" aria-hidden="true">
<Icon v-if="state === 'done'" icon="fa-solid fa-check"/>
<template v-else-if="number !== null">{{ number }}</template>
</span>
<div class="adm-step__body">
<component :is="headingTag" v-if="title || $slots.title" class="adm-step__title">
<span v-if="number !== null" class="sr-only">Étape {{ number }} : </span>
<slot name="title">{{ title }}</slot>
<span v-if="state === 'done'" class="sr-only"> (terminée)</span>
</component>
<slot></slot>
</div>
</li>
</template>
<script setup>
/**
* Étape d'un AdminSteps : pastille numérotée (ou coche si `state="done"`), titre, contenu libre.
* `number` facultatif : sans lui, la pastille suit le compteur CSS de la liste.
* Titre un niveau sous la carte qui contient la liste (h4 dans une carte h3).
*/
import { computed, inject, unref } from 'vue';
import { ADMIN_HEADING_KEY } from './adminUi';
import './admin.scss';
defineProps({
title: {
type: String,
default: null
},
number: {
type: [Number, String],
default: null
},
// 'todo' | 'current' | 'done'
state: {
type: String,
default: 'todo',
validator: (value) => ['todo', 'current', 'done'].includes(value)
}
});
const headingLevel = inject(ADMIN_HEADING_KEY, null);
const headingTag = computed(() => 'h' + Math.min(6, (Number(unref(headingLevel)) || 3) + 1));
</script>
+20
View File
@@ -0,0 +1,20 @@
<template>
<ol class="adm-steps" :aria-label="label || undefined">
<slot></slot>
</ol>
</template>
<script setup>
/**
* Liste d'étapes numérotées (assistant) : contient des AdminStep.
* La numérotation visuelle suit l'ordre (compteur CSS) ; l'<ol> la donne aux lecteurs d'écran.
*/
import './admin.scss';
defineProps({
// Nom accessible de la liste (facultatif)
label: {
type: String,
default: null
}
});
</script>
+27 -81
View File
@@ -1,97 +1,43 @@
<template>
<Teleport to="body">
<div class="toasts">
<TransitionGroup name="toast">
<div v-for="toast in adminStore.toasts" :key="toast.id" :class="['toast', toast.type]">
<Icon class="toast-icon" :icon="iconOf(toast.type)"/>
<p>{{ toast.text }}</p>
<IconAction icon="fa-solid fa-xmark" size="1em" @click="adminStore.dismiss(toast.id)"/>
</div>
<!-- Région toujours présente : les lecteurs d'écran n'annoncent que les ajouts dans une région déjà montée -->
<section class="adm-toasts-region" aria-label="Notifications de l'administration">
<TransitionGroup tag="ul" name="adm-toast" class="adm-toasts" aria-live="polite" aria-relevant="additions">
<li v-for="toast in adminStore.toasts" :key="toast.id" :class="['adm-toast', 'adm-toast--' + typeOf(toast.type)]"
:role="typeOf(toast.type) === 'error' ? 'alert' : undefined">
<Icon class="adm-toast__icon" :icon="toneIcon(typeOf(toast.type))" aria-hidden="true"/>
<p class="adm-toast__text">
<span class="sr-only">{{ prefixOf(toast.type) }}</span>{{ toast.text }}
</p>
<IconAction class="adm-toast__close" icon="fa-solid fa-xmark" size="0.95em"
label="Fermer la notification" @click="adminStore.dismiss(toast.id)"/>
</li>
</TransitionGroup>
</div>
</section>
</Teleport>
</template>
<script setup>
// Notifications de l'administration (résultat des actions, erreurs avec leur code)
import IconAction from '@/components/UI/IconAction.vue';
import { useAdminStore } from '@/stores/adminStore';
import { toneIcon } from './adminUi';
import './admin.scss';
const adminStore = useAdminStore();
function iconOf(type) {
if (type === 'error') return "fa-solid fa-circle-exclamation";
if (type === 'success') return "fa-solid fa-check-circle";
return "fa-solid fa-circle-info";
}
</script>
<style scoped>
.toasts {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 4500;
max-width: min(420px, calc(100vw - 40px));
// Types du store : 'info' | 'success' | 'error' (+ 'warning' toléré)
function typeOf(type) {
if (type === 'error' || type === 'success' || type === 'warning') return type;
if (type === 'warn') return 'warning';
return 'info';
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-radius: 10px;
background-color: var(--secondary);
color: var(--text);
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.35);
border-left: 4px solid var(--admin-color);
}
.toast p {
margin: 0;
flex: 1;
font-size: 0.9em;
word-break: break-word;
}
.toast.error {
border-left-color: var(--text-error);
}
.toast.error .toast-icon {
color: var(--text-error);
}
.toast.success {
border-left-color: var(--text-success);
}
.toast.success .toast-icon {
color: var(--text-success);
}
.toast.info .toast-icon {
color: var(--admin-color);
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(30px);
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.toasts {
left: 15px;
right: 15px;
bottom: 15px;
max-width: none;
function prefixOf(type) {
switch (typeOf(type)) {
case 'error': return "Erreur : ";
case 'success': return "Succès : ";
case 'warning': return "Attention : ";
default: return "Information : ";
}
}
</style>
</script>
+70
View File
@@ -0,0 +1,70 @@
<template>
<div class="adm-toolbar" :role="label ? 'group' : undefined" :aria-label="label || undefined">
<div class="adm-toolbar__start">
<div v-if="search !== null" class="adm-search" role="search">
<label class="sr-only" :for="inputId">{{ searchLabel }}</label>
<Icon class="adm-search__icon" icon="fa-solid fa-magnifying-glass" aria-hidden="true"/>
<input ref="inputRef" :id="inputId" class="adm-search__input" type="search" autocomplete="off"
spellcheck="false" :value="search" :placeholder="searchPlaceholder" :maxlength="searchMaxlength"
@input="$emit('update:search', $event.target.value)"/>
<IconAction v-if="search" class="adm-search__clear" icon="fa-solid fa-xmark" size="0.85em"
label="Effacer la recherche" @click="clear()"/>
</div>
<slot></slot>
</div>
<div v-if="$slots.end" class="adm-toolbar__end">
<slot name="end"></slot>
</div>
</div>
</template>
<script setup>
/**
* Barre d'outils placée juste sous l'en-tête d'une section : recherche, filtres (AdminPills), boutons.
* - `v-model:search` affiche un champ de recherche accessible (le débounce reste à la charge de la section).
* - slot par défaut : à gauche, après la recherche (filtres…) ; slot `end` : poussé à droite (boutons).
*/
import { nextTick, ref, useId } from 'vue';
import IconAction from '@/components/UI/IconAction.vue';
import './admin.scss';
defineProps({
// null = pas de champ de recherche ; une chaîne (même vide) = champ affiché (v-model:search)
search: {
type: String,
default: null
},
searchLabel: {
type: String,
default: "Rechercher"
},
searchPlaceholder: {
type: String,
default: "Rechercher…"
},
searchMaxlength: {
type: [Number, String],
default: 200
},
// Nom accessible du groupe (facultatif)
label: {
type: String,
default: null
}
});
const emit = defineEmits(['update:search']);
const inputId = `adm-search-${useId()}`;
const inputRef = ref(null);
function clear() {
emit('update:search', '');
nextTick(() => inputRef.value?.focus());
}
function focusSearch() {
inputRef.value?.focus();
}
defineExpose({ focusSearch });
</script>
+210 -251
View File
@@ -1,150 +1,179 @@
<template>
<Modal ref="modal" icon="fa-solid fa-user-gear" :title="user ? (user.global_name || user.username) : 'Compte'">
<Modal ref="modal" size="lg" icon="fa-solid fa-user-gear" :title="user ? (user.global_name || user.username || 'Compte') : 'Compte'">
<AdminState :loading="loading && !detail" :error="!detail ? error : null" :empty="!detail" empty-text="Aucun détail disponible" @retry="load()">
<ModalTree title="Identité" icon="fa-solid fa-id-card">
<div class="tree-content">
<div class="identity">
<AdminUserLine :user="detail" :online="online" size="60px"/>
<IconAction title="Rafraîchir l'identité Discord" icon="fa-solid fa-arrows-rotate" :class="{ busy: busy.refresh }" @click="refresh()"/>
<div class="detail">
<header class="head">
<AdminUserLine :user="detail" :online="online" size="56px" show-id/>
<div class="adm-tags head__tags">
<Tag v-if="detail.isAdmin" color="var(--admin-color)" icon="fa-solid fa-star">Administrateur</Tag>
<Tag v-if="detail.isFullBanned" tone="error" icon="fa-solid fa-hammer">Banni</Tag>
<Tag v-if="detail.isDeleted" tone="default" icon="fa-solid fa-user-slash">Supprimé</Tag>
<Tag :tone="online ? 'success' : 'default'" icon="fa-solid fa-signal">{{ online ? "En ligne" : "Hors ligne" }}</Tag>
</div>
<AdminKv label="ID" mono :value="detail.id"/>
<AdminKv label="Identité mise à jour" :value="detail.lastIdentityUpdate ? formatDate(detail.lastIdentityUpdate) : 'Jamais'"/>
<AdminKv label="Liaison Discord">
<Tag :color="detail.hasDiscordAuth ? 'var(--text-success)' : 'var(--text-warning)'">{{ detail.hasDiscordAuth ? "Active" : "Absente" }}</Tag>
</AdminKv>
<AdminKv label="Tokens actifs" mono :value="safeNum(detail.tokenCount)"/>
<AdminKv v-if="detail.identity?.locale" label="Langue" :value="detail.identity.locale"/>
<div class="tags">
<Tag v-for="label in detail.labels" :key="label" :color="labelColor(label)" icon="fa-solid fa-tag">{{ label }}</Tag>
</div>
</div>
</ModalTree>
<Button size="sm" variant="secondary" icon="fa-solid fa-arrows-rotate" :loading="busy.refresh"
:disabled="detail.isDeleted || !detail.hasDiscordAuth" @click="refresh()">Rafraîchir l'identité</Button>
</header>
<ModalTree title="Droits globaux" icon="fa-solid fa-star">
<div class="tree-content">
<Info v-if="isMe" secondary>Tu ne peux pas modifier tes propres droits.</Info>
<div class="switch-row">
<div>
<p class="switch-title"><Icon icon="fa-solid fa-star" style="color: var(--admin-color);"/> Administrateur</p>
<p class="hint">Accès complet à cette interface et à toutes les commandes.</p>
</div>
<Button :disabled="isMe || detail.isDeleted || busy.admin" :icon="detail.isAdmin ? 'fa-solid fa-user-minus' : 'fa-solid fa-user-plus'" @click="setAdmin(!detail.isAdmin)">
{{ detail.isAdmin ? "Retirer" : "Nommer" }}
</Button>
</div>
<div class="switch-row">
<div>
<p class="switch-title"><Icon icon="fa-solid fa-hammer" style="color: var(--text-error);"/> Bannissement global</p>
<p class="hint">{{ detail.isFullBanned ? "Ce compte est banni de Subsonics." : "Interdit l'accès à Subsonics et déconnecte toutes les sessions." }}</p>
</div>
<Button :disabled="isMe || (detail.isAdmin && !detail.isFullBanned) || busy.ban" :icon="detail.isFullBanned ? 'fa-solid fa-unlock' : 'fa-solid fa-hammer'" @click="setFullBan(!detail.isFullBanned)">
{{ detail.isFullBanned ? "Débannir" : "Bannir" }}
</Button>
</div>
</div>
</ModalTree>
<AdminBanner v-if="isMe" tone="info">C'est ton compte : tes propres droits et sessions ne sont pas modifiables ici.</AdminBanner>
<AdminBanner v-if="detail.isFullBanned" tone="error" title="Compte banni de Subsonics">
{{ detail.banReason ? "Raison : " + detail.banReason : "Aucune raison indiquée." }}
<span v-if="detail.bannedAt"> · {{ formatDate(detail.bannedAt) }}</span>
</AdminBanner>
<ModalTree title="Serveurs" icon="fa-solid fa-server">
<div class="tree-content">
<Info v-if="!detail.guilds?.length" secondary>Aucun serveur en commun avec Subsonics</Info>
<div class="list">
<div v-for="guild in detail.guilds" :key="guild.id" class="row">
<div class="row-main">
<ServerAvatar class="small-avatar" :src="guild.icon" :server-id="guild.id"/>
<div class="row-info">
<p class="row-title">{{ guild.name }}</p>
<div class="tags">
<Tag v-if="guild.isOwner" color="var(--owner-color)">Propriétaire</Tag>
<Tag v-if="guild.isMod" color="var(--mod-color)">Modérateur</Tag>
<Tag v-if="guild.isBanned" color="var(--text-error)">Banni</Tag>
<Tag v-if="detail.currentGuildId === guild.id" color="var(--text-success)">Actif</Tag>
</div>
</div>
<AdminStatRow :columns="4" label="Données du compte">
<AdminStat label="Playlists" :value="safeNum(detail.playlistCount ?? detail.playlists?.length)" icon="fa-solid fa-list" tone="main"/>
<AdminStat label="Historique" :value="safeNum(detail.historyCount ?? detail.history?.length)" icon="fa-solid fa-clock-rotate-left" tone="info"/>
<AdminStat label="Médias" :value="safeNum(detail.mediaCount ?? detail.media?.length)" icon="fa-solid fa-photo-film" tone="warning"/>
<AdminStat label="Sessions" :value="safeNum(detail.tokenCount)" icon="fa-solid fa-key" tone="admin"
:sub="(detail.sockets?.length || 0) + ' connexion(s) ouverte(s)'"/>
</AdminStatRow>
<AdminGrid>
<AdminCard title="Identité" icon="fa-solid fa-id-card" tone="admin" :span="6" :span-sm="12">
<AdminKv label="ID" mono :value="detail.id"/>
<AdminKv label="Identité mise à jour" :value="detail.lastIdentityUpdate ? formatDate(detail.lastIdentityUpdate) : 'Jamais'"/>
<AdminKv label="Liaison Discord">
<Tag :tone="detail.hasDiscordAuth ? 'success' : 'warning'">{{ detail.hasDiscordAuth ? "Active" : "Absente" }}</Tag>
</AdminKv>
<AdminKv v-if="detail.identity?.locale" label="Langue" :value="detail.identity.locale"/>
<div v-if="detail.labels?.length" class="adm-chips" aria-label="Labels du compte">
<span v-for="label in detail.labels" :key="label" class="adm-chip" :style="{ borderColor: labelColor(label) }">{{ label }}</span>
</div>
</AdminCard>
<AdminCard title="Droits globaux" icon="fa-solid fa-star" tone="info" :span="6" :span-sm="12">
<AdminField inline plain label="Administrateur" hint="Accès complet à cette interface et à toutes les commandes." v-slot="{ describedby }">
<Button size="sm" :aria-describedby="describedby" :variant="detail.isAdmin ? 'secondary' : 'primary'" :loading="busy.admin"
:disabled="isMe || detail.isDeleted" :icon="detail.isAdmin ? 'fa-solid fa-user-minus' : 'fa-solid fa-user-plus'"
:aria-label="detail.isAdmin ? 'Retirer les droits administrateur' : 'Nommer administrateur'" @click="setAdmin(!detail.isAdmin)">
{{ detail.isAdmin ? "Retirer" : "Nommer" }}
</Button>
</AdminField>
<hr class="adm-divider"/>
<AdminField inline plain label="Bannissement global"
:hint="detail.isFullBanned ? 'Ce compte est banni de Subsonics.' : 'Interdit l\'accès à Subsonics et ferme toutes les sessions.'" v-slot="{ describedby }">
<Button size="sm" :aria-describedby="describedby" :variant="detail.isFullBanned ? 'secondary' : 'danger'" :loading="busy.ban"
:disabled="isMe || (detail.isAdmin && !detail.isFullBanned)" :icon="detail.isFullBanned ? 'fa-solid fa-unlock' : 'fa-solid fa-hammer'"
:aria-label="detail.isFullBanned ? 'Débannir de Subsonics' : 'Bannir de Subsonics'" @click="setFullBan(!detail.isFullBanned)">
{{ detail.isFullBanned ? "Débannir" : "Bannir" }}
</Button>
</AdminField>
<p v-if="detail.isAdmin && !detail.isFullBanned && !isMe" class="adm-hint">Un administrateur ne peut pas être banni : retire d'abord ses droits.</p>
</AdminCard>
<AdminCard title="Serveurs" icon="fa-solid fa-server" tone="warning" :span="12" flush
:subtitle="(detail.guilds?.length || 0) + ' serveur(s) en commun avec Subsonics'">
<AdminState :empty="!detail.guilds?.length" :retry="false" empty-icon="fa-solid fa-server" empty-text="Aucun serveur en commun avec Subsonics">
<div class="adm-scroll guilds">
<AdminList divided label="Serveurs du compte">
<AdminRow v-for="guild in detail.guilds" :key="guild.id" dense :title="guild.name || guild.id">
<template #media>
<ServerAvatar class="adm-avatar adm-avatar--sm" :src="guild.icon" :server-id="guild.id" alt=""/>
</template>
<template #meta>
<Tag v-if="detail.currentGuildId === guild.id" tone="success" icon="fa-solid fa-location-dot">Actif</Tag>
<Tag v-if="guild.isOwner" color="var(--owner-color)" icon="fa-solid fa-crown">Propriétaire</Tag>
<Tag v-if="guild.isMod" color="var(--mod-color)" icon="fa-solid fa-user-shield">Modérateur</Tag>
<Tag v-if="guild.isBanned" tone="error" icon="fa-solid fa-ban">Banni</Tag>
</template>
<template v-if="!isMe && !detail.isAdmin" #actions>
<Button size="sm" variant="secondary" icon="fa-solid fa-user-shield" :aria-pressed="guild.isMod ? 'true' : 'false'"
:disabled="guildBusy.has(guild.id) || guild.isBanned || detail.isDeleted"
:aria-label="(guild.isMod ? 'Retirer le rôle de modérateur sur ' : 'Nommer modérateur sur ') + (guild.name || guild.id)"
@click="toggleGuild(guild, 'mod')">{{ guild.isMod ? "Retirer modo" : "Modérateur" }}</Button>
<Button size="sm" :variant="guild.isBanned ? 'secondary' : 'danger'" :icon="guild.isBanned ? 'fa-solid fa-unlock' : 'fa-solid fa-ban'"
:disabled="guildBusy.has(guild.id)"
:aria-label="(guild.isBanned ? 'Débannir de ' : 'Bannir de ') + (guild.name || guild.id)"
@click="toggleGuild(guild, 'ban')">{{ guild.isBanned ? "Débannir" : "Bannir" }}</Button>
</template>
</AdminRow>
</AdminList>
</div>
<div v-if="!isMe && !detail.isAdmin" class="actions">
<IconAction title="Bannir / Débannir de ce serveur" icon="fa-solid fa-hammer"
:color="guild.isBanned ? 'var(--text-error)' : 'var(--text-tertiary)'" @click="toggleGuild(guild, 'ban')"/>
<IconAction title="Nommer / Retirer modérateur" icon="fa-solid fa-user-shield"
:color="guild.isMod ? 'var(--mod-color)' : 'var(--text-tertiary)'" @click="toggleGuild(guild, 'mod')"/>
</AdminState>
</AdminCard>
<AdminCard title="Sessions ouvertes" icon="fa-solid fa-plug" tone="success" :span="6" :span-sm="12" flush
:subtitle="(detail.sockets?.length || 0) + ' connexion(s) · ' + safeNum(detail.tokenCount) + ' token(s)'">
<AdminState :empty="!detail.sockets?.length" :retry="false" empty-icon="fa-solid fa-plug-circle-xmark" empty-text="Aucune session ouverte">
<div class="adm-scroll sessions">
<AdminList divided label="Sessions ouvertes">
<AdminRow v-for="socketInfo in detail.sockets" :key="socketInfo.socketId" dense
:title="socketInfo.address || 'Adresse inconnue'" :subtitle="socketInfo.userAgent || 'Navigateur inconnu'">
<template #meta>
<span>{{ formatRelative(socketInfo.connectedAt) }}</span>
<span v-if="socketInfo.guildId">· {{ guildName(socketInfo.guildId) }}</span>
</template>
</AdminRow>
</AdminList>
</div>
</div>
</div>
</div>
</ModalTree>
</AdminState>
<template #footer>
<Button size="sm" variant="secondary" icon="fa-solid fa-plug-circle-xmark" :disabled="isMe || busy.disconnect" @click="disconnect(false)">Déconnecter</Button>
<Button size="sm" variant="danger" icon="fa-solid fa-key" :disabled="isMe || busy.disconnect" @click="disconnect(true)">Déconnecter et révoquer</Button>
</template>
</AdminCard>
<ModalTree title="Sessions" icon="fa-solid fa-plug">
<div class="tree-content">
<Info v-if="!detail.sockets?.length" secondary>Aucune session ouverte</Info>
<div class="list">
<div v-for="socketInfo in detail.sockets" :key="socketInfo.socketId" class="row column">
<AdminKv label="Adresse" mono :value="socketInfo.address || 'Inconnue'"/>
<AdminKv label="Navigateur" :value="socketInfo.userAgent || 'Inconnu'"/>
<AdminKv label="Connecté" :value="formatRelative(socketInfo.connectedAt)"/>
<AdminKv label="Serveur" mono :value="guildName(socketInfo.guildId)"/>
</div>
</div>
<div class="buttons">
<Button :disabled="isMe || busy.disconnect" icon="fa-solid fa-plug-circle-xmark" @click="disconnect(false)">Déconnecter</Button>
<Button :disabled="isMe || busy.disconnect" icon="fa-solid fa-key" @click="disconnect(true)">Déconnecter et révoquer les tokens</Button>
</div>
</div>
</ModalTree>
<AdminCard title="Données" icon="fa-solid fa-database" tone="main" :span="6" :span-sm="12" flush
subtitle="Playlists, médias et dernières écoutes">
<AdminState :empty="!hasData" :retry="false" empty-icon="fa-solid fa-box-open" empty-text="Aucune donnée enregistrée">
<div class="adm-scroll data">
<AdminList divided label="Données du compte">
<AdminRow v-for="playlist in detail.playlists" :key="'p' + playlist.playlistId" dense :title="playlist.title || 'Sans titre'">
<template #media><span class="adm-cover data__icon"><Icon icon="fa-solid fa-list" aria-hidden="true"/></span></template>
<template #meta>{{ safeNum(playlist.songCount) }} titre(s)<span v-if="playlist.type"> · {{ playlist.type }}</span></template>
</AdminRow>
<AdminRow v-for="media in detail.media" :key="'m' + media.id" dense>
<template #media><span class="adm-cover data__icon"><Icon icon="fa-solid fa-file-audio" aria-hidden="true"/></span></template>
<template #title>
<a v-if="safeUrl(media.url)" :href="media.url" target="_blank" rel="noopener noreferrer">{{ media.name || "Média" }}<span class="sr-only"> (nouvel onglet)</span></a>
<template v-else>{{ media.name || "Média" }}</template>
</template>
<template #meta>{{ formatBytes(media.size) }}</template>
</AdminRow>
<AdminRow v-for="(song, index) in recentHistory" :key="'h' + (song.id || '') + index" dense
:title="song.title || 'Titre inconnu'" :subtitle="song.author || 'Auteur inconnu'">
<template #media><span class="adm-cover data__icon"><Icon icon="fa-solid fa-clock-rotate-left" aria-hidden="true"/></span></template>
</AdminRow>
</AdminList>
</div>
</AdminState>
</AdminCard>
<ModalTree title="Données" icon="fa-solid fa-database">
<div class="tree-content">
<div class="stats">
<AdminStat label="Playlists" :value="safeNum(detail.playlistCount ?? detail.playlists?.length)" icon="fa-solid fa-list"/>
<AdminStat label="Historique" :value="safeNum(detail.historyCount ?? detail.history?.length)" icon="fa-solid fa-clock-rotate-left"/>
<AdminStat label="Médias" :value="safeNum(detail.mediaCount ?? detail.media?.length)" icon="fa-solid fa-photo-film"/>
</div>
<div v-if="detail.playlists?.length" class="list">
<div v-for="playlist in detail.playlists" :key="playlist.playlistId" class="row">
<span><Icon icon="fa-solid fa-list"/> {{ playlist.title }}</span>
<span class="row-side">{{ safeNum(playlist.songCount) }} titre(s) · {{ playlist.type }}</span>
</div>
</div>
<div v-if="detail.media?.length" class="list">
<div v-for="media in detail.media" :key="media.id" class="row">
<a v-if="media.url" :href="media.url" target="_blank" rel="noopener"><Icon icon="fa-solid fa-file-audio"/> {{ media.name }}</a>
<span v-else><Icon icon="fa-solid fa-file-audio"/> {{ media.name }}</span>
<span class="row-side">{{ formatBytes(media.size) }}</span>
</div>
</div>
<div v-if="detail.history?.length" class="list">
<p class="hint">Dernières écoutes</p>
<div v-for="(song, index) in detail.history.slice(0, 10)" :key="(song.id || '') + index" class="row">
<span class="ellipsis">{{ song.title }}</span>
<span class="row-side">{{ song.author }}</span>
</div>
</div>
</div>
</ModalTree>
<ModalTree title="Zone dangereuse" icon="fa-solid fa-triangle-exclamation">
<div class="tree-content">
<p class="hint">La suppression conserve les bannissements mais efface le compte. Les données (playlists, historique, médias) peuvent être purgées.</p>
<Button :disabled="isMe || detail.isAdmin || detail.isDeleted || busy.delete" icon="fa-solid fa-trash" @click="remove()">Supprimer le compte</Button>
</div>
</ModalTree>
<AdminCard title="Zone dangereuse" icon="fa-solid fa-triangle-exclamation" tone="error" :span="12">
<p class="adm-text">La suppression conserve les bannissements mais efface le compte. Les données (playlists, historique, médias) peuvent être purgées.</p>
<template #footer>
<Button variant="danger" icon="fa-solid fa-trash" :loading="busy.delete" :disabled="isMe || detail.isAdmin || detail.isDeleted" @click="remove()">Supprimer le compte</Button>
</template>
</AdminCard>
</AdminGrid>
</div>
</AdminState>
</Modal>
<AdminConfirm ref="confirmRef"/>
</template>
<script setup>
/**
* Fiche d'un compte : identité, droits globaux, serveurs (modération / bannissement), sessions, données, suppression.
* open(user) avec au moins { id } ; émet `changed` (UserSummary, ou null pour recharger la liste).
*/
import { computed, reactive, ref } from 'vue';
import { useAdminStore } from '@/stores/adminStore';
import { useUserStore } from '@/stores/userStore';
import { adminRequest, describeError } from '@/utils/AdminRequest';
import Modal from '@/components/UI/Modal.vue';
import ModalTree from '@/components/UI/ModalTree.vue';
import Button from '@/components/UI/Button.vue';
import Tag from '@/components/UI/Tag.vue';
import Info from '@/components/UI/Info.vue';
import IconAction from '@/components/UI/IconAction.vue';
import ServerAvatar from '@/components/UI/ServerAvatar.vue';
import AdminState from './AdminState.vue';
import AdminBanner from './AdminBanner.vue';
import AdminGrid from './AdminGrid.vue';
import AdminCard from './AdminCard.vue';
import AdminKv from './AdminKv.vue';
import AdminField from './AdminField.vue';
import AdminStat from './AdminStat.vue';
import AdminStatRow from './AdminStatRow.vue';
import AdminList from './AdminList.vue';
import AdminRow from './AdminRow.vue';
import AdminUserLine from './AdminUserLine.vue';
import AdminConfirm from './AdminConfirm.vue';
import { safeNum, formatDate, formatRelative, formatBytes } from './adminFormat';
@@ -161,17 +190,19 @@ const detail = ref(null);
const loading = ref(false);
const error = ref(null);
const busy = reactive({ refresh: false, admin: false, ban: false, disconnect: false, delete: false });
const guildBusy = ref(new Set());
const isMe = computed(() => detail.value?.id === userStore.userInfo?.identity?.id);
const online = computed(() => adminStore.presence ? adminStore.onlineIds.has(detail.value?.id) : !!detail.value?.online);
const online = computed(() => (adminStore.presence ? adminStore.onlineIds.has(detail.value?.id) : !!detail.value?.online));
const displayName = computed(() => detail.value?.global_name || detail.value?.username || detail.value?.id);
const recentHistory = computed(() => (detail.value?.history || []).slice(0, 10));
const hasData = computed(() => !!(detail.value?.playlists?.length || detail.value?.media?.length || detail.value?.history?.length));
function labelColor(label) {
if (label === "ADMIN") return "var(--admin-color)";
if (label === "BAN" || label.startsWith("BAN_")) return "var(--text-error)";
if (label.startsWith("MOD_")) return "var(--mod-color)";
if (label === "DELETED") return "var(--text-secondary)";
return "var(--quaternary)";
return null;
}
function guildName(guildId) {
@@ -179,6 +210,10 @@ function guildName(guildId) {
return detail.value?.guilds?.find(g => g.id === guildId)?.name || guildId;
}
function safeUrl(url) {
return typeof url === 'string' && /^https?:\/\//i.test(url);
}
async function load() {
if (!user.value || loading.value) return;
loading.value = true;
@@ -196,6 +231,7 @@ function open(target) {
user.value = target;
detail.value = null;
error.value = null;
guildBusy.value = new Set();
modal.value?.open();
load();
}
@@ -251,7 +287,7 @@ async function setFullBan(value) {
icon: "fa-solid fa-hammer",
message: `${displayName.value} sera banni de Subsonics et toutes ses sessions seront fermées.`,
askReason: true,
reasonLabel: "Raison du bannissement (optionnelle)",
reasonLabel: "Raison du bannissement (facultative)",
reasonMin: 0,
reasonMax: 200,
confirmLabel: "Bannir",
@@ -271,12 +307,27 @@ async function toggleGuild(guild, kind) {
const event = kind === 'ban' ? "/ADMIN/USERS/SET_GUILD_BAN" : "/ADMIN/USERS/SET_GUILD_MOD";
const field = kind === 'ban' ? 'isBanned' : 'isMod';
const value = !guild[field];
if (kind === 'ban' && value) {
const ok = await confirmRef.value.ask({
title: "Bannir du serveur",
icon: "fa-solid fa-ban",
message: `${displayName.value} ne pourra plus utiliser Subsonics sur « ${guild.name || guild.id} ».`,
confirmLabel: "Bannir",
confirmIcon: "fa-solid fa-ban"
});
if (!ok) return;
}
guildBusy.value = new Set(guildBusy.value).add(guild.id);
try {
applySummary(await adminRequest(event, { userId: detail.value.id, guildId: guild.id, value }));
detail.value = { ...detail.value, guilds: detail.value.guilds.map(g => g.id === guild.id ? { ...g, [field]: value } : g) };
adminStore.notify('success', `${guild.name} : ${kind === 'ban' ? (value ? "banni" : "débanni") : (value ? "nommé modérateur" : "n'est plus modérateur")}`);
adminStore.notify('success', `${guild.name || guild.id} : ${kind === 'ban' ? (value ? "banni" : "débanni") : (value ? "nommé modérateur" : "n'est plus modérateur")}`);
} catch (e) {
adminStore.notifyError(e, kind === 'ban' ? "Bannissement du serveur" : "Modération");
} finally {
const next = new Set(guildBusy.value);
next.delete(guild.id);
guildBusy.value = next;
}
}
@@ -325,144 +376,52 @@ async function remove() {
defineExpose({ open });
</script>
<style scoped>
.tree-content {
display: flex;
flex-direction: column;
gap: 10px;
margin: 10px 0 15px 0;
}
p {
margin: 0;
}
.identity {
.detail {
display: flex;
flex-direction: column;
gap: var(--adm-gap);
min-width: 0;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.busy {
opacity: 0.5;
pointer-events: none;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
gap: 12px;
}
.hint {
.head :deep(.adm-user__name) {
font-size: 1.1em;
}
.head__tags {
flex: 1 1 auto;
}
.guilds {
--adm-scroll-max: 320px;
}
.sessions,
.data {
--adm-scroll-max: 300px;
}
.data__icon {
--adm-cover-size: 30px;
font-size: 0.85em;
color: var(--text-secondary);
}
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
background-color: var(--tertiary);
border-radius: 10px;
padding: 10px;
}
.switch-title {
font-weight: 600;
display: flex;
align-items: center;
gap: 5px;
}
.list {
display: flex;
flex-direction: column;
gap: 5px;
max-height: 30vh;
overflow-y: auto;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background-color: var(--tertiary);
border-radius: 10px;
padding: 7px 10px;
font-size: 0.9em;
}
.row.column {
flex-direction: column;
align-items: stretch;
gap: 3px;
}
.row a {
.adm-row a {
color: var(--text);
}
.row-main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.row-info {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.row-title {
font-weight: 600;
}
.row-side {
color: var(--text-secondary);
font-size: 0.85em;
text-align: right;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.small-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
}
.buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.switch-row {
flex-direction: column;
align-items: stretch;
}
.adm-row a:hover {
color: var(--main);
}
</style>
+30 -84
View File
@@ -1,23 +1,30 @@
<template>
<div class="user-card">
<div class="avatar">
<img :src="userAvatar(user)" :style="{ width: size, height: size }" alt="Avatar"/>
<Icon v-if="user?.isAdmin" style="color: var(--admin-color);" icon="fa-solid fa-star" class="tag"/>
<Icon v-else-if="user?.isOwner" style="color: var(--owner-color);" icon="fa-solid fa-crown" class="tag"/>
<Icon v-else-if="user?.isMod" style="color: var(--mod-color);" icon="fa-solid fa-shield-halved" class="tag"/>
<span v-if="online" class="online-dot" title="En ligne"></span>
</div>
<div class="user-info">
<p class="global">{{ user?.global_name || user?.username || "Nom d'affichage inconnu" }}</p>
<p class="username">{{ user?.username || "Identifiant inconnu" }}<span v-if="showId" class="id"> · {{ user?.id }}</span></p>
</div>
<div class="adm-user">
<span class="adm-user__avatar" :style="{ '--adm-avatar-size': size }">
<!-- Avatar décoratif : le nom est écrit juste à côté -->
<img :src="userAvatar(user)" alt="" loading="lazy" referrerpolicy="no-referrer"/>
<span v-if="role" class="adm-user__role" :style="{ color: role.color }" :title="role.label">
<Icon :icon="role.icon" aria-hidden="true"/>
</span>
<span v-if="online" class="adm-user__online" title="En ligne"></span>
</span>
<span class="adm-user__info">
<span class="adm-user__name">{{ user?.global_name || user?.username || "Nom d'affichage inconnu" }}</span>
<span class="adm-user__handle">
<span>{{ user?.username ? '@' + user.username : "Identifiant inconnu" }}</span>
<span v-if="showId" class="adm-mono"> · {{ user?.id }}</span>
</span>
<span v-if="role || online" class="sr-only">{{ [role?.label, online ? "en ligne" : null].filter(Boolean).join(", ") }}</span>
</span>
</div>
</template>
<script setup>
// Variante de UI/User.vue pour une identité brute (UserLite) avec repli d'avatar et pastille « en ligne »
// Identité brute (UserLite) : avatar avec repli, pastille de rôle et « en ligne », nom + identifiant
import { computed } from 'vue';
import { userAvatar } from './adminFormat';
import './admin.scss';
defineProps({
const props = defineProps({
user: {
type: Object,
required: true
@@ -30,78 +37,17 @@ defineProps({
type: Boolean,
default: false
},
// Taille CSS de l'avatar
size: {
type: String,
default: "50px"
default: "40px"
}
});
const role = computed(() => {
if (props.user?.isAdmin) return { label: "Administrateur", icon: "fa-solid fa-star", color: "var(--admin-color)" };
if (props.user?.isOwner) return { label: "Propriétaire", icon: "fa-solid fa-crown", color: "var(--owner-color)" };
if (props.user?.isMod) return { label: "Modérateur", icon: "fa-solid fa-shield-halved", color: "var(--mod-color)" };
return null;
});
</script>
<style scoped>
.user-card {
display: flex;
align-items: center;
gap: 10px;
user-select: none;
min-width: 0;
}
.avatar {
position: relative;
display: inline-block;
flex-shrink: 0;
}
img {
border-radius: 100%;
object-fit: cover;
background-color: var(--main);
display: block;
}
.tag {
position: absolute;
bottom: 0;
right: 0;
font-size: 0.8em;
border-radius: 50%;
}
.online-dot {
position: absolute;
top: 0;
right: 0;
width: 10px;
height: 10px;
border-radius: 100%;
background-color: var(--mod-color);
border: 2px solid var(--tertiary);
}
.user-info {
display: flex;
gap: 5px;
flex-direction: column;
min-width: 0;
}
p {
margin: 0;
}
.global {
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.username {
color: var(--text-secondary);
font-size: 12px;
word-break: break-all;
}
.id {
font-family: monospace;
}
</style>
+88 -136
View File
@@ -1,59 +1,81 @@
<template>
<AdminSection title="👥 Comptes" :subtitle="subtitle">
<template #actions>
<input type="text" class="search" v-model="search" placeholder="Rechercher (ID, identifiant, nom)"/>
<Button icon="fa-solid fa-rotate" :disabled="loading" @click="load()">Actualiser</Button>
</template>
<AdminSection title="Comptes" subtitle="Comptes Subsonics : droits, bannissements, sessions et données">
<AdminStatRow :columns="5" label="Résumé des comptes">
<AdminStat label="Inscrits" :value="counts.registered" icon="fa-solid fa-id-card" tone="admin"/>
<AdminStat label="En ligne" :value="counts.online" icon="fa-solid fa-signal" tone="success"
:sub="counts.sockets !== null ? counts.sockets + ' connexion(s) web' : null"/>
<AdminStat label="Administrateurs" :value="counts.admins" icon="fa-solid fa-star" tone="info"/>
<AdminStat label="Bannis" :value="counts.banned" icon="fa-solid fa-hammer" :tone="counts.banned ? 'warning' : 'default'"/>
<AdminStat label="Supprimés" :value="counts.deleted" icon="fa-solid fa-user-slash"/>
</AdminStatRow>
<AdminPills v-model="filter" :options="filters"/>
<AdminToolbar v-model:search="search" search-label="Rechercher un compte" search-placeholder="ID, identifiant ou nom…" label="Filtres des comptes">
<AdminPills v-model="filter" :options="filters" label="Catégorie"/>
<template #end>
<Button variant="secondary" icon="fa-solid fa-rotate" :loading="loading" @click="load()">Actualiser</Button>
</template>
</AdminToolbar>
<p class="sr-only" aria-live="polite">{{ liveSummary }}</p>
<!-- Tant que la 1re réponse n'est pas arrivée (même avant onMounted), on affiche le chargement -->
<AdminState :loading="!result && !error" :error="!result ? error : null"
:empty="!!result && items.length === 0"
:empty-text="search ? 'Aucun compte ne correspond à cette recherche' : 'Aucun compte dans cette catégorie'"
@retry="load()">
<div class="users-list">
<div v-for="user in items" :key="user.id" class="user">
<div class="group">
<AdminUserLine :user="user" :online="isOnline(user)" show-id/>
<div class="tags">
<Tag v-if="user.isAdmin" color="var(--admin-color)" icon="fa-solid fa-star">Administrateur</Tag>
<Tag v-if="user.isFullBanned" color="var(--text-error)" icon="fa-solid fa-hammer">Banni</Tag>
<Tag v-if="user.isDeleted" color="var(--text-secondary)" icon="fa-solid fa-user-slash">Supprimé</Tag>
<Tag v-if="isOnline(user)" color="var(--text-success)">En ligne<span v-if="user.socketCount > 1"> ({{ user.socketCount }})</span></Tag>
<Tag v-if="user.modGuilds?.length" color="var(--mod-color)" icon="fa-solid fa-user-shield">Modo ×{{ user.modGuilds.length }}</Tag>
<Tag v-if="user.bannedGuilds?.length" color="var(--text-warning)" icon="fa-solid fa-ban">Banni ×{{ user.bannedGuilds.length }}</Tag>
</div>
</div>
<div class="side">
<span class="side-info" :title="'Tokens actifs : ' + safeNum(user.tokenCount)"><Icon icon="fa-solid fa-key"/> {{ safeNum(user.tokenCount) }}</span>
<span class="side-info" title="Serveurs communs"><Icon icon="fa-solid fa-server"/> {{ user.guildIds?.length || 0 }}</span>
<Button color-lower icon="fa-solid fa-magnifying-glass" @click="detail.open(user)">Détails</Button>
</div>
</div>
</div>
<div class="pagination">
<IconAction title="Page précédente" icon="fa-solid fa-chevron-left" :color="page > 1 ? 'var(--text)' : 'var(--text-tertiary)'" @click="goTo(page - 1)"/>
<span class="page-info">Page {{ page }} / {{ pageCount }} · {{ safeNum(result?.total) }} compte(s)</span>
<IconAction title="Page suivante" icon="fa-solid fa-chevron-right" :color="page < pageCount ? 'var(--text)' : 'var(--text-tertiary)'" @click="goTo(page + 1)"/>
<Icon v-if="loading" icon="fa-solid fa-spinner" spin-pulse class="page-loading"/>
</div>
<AdminState :loading="!result && !error" :error="!result ? error : null" @retry="load()">
<AdminCard title="Liste des comptes" icon="fa-solid fa-users" :span="12" flush :busy="loading"
:subtitle="safeNum(result?.total) + ' compte(s) · page ' + page + ' sur ' + pageCount">
<AdminState :empty="items.length === 0" :retry="false" :empty-icon="search ? 'fa-solid fa-magnifying-glass' : 'fa-solid fa-user-slash'"
:empty-text="search ? 'Aucun compte ne correspond à cette recherche' : 'Aucun compte dans cette catégorie'">
<AdminList divided label="Comptes">
<AdminRow v-for="user in items" :key="user.id" :tone="user.isFullBanned ? 'error' : 'default'">
<AdminUserLine :user="user" :online="isOnline(user)" show-id/>
<div v-if="hasTags(user)" class="adm-tags">
<Tag v-if="user.isAdmin" color="var(--admin-color)" icon="fa-solid fa-star">Administrateur</Tag>
<Tag v-if="user.isFullBanned" tone="error" icon="fa-solid fa-hammer">Banni</Tag>
<Tag v-if="user.isDeleted" tone="default" icon="fa-solid fa-user-slash">Supprimé</Tag>
<Tag v-if="isOnline(user)" tone="success" icon="fa-solid fa-signal">En ligne<span v-if="user.socketCount > 1">&nbsp;({{ user.socketCount }})</span></Tag>
<Tag v-if="user.modGuilds?.length" color="var(--mod-color)" icon="fa-solid fa-user-shield">Modo ×{{ user.modGuilds.length }}</Tag>
<Tag v-if="user.bannedGuilds?.length" tone="warning" icon="fa-solid fa-ban">Banni ×{{ user.bannedGuilds.length }}</Tag>
</div>
<template #meta>
<span :title="'Sessions actives : ' + safeNum(user.tokenCount)"><Icon icon="fa-solid fa-key" aria-hidden="true"/> <span class="sr-only">Sessions :</span> {{ safeNum(user.tokenCount) }}</span>
<span title="Serveurs en commun"><Icon icon="fa-solid fa-server" aria-hidden="true"/> <span class="sr-only">Serveurs :</span> {{ user.guildIds?.length || 0 }}</span>
<span title="Playlists"><Icon icon="fa-solid fa-list" aria-hidden="true"/> <span class="sr-only">Playlists :</span> {{ safeNum(user.playlistCount) }}</span>
</template>
<template #actions>
<Button size="sm" variant="secondary" icon="fa-solid fa-user-gear"
:aria-label="'Gérer le compte de ' + (user.global_name || user.username || user.id)" @click="detail.open(user)">Gérer</Button>
</template>
</AdminRow>
</AdminList>
</AdminState>
<template v-if="pageCount > 1" #footer>
<nav class="adm-pager" aria-label="Pagination des comptes">
<Button size="sm" variant="ghost" icon="fa-solid fa-chevron-left" :disabled="page <= 1 || loading" @click="goTo(page - 1)">Précédente</Button>
<span class="adm-pager__info">Page {{ page }} / {{ pageCount }}</span>
<Button size="sm" variant="ghost" icon-right="fa-solid fa-chevron-right" :disabled="page >= pageCount || loading" @click="goTo(page + 1)">Suivante</Button>
</nav>
</template>
</AdminCard>
</AdminState>
<AdminUserDetail ref="detail" @changed="onChanged"/>
</AdminSection>
</template>
<script setup>
// Section « Comptes » : chiffres clés, recherche serveur (débouncée), filtres, liste paginée et fiche détaillée
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useAdminStore } from '@/stores/adminStore';
import { adminRequest, describeError } from '@/utils/AdminRequest';
import Button from '@/components/UI/Button.vue';
import Tag from '@/components/UI/Tag.vue';
import IconAction from '@/components/UI/IconAction.vue';
import AdminSection from './AdminSection.vue';
import AdminState from './AdminState.vue';
import AdminStat from './AdminStat.vue';
import AdminStatRow from './AdminStatRow.vue';
import AdminToolbar from './AdminToolbar.vue';
import AdminPills from './AdminPills.vue';
import AdminCard from './AdminCard.vue';
import AdminList from './AdminList.vue';
import AdminRow from './AdminRow.vue';
import AdminUserLine from './AdminUserLine.vue';
import AdminUserDetail from './AdminUserDetail.vue';
import { safeNum } from './adminFormat';
@@ -72,29 +94,43 @@ const error = ref(null);
const filters = [
{ value: 'all', label: "Tous" },
{ value: 'online', label: "En ligne", icon: "fa-solid fa-circle", color: "color-mix(in srgb, var(--mod-color) 60%, black)" },
{ value: 'online', label: "En ligne", icon: "fa-solid fa-signal" },
{ value: 'admin', label: "Administrateurs", icon: "fa-solid fa-star", color: "var(--admin-color)" },
{ value: 'banned', label: "Bannis", icon: "fa-solid fa-hammer" },
{ value: 'deleted', label: "Supprimés", icon: "fa-solid fa-user-slash", color: "var(--quaternary)" }
{ value: 'deleted', label: "Supprimés", icon: "fa-solid fa-user-slash" }
];
// Réponse inattendue (data nulle, items absent) : liste vide plutôt qu'une erreur de rendu
const items = computed(() => Array.isArray(result.value?.items) ? result.value.items : []);
const items = computed(() => (Array.isArray(result.value?.items) ? result.value.items : []));
const pageCount = computed(() => Math.max(1, Math.ceil(safeNum(result.value?.total) / safeNum(result.value?.pageSize, PAGE_SIZE))));
const subtitle = computed(() => {
const counts = adminStore.presence?.counts;
if (counts) return `${safeNum(counts.registered)} compte(s) inscrit(s), ${safeNum(counts.online)} en ligne`;
return "Gestion des comptes Subsonics";
// Chiffres : présence poussée en direct, sinon le résumé de la vue d'ensemble
const counts = computed(() => {
const overview = adminStore.overview;
const presence = adminStore.presence?.counts;
return {
registered: safeNum(presence?.registered ?? overview?.users?.registered),
online: safeNum(presence?.online ?? overview?.web?.usersOnline),
sockets: overview?.web ? safeNum(overview.web.sockets) : null,
admins: safeNum(overview?.users?.admins),
banned: safeNum(overview?.users?.fullBanned),
deleted: safeNum(overview?.users?.deleted)
};
});
const liveSummary = computed(() => (result.value ? `${safeNum(result.value.total)} compte(s) trouvé(s), page ${page.value} sur ${pageCount.value}` : ""));
// La présence poussée par "/ADMIN/USERS/UPDATE" prime sur l'instantané de la liste
function isOnline(user) {
if (adminStore.presence) return adminStore.onlineIds.has(user.id);
return !!user.online;
}
function hasTags(user) {
return user.isAdmin || user.isFullBanned || user.isDeleted || isOnline(user) || user.modGuilds?.length || user.bannedGuilds?.length;
}
// Une seule requête à la fois ; si les critères changent pendant le chargement, on relance à la fin
let queued = false;
async function load() {
@@ -109,6 +145,8 @@ async function load() {
});
result.value = data && typeof data === "object" ? data : { items: [], total: 0, page: page.value, pageSize: PAGE_SIZE };
error.value = null;
// La page demandée n'existe plus (comptes supprimés entre-temps) : on revient à la dernière
if (page.value > pageCount.value) { page.value = pageCount.value; queued = true; }
} catch (e) {
error.value = describeError(e);
if (result.value) adminStore.notifyError(e, "Liste des comptes");
@@ -146,96 +184,10 @@ function onChanged(summary) {
};
}
onMounted(() => load());
onMounted(() => {
load();
// Chiffres clés : la vue d'ensemble est déjà chargée par le tableau de bord, sinon on la demande
if (!adminStore.overview) adminStore.fetchOverview();
});
onUnmounted(() => clearTimeout(searchTimer));
</script>
<style scoped>
.search {
min-width: 260px;
}
.users-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.user {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background-color: var(--tertiary);
padding: 5px 10px 5px 5px;
border-radius: 10px;
}
.group {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.side {
display: flex;
align-items: center;
gap: 15px;
flex-shrink: 0;
}
.side-info {
color: var(--text-secondary);
font-size: 0.85em;
font-family: monospace;
display: flex;
align-items: center;
gap: 5px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
margin-top: 5px;
}
.page-info {
font-size: 0.85em;
color: var(--text-secondary);
}
.page-loading {
color: var(--text-secondary);
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.search {
min-width: 0;
flex: 1;
}
.user {
flex-direction: column;
align-items: stretch;
padding: 10px;
}
.group {
flex-direction: column;
align-items: flex-start;
}
.side {
justify-content: space-between;
}
}
</style>
+150 -178
View File
@@ -1,7 +1,8 @@
<template>
<AdminSection title="☁️ WARP" subtitle="Proxy Cloudflare WARP utilisé par yt-dlp pour contourner les blocages">
<template #actions>
<Button icon="fa-solid fa-stethoscope" :disabled="adminStore.warpChecking || (warp && !warp.configured)" @click="adminStore.checkWarp()">
<Button variant="secondary" icon="fa-solid fa-stethoscope" :loading="adminStore.warpChecking"
:disabled="!warp || !warp.configured" @click="adminStore.checkWarp()">
{{ adminStore.warpChecking ? "Vérification…" : "Revérifier" }}
</Button>
</template>
@@ -9,118 +10,172 @@
<AdminState :loading="!warp && adminStore.warpLoading" :error="!warp ? adminStore.warpError : null"
:empty="!warp" empty-text="Aucun statut WARP reçu" @retry="adminStore.fetchWarp()">
<Info v-if="!warp.configured" class="banner">
Aucun proxy WARP n'est configuré : yt-dlp utilise uniquement la connexion directe.
</Info>
<AdminBanner v-if="!warp.configured" tone="info" title="Aucun proxy configuré">
yt-dlp utilise uniquement la connexion directe. Renseignez l'URL du proxy ci-dessous pour activer WARP.
</AdminBanner>
<AdminBanner v-else-if="!warp.healthy && (warp.lastCheck || warp.lastError)" tone="error" title="Proxy injoignable">
<template v-if="warp.lastError">Dernière erreur : <span class="adm-mono">{{ warp.lastError }}</span></template>
<template v-else>Le dernier contrôle de santé a échoué.</template>
<template v-if="warp.fallbackDirect"> Les lectures passent par la connexion directe en attendant.</template>
</AdminBanner>
<div class="stats">
<AdminStat label="État" :value="!warp.configured ? 'Non configuré' : warp.healthy ? 'Opérationnel' : 'Injoignable'"
:icon="warp.healthy ? 'fa-solid fa-circle-check' : 'fa-solid fa-circle-xmark'"
:color="!warp.configured ? 'var(--text-secondary)' : warp.healthy ? 'var(--text-success)' : 'var(--text-error)'"/>
<AdminStat label="IP de sortie" :value="warp.ip || 'Inconnue'" icon="fa-solid fa-network-wired" color="var(--admin-color)"
<AdminStatRow :columns="4" label="État du proxy">
<AdminStat label="État" :value="state.label" :icon="state.icon" :tone="state.tone"/>
<AdminStat label="IP de sortie" :value="warp.ip || 'Inconnue'" icon="fa-solid fa-network-wired" tone="admin" mono
:sub="warp.warp ? 'Mode WARP : ' + warp.warp : null"/>
<AdminStat label="Dernière vérification" :value="formatRelative(warp.lastCheck, nowDate)" icon="fa-solid fa-clock" color="var(--owner-color)"
<AdminStat label="Dernière vérification" :value="formatRelative(warp.lastCheck, nowDate)" icon="fa-solid fa-clock" tone="warning"
:sub="warp.lastCheck ? formatDate(warp.lastCheck) : null"/>
<AdminStat label="Intervalle de vérification" :value="Math.round(safeNum(warp.checkIntervalMs, 60000) / 1000) + ' s'" icon="fa-solid fa-stopwatch"/>
</div>
<AdminStat label="Intervalle de contrôle" :value="Math.round(safeNum(warp.checkIntervalMs, 60000) / 1000) + ' s'" icon="fa-solid fa-stopwatch"
sub="Trace Cloudflare"/>
</AdminStatRow>
<div class="cards">
<AdminCard title="Configuration" icon="fa-solid fa-gear" color="var(--text-secondary)">
<AdminGrid>
<!-- Configuration en vigueur -->
<AdminCard title="Configuration active" subtitle="Valeurs utilisées par le processus en cours"
icon="fa-solid fa-gear" :span="canEdit ? 5 : 12" :span-md="12">
<AdminKv label="Source">
<Tag :tone="warp.source === 'env' ? 'warning' : 'default'" icon="fa-solid fa-file-lines">{{ sourceLabel }}</Tag>
</AdminKv>
<AdminKv label="URL du proxy" mono :value="warp.url || 'Aucune'"/>
<AdminKv label="Repli en direct">
<Tag :color="warp.fallbackDirect ? 'var(--text-success)' : 'var(--text-warning)'">{{ warp.fallbackDirect ? "Autorisé" : "Désactivé" }}</Tag>
<Tag :tone="warp.fallbackDirect ? 'success' : 'warning'" :icon="warp.fallbackDirect ? 'fa-solid fa-check' : 'fa-solid fa-ban'">
{{ warp.fallbackDirect ? "Autorisé" : "Désactivé" }}
</Tag>
</AdminKv>
<AdminKv label="Ordre des routes">
<Tag v-for="(route, index) in warp.routes || []" :key="route + index" :color="route === 'warp' ? 'var(--owner-color)' : 'var(--admin-color)'" icon="fa-solid fa-route">
{{ index + 1 }}. {{ route === 'warp' ? "WARP" : "Direct" }}
</Tag>
<ol class="warp-routes" aria-label="Ordre des routes">
<li v-for="(route, index) in warp.routes || []" :key="route + index">
<Tag :color="route === 'warp' ? 'var(--owner-color)' : 'var(--admin-color)'" icon="fa-solid fa-route">
{{ index + 1 }}. {{ route === 'warp' ? "WARP" : "Direct" }}
</Tag>
</li>
</ol>
</AdminKv>
<Error v-if="warp.lastError">Dernière erreur : {{ warp.lastError }}</Error>
</AdminCard>
<AdminCard v-if="warp.file && warp.locked" title="Modifier le proxy" icon="fa-solid fa-pen" color="var(--main)">
<template #header>
<Tag :color="warp.source === 'env' ? 'var(--text-warning)' : 'var(--text-secondary)'" icon="fa-solid fa-file-lines">
{{ warp.source === 'env' ? "Variable d'environnement" : warp.source === 'file' ? "data/proxy.json" : "Aucune source" }}
</Tag>
</template>
<Info v-if="warp.locked.url" secondary>
L'URL et l'activation sont imposées par la variable d'environnement WARP_PROXY : modifiez-la (docker-compose / .env) puis redémarrez.
</Info>
<!-- Édition de data/proxy.json -->
<AdminCard v-if="canEdit" title="Modifier le proxy" subtitle="Enregistré dans data/proxy.json puis vérifié"
icon="fa-solid fa-pen" tone="main" :span="7" :span-md="12">
<AdminBanner v-if="locked.url" tone="warning" title="Imposé par l'environnement">
L'URL et l'activation viennent de la variable <span class="adm-mono">WARP_PROXY</span> :
modifiez-la (docker-compose / .env) puis redémarrez.
</AdminBanner>
<label :class="['check', { locked: warp.locked.enabled }]">
<input type="checkbox" v-model="form.enabled" :disabled="warp.locked.enabled || saving"/>
<span>Utiliser le proxy</span>
<span v-if="warp.locked.enabled" class="lock"><Icon icon="fa-solid fa-lock"/> WARP_PROXY</span>
</label>
<div class="warp-form">
<AdminField :env="locked.enabled ? 'WARP_PROXY' : null" :dirty="form.enabled !== fileEnabled">
<Switch v-model="form.enabled" label="Utiliser le proxy"
description="yt-dlp passe d'abord par WARP, puis par la connexion directe si le repli est autorisé."
:disabled="locked.enabled || saving"/>
</AdminField>
<div class="field">
<span class="field-label">URL du proxy</span>
<p class="hint">
Actuelle (fichier) : <span class="mono">{{ warp.file.url || "aucune" }}</span>
<template v-if="warp.file.hasCredentials"> · identifiants masqués</template>
<AdminField label="URL du proxy" :env="locked.url ? 'WARP_PROXY' : null" :error="urlError" :dirty="form.url.trim() !== ''"
v-slot="{ id, describedby, invalid, disabled }">
<input :id="id" type="text" v-model="form.url" class="adm-mono" maxlength="500" spellcheck="false" autocomplete="off"
inputmode="url" :aria-describedby="describedby" :aria-invalid="invalid ? 'true' : undefined"
:disabled="disabled || saving" @keyup.enter="saveConfig()"
:placeholder="warp.file.url ? 'Nouvelle URL (vide = conserver l\'actuelle)' : 'http://127.0.0.1:1080'"/>
<IconAction v-if="warp.file.url && !locked.url" label="Effacer l'URL du proxy" icon="fa-solid fa-trash"
color="var(--text-error)" :disabled="saving" @click="clearUrl()"/>
</AdminField>
<p class="adm-hint">
Actuelle : <span class="adm-code">{{ warp.file.url || "aucune" }}</span>
<template v-if="warp.file.hasCredentials"> · identifiants masqués</template>.
Formats acceptés : http://, https://, socks5:// ou socks5h://hôte:port
(identifiants facultatifs : http://utilisateur:motdepasse@hôte:port).
</p>
<div class="field-row">
<input type="text" v-model="form.url" class="mono url-input" maxlength="500" spellcheck="false" autocomplete="off"
:disabled="warp.locked.url || saving" :placeholder="warp.file.url ? 'Nouvelle URL (vide = conserver l\'actuelle)' : 'http://127.0.0.1:1080'"/>
<IconAction v-if="warp.file.url && !warp.locked.url" title="Effacer l'URL" icon="fa-solid fa-trash" color="var(--text-error)" @click="clearUrl()"/>
</div>
<p v-if="urlError" class="field-error">{{ urlError }}</p>
<p class="hint">http://, https://, socks5:// ou socks5h://hôte:port (identifiants facultatifs : http://utilisateur:motdepasse@hôte:port).</p>
<AdminField :env="locked.fallbackDirect ? 'WARP_FALLBACK_DIRECT' : null" :dirty="form.fallbackDirect !== fileFallback">
<Switch v-model="form.fallbackDirect" label="Repli en connexion directe"
description="Si WARP échoue ou est injoignable, la lecture est retentée sans proxy."
:disabled="locked.fallbackDirect || saving"/>
</AdminField>
</div>
<label :class="['check', { locked: warp.locked.fallbackDirect }]">
<input type="checkbox" v-model="form.fallbackDirect" :disabled="warp.locked.fallbackDirect || saving"/>
<span>Repli en connexion directe si WARP échoue</span>
<span v-if="warp.locked.fallbackDirect" class="lock"><Icon icon="fa-solid fa-lock"/> WARP_FALLBACK_DIRECT</span>
</label>
<div class="form-actions">
<Button icon="fa-solid fa-floppy-disk" :disabled="!dirty || !!urlError || saving" @click="saveConfig()">
<template #footer>
<span v-if="dirty" class="adm-footer-meta">Modifications non enregistrées</span>
<Button variant="secondary" icon="fa-solid fa-xmark" :disabled="!dirty || saving" @click="resetForm()">Annuler</Button>
<Button icon="fa-solid fa-floppy-disk" :loading="saving" :disabled="!dirty || !!urlError" @click="saveConfig()">
{{ saving ? "Enregistrement…" : "Enregistrer et vérifier" }}
</Button>
<Button class="secondary" icon="fa-solid fa-xmark" :disabled="!dirty || saving" @click="resetForm()">Annuler</Button>
</div>
</template>
</AdminCard>
<AdminCard title="Processus yt-dlp actifs" icon="fa-brands fa-youtube" color="var(--text-error)">
<Info v-if="!processes.length" secondary>Aucun téléchargement en cours</Info>
<AdminKv v-for="proc in processes" :key="proc.guildId" :label="proc.guildName || proc.guildId">
<Tag :color="proc.ytdlp.route === 'warp' ? 'var(--owner-color)' : 'var(--admin-color)'" icon="fa-solid fa-route">
{{ proc.ytdlp.route === 'warp' ? "WARP" : "Direct" }}
</Tag>
<span class="mono">PID {{ proc.ytdlp.pid }}</span>
<span class="since">{{ formatRelative(proc.ytdlp.startedAt, nowDate) }}</span>
</AdminKv>
<!-- Téléchargements en cours -->
<AdminCard title="Processus yt-dlp actifs" subtitle="Route empruntée par chaque téléchargement en cours"
icon="fa-brands fa-youtube" tone="error" :span="12" :flush="processes.length > 0">
<template #actions>
<span class="adm-badge" aria-live="polite">{{ processes.length }} en cours</span>
</template>
<p v-if="!processes.length" class="adm-hint">Aucun téléchargement en cours.</p>
<AdminList v-else divided label="Processus yt-dlp actifs">
<AdminRow v-for="proc in processes" :key="proc.guildId" as="li" dense
:title="proc.guildName || proc.guildId" :subtitle="'Démarré ' + formatRelative(proc.ytdlp.startedAt, nowDate)">
<template #meta>
<Tag :color="proc.ytdlp.route === 'warp' ? 'var(--owner-color)' : 'var(--admin-color)'" icon="fa-solid fa-route">
{{ proc.ytdlp.route === 'warp' ? "WARP" : "Direct" }}
</Tag>
<span class="adm-chip">PID {{ proc.ytdlp.pid }}</span>
</template>
</AdminRow>
</AdminList>
</AdminCard>
</div>
</AdminGrid>
</AdminState>
<AdminConfirm ref="confirmRef"/>
</AdminSection>
</template>
<script setup>
/**
* Section « WARP » : statut (/ADMIN/WARP/STATUS, poussée /ADMIN/WARP/UPDATE), contrôle (/ADMIN/WARP/CHECK)
* et édition de data/proxy.json (/ADMIN/WARP/CONFIG {url?, enabled?, fallbackDirect?}).
*/
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { useAdminStore } from '@/stores/adminStore';
import { adminRequest } from '@/utils/AdminRequest';
import Button from '@/components/UI/Button.vue';
import IconAction from '@/components/UI/IconAction.vue';
import AdminConfirm from './AdminConfirm.vue';
import Switch from '@/components/UI/Switch.vue';
import Tag from '@/components/UI/Tag.vue';
import Info from '@/components/UI/Info.vue';
import Error from '@/components/UI/Error.vue';
import AdminSection from './AdminSection.vue';
import AdminState from './AdminState.vue';
import AdminStat from './AdminStat.vue';
import AdminGrid from './AdminGrid.vue';
import AdminCard from './AdminCard.vue';
import AdminStatRow from './AdminStatRow.vue';
import AdminStat from './AdminStat.vue';
import AdminBanner from './AdminBanner.vue';
import AdminField from './AdminField.vue';
import AdminKv from './AdminKv.vue';
import AdminList from './AdminList.vue';
import AdminRow from './AdminRow.vue';
import AdminConfirm from './AdminConfirm.vue';
import { safeNum, formatDate, formatRelative } from './adminFormat';
const adminStore = useAdminStore();
// Statut poussé par "/ADMIN/WARP/UPDATE" (via adminStore), à défaut celui de la vue d'ensemble
const warp = computed(() => adminStore.warp || adminStore.overview?.warp || null);
const processes = computed(() => adminStore.players.filter(p => p.ytdlp));
const processes = computed(() => (adminStore.players || []).filter(p => p?.ytdlp));
const locked = computed(() => warp.value?.locked || {});
const canEdit = computed(() => !!(warp.value?.file && warp.value?.locked));
const state = computed(() => {
if (!warp.value?.configured) return { label: "Non configuré", icon: "fa-solid fa-circle-minus", tone: "default" };
if (!warp.value.healthy && !warp.value.lastCheck && !warp.value.lastError) {
return { label: "En attente", icon: "fa-solid fa-hourglass-half", tone: "info" };
}
return warp.value.healthy
? { label: "Opérationnel", icon: "fa-solid fa-circle-check", tone: "success" }
: { label: "Injoignable", icon: "fa-solid fa-circle-xmark", tone: "error" };
});
const sourceLabel = computed(() => {
switch (warp.value?.source) {
case "env": return "Variable WARP_PROXY";
case "file": return "data/proxy.json";
default: return "Aucune";
}
});
// ----- Édition de data/proxy.json (/ADMIN/WARP/CONFIG) -----
// L'URL n'est jamais renvoyée en clair (identifiants) : le champ reste vide = URL actuelle conservée
@@ -129,16 +184,18 @@ const saving = ref(false);
const form = reactive({ enabled: false, url: "", fallbackDirect: true });
const PROXY_URL = /^(https?|socks5h?):\/\/[^\s/?#]+(\/)?$/i;
const fileEnabled = computed(() => !!warp.value?.file?.enabled);
const fileFallback = computed(() => warp.value?.file?.fallbackDirect !== false);
function resetForm() {
form.enabled = !!warp.value?.file?.enabled;
form.fallbackDirect = warp.value?.file?.fallbackDirect !== false;
form.enabled = fileEnabled.value;
form.fallbackDirect = fileFallback.value;
form.url = "";
}
const dirty = computed(() => {
const file = warp.value?.file;
if (!file) return false;
return form.url.trim() !== "" || form.enabled !== !!file.enabled || form.fallbackDirect !== (file.fallbackDirect !== false);
if (!warp.value?.file) return false;
return form.url.trim() !== "" || form.enabled !== fileEnabled.value || form.fallbackDirect !== fileFallback.value;
});
const urlError = computed(() => {
@@ -174,13 +231,13 @@ async function sendConfig(patch) {
}
async function saveConfig() {
if (!dirty.value || urlError.value || saving.value) return;
if (!dirty.value || urlError.value || saving.value || !warp.value?.file) return;
const file = warp.value.file;
const locked = warp.value.locked || {};
const lock = locked.value;
const patch = {};
if (!locked.url && form.url.trim()) patch.url = form.url.trim();
if (!locked.enabled && form.enabled !== !!file.enabled) patch.enabled = form.enabled;
if (!locked.fallbackDirect && form.fallbackDirect !== (file.fallbackDirect !== false)) patch.fallbackDirect = form.fallbackDirect;
if (!lock.url && form.url.trim()) patch.url = form.url.trim();
if (!lock.enabled && form.enabled !== !!file.enabled) patch.enabled = form.enabled;
if (!lock.fallbackDirect && form.fallbackDirect !== (file.fallbackDirect !== false)) patch.fallbackDirect = form.fallbackDirect;
if (!Object.keys(patch).length) return;
await sendConfig(patch);
}
@@ -202,117 +259,32 @@ let timer = null;
onMounted(() => {
adminStore.fetchWarp();
// Processus yt-dlp : la liste des players n'est pas forcément chargée si on arrive directement ici
if (!adminStore.playersLoaded) adminStore.fetchPlayers();
timer = setInterval(() => { nowDate.value = Date.now(); }, 1000);
});
onUnmounted(() => clearInterval(timer));
</script>
<style scoped>
.banner {
background-color: var(--tertiary);
border-radius: 10px;
padding: 10px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 10px;
align-items: start;
}
.mono {
font-family: monospace;
}
.since {
color: var(--text-secondary);
font-size: 0.85em;
}
p {
margin: 0;
}
.hint {
font-size: 0.8em;
color: var(--text-secondary);
}
.check {
display: flex;
align-items: center;
gap: 7px;
font-size: 0.9em;
cursor: pointer;
}
.check.locked {
cursor: not-allowed;
color: var(--text-secondary);
}
.lock {
margin-left: auto;
font-family: monospace;
font-size: 0.8em;
color: var(--text-warning);
display: flex;
align-items: center;
gap: 5px;
}
.field {
.warp-form {
display: flex;
flex-direction: column;
gap: 5px;
gap: 14px;
}
.field-label {
font-weight: 600;
font-size: 0.9em;
/* L'interrupteur occupe toute la ligne : libellé à gauche, bouton à droite */
.warp-form :deep(.ui-switch) {
flex: 1 1 auto;
}
.field-row {
.warp-routes {
display: flex;
align-items: center;
gap: 5px;
}
.url-input {
flex: 1;
min-width: 0;
background-color: var(--secondary) !important;
}
.url-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.field-error {
font-size: 0.8em;
color: var(--text-error);
}
.form-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.secondary {
background-color: var(--secondary);
color: var(--text);
}
.secondary:hover {
background-color: var(--quaternary);
justify-content: flex-end;
gap: 5px;
margin: 0;
padding: 0;
list-style: none;
}
</style>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+85
View File
@@ -0,0 +1,85 @@
/**
* [Subsonics Chopin - Frontend]
* File: adminUi.js
* Description: Aides partagées par les primitives d'administration (tons, largeurs de grille, mémoire locale)
*/
// Fourni par AdminDashboard : { sections (liste {id,label,icon,group}), current (computed de la section active) }
export const ADMIN_NAV_KEY = Symbol('adminNav');
// Id du titre h2 de la section affichée (la zone <main> s'y réfère via aria-labelledby)
export const ADMIN_SECTION_TITLE_ID = 'admin-section-title';
// Niveau de titre des cartes (h3 par défaut, h4 dans un groupe titré AdminGrid) : hiérarchie lisible au lecteur d'écran
export const ADMIN_HEADING_KEY = Symbol('adminHeading');
// Fourni par AdminList : balise des AdminRow enfants ('li' dans une liste ul/ol)
export const ADMIN_LIST_KEY = Symbol('adminList');
// Retire un emoji en tête de titre ("📊 Vue d'ensemble" → "Vue d'ensemble") : l'icône vient de la navigation
export function stripLeadingEmoji(text) {
return String(text ?? '').replace(/^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|[‍️⃣])+\s*/u, '');
}
// Tons acceptés par les primitives (Card, Stat, Banner, Row, Meter…)
export const ADMIN_TONES = ['default', 'main', 'success', 'warning', 'error', 'info', 'admin'];
// Alias tolérés : anciens noms de classes ("warn", "bad", "good"…) utilisés dans les sections
const TONE_ALIASES = {
warn: 'warning',
bad: 'error',
danger: 'error',
good: 'success',
ok: 'success',
neutral: 'default'
};
export function normalizeTone(tone, fallback = 'default') {
if (!tone) return fallback;
const value = TONE_ALIASES[tone] || tone;
return ADMIN_TONES.includes(value) ? value : fallback;
}
export function toneValidator(value) {
return !value || ADMIN_TONES.includes(value) || value in TONE_ALIASES;
}
// Icône par défaut d'un ton (bandeaux, notifications)
export function toneIcon(tone) {
switch (normalizeTone(tone, 'info')) {
case 'error': return "fa-solid fa-circle-exclamation";
case 'warning': return "fa-solid fa-triangle-exclamation";
case 'success': return "fa-solid fa-circle-check";
default: return "fa-solid fa-circle-info";
}
}
function clampSpan(value, fallback) {
const n = Math.round(Number(value));
return Number.isFinite(n) && n >= 1 && n <= 12 ? n : fallback;
}
/**
* Largeurs effectives d'un élément de la grille 12 colonnes.
* md (conteneur ≤ 1080 px) : 2→4, 3→6, 9–11→12 ; sm (≤ 820 px) : 2–4→6, 5→12, 6→6, 7+→12 ; ≤ 560 px : tout en 12.
*/
export function spanClasses(span, spanMd, spanSm) {
const base = clampSpan(span, 4);
const md = clampSpan(spanMd, base <= 2 ? 4 : base === 3 ? 6 : base >= 9 ? 12 : base);
const sm = clampSpan(spanSm, base === 5 ? 12 : base <= 6 ? 6 : 12);
return [`adm-span-${base}`, `adm-span-md-${md}`, `adm-span-sm-${sm}`];
}
// Mémoire locale tolérante (navigation privée, stockage bloqué)
export function readLocal(key, fallback = null) {
try {
const value = localStorage.getItem(key);
return value === null ? fallback : value;
} catch (e) {
return fallback;
}
}
export function writeLocal(key, value) {
try { localStorage.setItem(key, value); } catch (e) { /* stockage indisponible */ }
}
+12 -3
View File
@@ -1,9 +1,9 @@
<template>
<Box :box-class="'box'" padding="closed">
<Box ref="root" :box-class="'box'" padding="closed">
<User :user="userStore.userInfo" />
<div class="user-action">
<IconAction title="Paramètres" icon="fa-solid fa-gear" @click="userSettings.open()"/>
<IconAction title="Déconnexion" color="red" icon="fa-solid fa-right-from-bracket" @click="signOut(router)"/>
<IconAction label="Paramètres" icon="fa-solid fa-gear" @click="openSettings()"/>
<IconAction label="Déconnexion" color="var(--text-error)" icon="fa-solid fa-right-from-bracket" @click="signOut(router)"/>
</div>
<!-- Paramètres : thème, comptes connectés (YouTube), support, confidentialité, accès à l'administration -->
<UserSettings ref="userSettings"/>
@@ -23,12 +23,21 @@ if(!socket.connected) {
import { useUserStore } from '@/stores/userStore';
import UserSettings from '../Widget/User/UserSettings.vue';
import { useUserSettingsOpener } from '../Widget/View/Home/userSettingsOpener';
import { ref } from 'vue';
const userStore = useUserStore();
const router = useRouter();
const root = ref(null);
const userSettings = ref(null);
function openSettings(section) {
userSettings.value?.open(section);
}
// Events.emit('USER_SETTINGS_OPEN', section?) : accueil, playlists… (une seule modale ouverte, cf. userSettingsOpener)
useUserSettingsOpener(() => root.value?.$el ?? null, openSettings);
</script>
<style scoped>
.box {
+223 -114
View File
@@ -2,41 +2,66 @@
<Box padding="closed">
<div class="container">
<SubsonicsLogo/>
<div ref="serverBox" class="server-box">
<Box ref="content" :overbox="showMenu" level="second" no-shadow padding="closed">
<div v-if="server" class="itm">
<ServerItem :server="server"/>
<div class="actions">
<ListenBox>{{ server.members.length + 1}}</ListenBox>
<IconAction ref="opener" @click="showMenu = !showMenu" :icon="showMenu ? 'fa-solid fa-angle-up' : 'fa-solid fa-angle-down'"/>
</div>
<div ref="serverBox" :class="['server-box', { 'server-box--open': showMenu }]">
<div v-if="server" class="server-card" @click="onCardClick">
<ServerItem :server="server"/>
<div class="actions">
<ListenBox title="Utilisateurs connectés à Subsonics sur ce serveur">{{ onlineCount }}</ListenBox>
<IconAction
ref="opener"
:icon="showMenu ? 'fa-solid fa-angle-up' : 'fa-solid fa-angle-down'"
:label="showMenu ? 'Masquer les options du serveur' : 'Afficher les membres et options du serveur'"
:aria-expanded="showMenu ? 'true' : 'false'"
:aria-controls="menuId"
@click.stop="toggleMenu()"
/>
</div>
</div>
<div v-else-if="server === undefined" class="server-card server-card--loading">
<Spinner label="Chargement du serveur…" visible-label />
</div>
<div v-else class="server-card server-card--error">
<Error>Serveur indisponible</Error>
<Button size="sm" variant="secondary" icon="fa-solid fa-arrow-right" to="/servers">Changer</Button>
</div>
<div
v-show="showMenu && server"
:id="menuId"
class="menu"
role="region"
aria-label="Membres et options du serveur"
@keydown.esc.stop="closeMenu(true)"
>
<div class="menu-content">
<p class="menu-title">
<Icon icon="fa-solid fa-users" aria-hidden="true"/>
<span>En ligne sur Subsonics</span>
</p>
<div v-if="server" class="users-container">
<ul v-if="server.members.length > 0" class="users-list" aria-label="Utilisateurs en ligne">
<GuildHeaderUsers :server="server"/>
</ul>
<Info secondary v-else>Personne d'autre n'est connecté pour le moment.</Info>
</div>
<div class="server-actions">
<Button v-if="gestion" size="sm" variant="secondary" icon="fa-solid fa-gear" @click="openSettings()">Gestion</Button>
<Button size="sm" icon="fa-solid fa-arrow-right-arrow-left" to="/servers">Changer de serveur</Button>
</div>
<Error v-else><ServerItem/></Error>
</Box>
<div :style="`width: ${serverBox.offsetWidth}px !important;`" class="menu" v-if="showMenu">
<div class="menu-content">
<div>
<p><Icon icon="fa-solid fa-users"/> Utilisateurs en ligne</p>
<div v-if="server" class="users-container">
<div v-if="server.members.length > 0" class="users-list">
<GuildHeaderUsers :server="server"/>
</div>
<Info secondary v-else>Aucun utilisateur en ligne</Info>
</div>
</div>
<div class="server-actions">
<Button icon="fa-solid fa-gear" v-if="gestion" @click="settings.open()">Gestion</Button>
<Button icon="fa-solid fa-arrow-right" :style="{width: gestion ? '' : '100%'}" @click="router.push(`/servers`)">Changer de serveur</Button>
</div>
</div>
</div>
</div>
<div v-if="showMenu" style="height: 10px;"></div>
<GuildSettings ref="settings" :server="server" v-if="server && gestion"/>
</div>
</div>
</Box>
</template>
<script setup>
/**
* En-tête de la colonne gauche : logo, serveur actif (état, membres en ligne) et panneau dépliant
* (utilisateurs connectés et leurs actions de modération, gestion du serveur, changement de serveur).
* Panneau : bouton à aria-expanded, Échap ou clic extérieur pour fermer.
*/
import { computed, onMounted, onUnmounted, ref, useId } from 'vue';
import SubsonicsLogo from '@/components/UI/SubsonicsLogo.vue';
import Box from '@/components/UI/Box.vue';
import ServerItem from '@/components/UI/Server.vue';
@@ -45,79 +70,120 @@ import ListenBox from '@/components/UI/ListenBox.vue';
import Error from '@/components/UI/Error.vue';
import Info from '@/components/UI/Info.vue';
import Button from '@/components/UI/Button.vue';
import Spinner from '@/components/UI/Spinner.vue';
import { IORequest } from '@/utils/IORequest';
import events from '@/utils/Events';
import { useRouter } from 'vue-router';
import { ref, onMounted, onUnmounted } from 'vue';
import Events from '@/utils/Events';
import GuildHeaderUsers from '../Widget/Guild/GuildHeaderUsers.vue';
import GuildSettings from '../Widget/Guild/GuildSettings.vue';
import { useUserStore } from '@/stores/userStore';
import { useGlobalStore } from '@/stores/globalStore';
import Events from '@/utils/Events';
const userStore = useUserStore();
const server = ref(undefined)
const showMenu = ref(false);
const serverBox = ref(null);
const router = useRouter();
const content = ref(null);
const opener = ref(null);
var gestion = ref(false)
const globalStore = useGlobalStore();
const menuId = `guild-menu-${useId()}`;
// undefined = chargement, null = indisponible
const server = ref(undefined);
const showMenu = ref(false);
const serverBox = ref(null);
const opener = ref(null);
const gestion = ref(false);
const settings = ref(null);
events.on("UPDATE", () => {
updateServerInfo();
})
// Moi + les autres utilisateurs connectés
const onlineCount = computed(() => (server.value?.members?.length || 0) + 1);
onMounted(() => {
updateServerInfo();
window.addEventListener('resize', () => {
showMenu.value = false;
});
function toggleMenu() {
if (showMenu.value) closeMenu(false);
else openMenu();
}
content.value.$el.addEventListener('pointerdown', (ev) => {
if(ev.pointerType === 'mouse') return;
if(opener.value.$el.contains(ev.target)) return;
if(!showMenu.value) {
showMenu.value = true;
}
});
})
function openMenu() {
showMenu.value = true;
document.addEventListener('pointerdown', onDocumentPointerDown, true);
}
onUnmounted(() => {
window.removeEventListener('resize', () => {
showMenu.value = false;
});
userStore.userInfo.identity.isAdmin = null;
userStore.userInfo.identity.isMod = null;
userStore.userInfo.identity.isOwner = null;
});
function closeMenu(focusOpener) {
if (!showMenu.value) return;
showMenu.value = false;
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
if (focusOpener) opener.value?.$el?.focus?.();
}
// Clic sur la carte (hors bouton) : ouvre aussi le panneau, comme avant
function onCardClick(event) {
if (event.target instanceof Element && event.target.closest('button, a')) return;
toggleMenu();
}
function onDocumentPointerDown(event) {
const target = event.target;
if (!(target instanceof Node)) return;
if (serverBox.value?.contains(target)) return;
// Menus et modales téléportés (actions sur un utilisateur, gestion) : ne ferment pas le panneau
if (target instanceof Element && target.closest('.ui-menu, .modal-overlay')) return;
closeMenu(false);
}
function onResize() {
closeMenu(false);
}
function openSettings() {
closeMenu(false);
settings.value?.open();
}
function updateServerInfo() {
IORequest("/GUILD/INFO", (data) => {
if (!data || typeof data !== 'object') {
server.value = null;
return;
}
server.value = data;
document.title = data.name + " - Subsonics";
globalStore.actualServer = data;
userStore.userInfo.identity.isAdmin = userStore.userInfo.labels.includes('ADMIN');
userStore.userInfo.identity.isMod = userStore.userInfo.labels.includes('MOD_' + server.value.id);
userStore.userInfo.identity.isOwner = userStore.userInfo.identity.id == server.value.owner;
gestion.value = userStore.userInfo.labels.includes('ADMIN') || userStore.userInfo.labels.includes('MOD_' + server.value.id) || userStore.userInfo.identity.id == server.value.owner;
console.log("Server info updated");
events.emit("GUILD_JOINED", data);
})
const labels = userStore.userInfo?.labels || [];
const identity = userStore.userInfo?.identity;
if (identity) {
identity.isAdmin = labels.includes('ADMIN');
identity.isMod = labels.includes('MOD_' + data.id);
identity.isOwner = identity.id == data.owner;
}
gestion.value = labels.includes('ADMIN') || labels.includes('MOD_' + data.id) || identity?.id == data.owner;
Events.emit("GUILD_JOINED", data);
});
}
Events.on("GUILD_LIST_UPDATE", (guilds) => {
server.value = guilds.find(g => g.id === globalStore.lastGuild);
function onUpdate() {
updateServerInfo();
}
function onGuildListUpdate(guilds) {
const found = Array.isArray(guilds) ? guilds.find(g => g.id === globalStore.lastGuild) : null;
if (found) server.value = found;
}
Events.on("UPDATE", onUpdate);
Events.on("GUILD_LIST_UPDATE", onGuildListUpdate);
onMounted(() => {
updateServerInfo();
window.addEventListener('resize', onResize);
});
onUnmounted(() => {
Events.off("UPDATE", onUpdate);
Events.off("GUILD_LIST_UPDATE", onGuildListUpdate);
window.removeEventListener('resize', onResize);
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
const identity = userStore.userInfo?.identity;
if (identity) {
identity.isAdmin = null;
identity.isMod = null;
identity.isOwner = null;
}
});
</script>
<style scoped>
.container {
@@ -128,66 +194,109 @@ Events.on("GUILD_LIST_UPDATE", (guilds) => {
height: 100%;
}
.itm {
.server-box {
width: 100%;
position: relative;
}
.server-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px;
border-radius: var(--radius);
background-color: var(--surface-2);
cursor: pointer;
transition: border-radius var(--dur-fast) ease, background-color var(--dur-fast) ease;
}
.server-card:hover {
background-color: color-mix(in srgb, var(--surface-2) 88%, var(--text));
}
.server-card--loading,
.server-card--error {
cursor: default;
}
.server-card--loading {
justify-content: center;
min-height: 70px;
}
.server-box--open .server-card {
border-radius: var(--radius) var(--radius) 0 0;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.menu {
position: absolute;
left: 0;
right: 0;
top: 100%;
z-index: 1000;
background-color: var(--surface-2);
border-radius: 0 0 var(--radius) var(--radius);
box-shadow: var(--shadow-lg);
border-top: 1px solid var(--border);
animation: u-fade-in var(--dur-fast) ease-out;
}
.menu-content {
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.server-box {
width: 100%;
position: relative;
display: inline-block;
.menu-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
.users-container {
position: relative;
max-height: 22vh;
overflow-y: auto;
overscroll-behavior: contain;
}
.collapsed-box {
height: auto;
.users-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.server-actions {
display: flex;
justify-content: space-between;
gap: 8px;
}
.server-actions p {
margin: 0;
font-size: 12px;
}
.menu {
display: flex;
flex-direction: column;
gap: 10px;
position: absolute;
background-color: var(--tertiary);
border-radius: 0px 0px 10px 10px;
z-index: 1000;
}
.menu-content {
padding: 0px 10px 10px 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.users-container {
position: relative;
.server-actions > * {
flex: 1;
max-height: 20vh;
overflow-y: auto;
}
.users-list {
display: flex;
flex-direction: column;
gap: 10px;
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.server-actions {
flex-direction: column;
}
}
</style>
+99 -62
View File
@@ -5,29 +5,30 @@
<div class="player">
<div class="player-line">
<VideoList :player-offline="!online" :video="video" />
<div class="player-tags">
<div class="player-tags" aria-live="polite">
<ActualChannel/>
<Tag v-for="tag in statusTags" :key="tag.key" :color="tag.color" :icon="tag.icon" :title="tag.title">{{ tag.text }}</Tag>
</div>
</div>
<DurationBar class="duration-bar" v-bind="durationBarProps" @seek="seek" />
<div class="actions">
<div v-if="online" class="actions-div">
<CustomIcon title="En boucle" :active="loop" @click="toggleLoop"><Loop active icon="fa-solid fa-sync"/></CustomIcon>
<CustomIcon title="Lecture aléatoire" :active="shuffle" @click="toggleShuffle"><Shuffle icon="fa-solid fa-shuffle"/></CustomIcon>
<div v-if="online" class="actions-div" role="group" aria-label="Options de lecture">
<CustomIcon label="En boucle" :active="loop" :aria-pressed="loop ? 'true' : 'false'" @click="toggleLoop"><Loop/></CustomIcon>
<CustomIcon label="Lecture aléatoire" :active="shuffle" :aria-pressed="shuffle ? 'true' : 'false'" @click="toggleShuffle"><Shuffle/></CustomIcon>
<CustomIcon label="Paroles" :active="lyricsOpen" :aria-pressed="lyricsOpen ? 'true' : 'false'" @click="toggleLyrics"><Icon class="lyrics-icon" icon="fa-solid fa-microphone-lines"/></CustomIcon>
</div>
<span v-else></span>
<div :class="{'actions-div': true, 'offline': !online || !video}" >
<Backward title="Précédent" @click="backward" :class="{'playIcon': online && video, 'itm-sec': true, 'enabled': online}" class="itm-sec" icon="fa-solid fa-backward-step"/>
<div @click="togglePause()" :title="pauseTitle" :class="{'playIcon': online && video, 'itm-main': true}">
<Play v-if="showPlayIcon"/>
<Pause v-else/>
</div>
<Forward title="Suivant" @click="forward" :class="{'playIcon': online && video, 'itm-sec': true, 'enabled': online}" class="itm-sec" icon="fa-solid fa-forward-step"/>
<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()">
<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>
</div>
<div v-if="online" class="actions-div">
<CustomIcon title="Changer de salon" @click="changeChannel"><Hand icon="fa-solid fa-hand"/></CustomIcon>
<CustomIcon title="Déconnecter" @click="disconnect"><Disconnect icon="fa-solid fa-phone-slash"/></CustomIcon>
<div v-if="online" class="actions-div" role="group" aria-label="Salon vocal">
<CustomIcon label="Changer de salon" @click="changeChannel"><Hand/></CustomIcon>
<CustomIcon label="Déconnecter" @click="disconnect"><Disconnect/></CustomIcon>
</div>
<span v-else></span>
</div>
@@ -35,36 +36,43 @@
</Box>
</section>
<div class="player-placeholder" :style="{ height: playerHeight + 'px' }"></div>
<div :class="{'player-mobile-container': true, 'player-mobile-container-open': playerOpen}">
<section @click="togglePlayerOpen" ref="playerMobile" v-if="online" :class="{'player-mobile-unfold': playerOpen, 'player-mobile': true}">
<div v-show="playerOpen" class="player-menu">
<div :class="{'player-mobile-container': true, 'player-mobile-container-open': playerOpen, 'slide-out': playerClosing}">
<!-- Toucher la barre repliée l'ouvre ; le bouton chevron est l'équivalent clavier / lecteur d'écran -->
<section @click="onMobileSectionClick" ref="playerMobile" v-if="online" :class="{'player-mobile-unfold': playerOpen, 'player-mobile': true}" aria-label="Lecteur">
<div v-show="playerOpen" id="player-mobile-menu" class="player-menu">
<DurationBar mobile class="duration-bar" v-bind="durationBarProps" @seek="seek" />
<span class="actions-container">
<div :class="{'actions-div': true, 'offline': !online || !video}" >
<Backward title="Précédent" @click="backward" :class="{'playIcon': online && video, 'itm-sec': true, 'enabled': online}" class="itm-sec" icon="fa-solid fa-backward-step"/>
<div @click="togglePause()" :title="pauseTitle" :class="{'playIcon': online && video, 'itm-main': true}">
<Play v-if="showPlayIcon"/>
<Pause v-else/>
</div>
<Forward title="Suivant" @click="forward" :class="{'playIcon': online && video, 'itm-sec': true, 'enabled': online}" class="itm-sec" icon="fa-solid fa-forward-step"/>
<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()">
<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>
</div>
</span>
<div class="player-tags">
<div class="player-tags" aria-live="polite">
<ActualChannel/>
<Tag v-for="tag in statusTags" :key="tag.key" :color="tag.color" :icon="tag.icon" :title="tag.title">{{ tag.text }}</Tag>
</div>
<div class="button-div" v-if="online" >
<CustomIcon title="En boucle" :active="loop" @click="toggleLoop"><Loop active icon="fa-solid fa-sync"/></CustomIcon>
<CustomIcon title="Lecture aléatoire" :active="shuffle" @click="toggleShuffle"><Shuffle icon="fa-solid fa-shuffle"/></CustomIcon>
<CustomIcon title="Changer de salon" @click="changeChannel"><Hand icon="fa-solid fa-hand"/></CustomIcon>
<CustomIcon title="Déconnecter" @click="disconnect"><Disconnect icon="fa-solid fa-phone-slash"/></CustomIcon>
<div class="button-div" v-if="online" role="group" aria-label="Options">
<CustomIcon label="En boucle" :active="loop" :aria-pressed="loop ? 'true' : 'false'" @click="toggleLoop"><Loop/></CustomIcon>
<CustomIcon label="Lecture aléatoire" :active="shuffle" :aria-pressed="shuffle ? 'true' : 'false'" @click="toggleShuffle"><Shuffle/></CustomIcon>
<CustomIcon label="Paroles" :active="lyricsOpen" :aria-pressed="lyricsOpen ? 'true' : 'false'" @click="openLyricsFromMobile"><Icon class="lyrics-icon" icon="fa-solid fa-microphone-lines"/></CustomIcon>
<CustomIcon label="Changer de salon" @click="changeChannel"><Hand/></CustomIcon>
<CustomIcon label="Déconnecter" @click="disconnect"><Disconnect/></CustomIcon>
</div>
</div>
<div :class="{'player-mobile-bar': playerOpen, 'player-mobile-fold': !playerOpen}">
<div v-if="online" ref="playerMobileToggle">
<IconAction v-if="!playerOpen" icon="fa-angle-up"/>
<IconAction v-else icon="fa-angle-down"/>
</div>
<IconAction
ref="playerMobileToggle"
class="mobile-toggle"
:icon="playerOpen ? 'fa-solid fa-angle-down' : 'fa-solid fa-angle-up'"
:label="playerOpen ? 'Réduire le lecteur' : 'Agrandir le lecteur'"
:aria-expanded="playerOpen ? 'true' : 'false'"
aria-controls="player-mobile-menu"
@click.stop="togglePlayerOpen"
/>
<VideoList v-if="!playerOpen" mobile :player-offline="!online" :video="video" />
<Video mobile v-else :video="video" />
</div>
@@ -214,26 +222,56 @@ function onGuildJoined() {
Events.on("GUILD_JOINED", onGuildJoined);
function togglePlayerOpen(event) {
if(!playerOpen.value || playerMobileToggle.value.contains(event.target)) {
if (playerOpen.value) {
// player est ouvert → on ferme avec animation
const container = playerMobile.value.parentElement; // .player-mobile-container
container.classList.add('slide-out');
// === LECTEUR MOBILE (feuille repliable) ===
// Durée de l'animation de fermeture (slideOut) ; la feuille est retirée ensuite
const CLOSE_ANIMATION_MS = 500;
const playerClosing = ref(false);
let closeTimer = null;
container.addEventListener('animationend', () => {
container.classList.remove('player-mobile-container-open');
container.classList.remove('slide-out');
playerOpen.value = false;
}, { once: true });
function openPlayer() {
clearTimeout(closeTimer);
playerClosing.value = false;
playerOpen.value = true;
}
} else {
// player est fermé → on ouvre normalement
playerOpen.value = true;
playerMobile.value.parentElement.classList.add('player-mobile-container-open');
}
function closePlayer() {
if (!playerOpen.value || playerClosing.value) return;
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
playerOpen.value = false;
return;
}
playerClosing.value = true;
closeTimer = setTimeout(() => {
playerClosing.value = false;
playerOpen.value = false;
}, CLOSE_ANIMATION_MS);
}
// Bouton chevron : équivalent clavier / lecteur d'écran du toucher sur la barre
function togglePlayerOpen() {
if (playerOpen.value && !playerClosing.value) closePlayer();
else openPlayer();
}
// Toucher la barre repliée l'ouvre ; une fois ouverte, les clics vont aux contrôles
function onMobileSectionClick() {
if (!playerOpen.value) openPlayer();
}
// === PAROLES ===
const lyricsOpen = computed(() => playerStore.lyricsOpen);
// Bascule la vue des paroles (Layout/View.vue gère l'ouverture / la fermeture)
function toggleLyrics() {
Events.emit('VIEW_LYRICS');
}
// Mobile : la feuille du lecteur couvre la vue, on la replie pour montrer les paroles
function openLyricsFromMobile() {
const opening = !playerStore.lyricsOpen;
Events.emit('VIEW_LYRICS');
if (opening) closePlayer();
}
@@ -292,6 +330,7 @@ onUnmounted(() => {
Events.off("GUILD_JOINED", onGuildJoined);
clearTimeout(errorTimer);
clearTimeout(actionErrorTimer);
clearTimeout(closeTimer);
// Le prochain montage (autre serveur, reconnexion) repart d'un état vierge
playerStore.reset();
})
@@ -303,9 +342,7 @@ useResizeObserver(playerMobile, (entries) => {
})
onClickOutside(playerMobile, () => {
if (playerOpen.value) {
playerOpen.value = false;
}
closePlayer();
});
// Appelé par DurationBar (position en ms, seconde entière) ; renvoie false si le seek est refusé
@@ -374,13 +411,6 @@ async function seek(ms) {
cursor: pointer;
}
.itm-sec:hover && .playIcon {
--main: var(--main-hover);
}
.itm-sec:active && .playIcon {
--main: var(--main-active);
}
.itm-main {
padding: 15px;
@@ -393,14 +423,21 @@ async function seek(ms) {
transition: all 0.3s ease-in-out;
}
.itm-main:hover && .playIcon {
.itm-main.playIcon:hover:not(:disabled) {
background: var(--main-hover);
}
.itm-main:active && .playIcon {
.itm-main.playIcon:active:not(:disabled) {
background: var(--main-active);
}
/* Icône FontAwesome du bouton « Paroles » : même gabarit que les SVG du lecteur */
.lyrics-icon {
width: 20px;
height: 20px;
color: var(--text);
}
.actions-div {
display: flex;
+531 -172
View File
@@ -1,218 +1,577 @@
<template>
<div class="playlist">
<!-- <p class="title"><Icon icon="fa-list-ol"/> Playlists</p> -->
<div class="playlist-items">
<div v-if="playlists.length > 0" class="playlist-items-list">
<PlaylistItem :selected="globalStore.actualPlaylistId === value.playlistId" @click="Events.emit('playlist:open', value)" v-for="value in playlists" :key="value.playlistId" :title="value.title" :type="value.type"/>
</div>
<div class="none" v-else>
<p class="none-title"><Icon icon="fa-circle-xmark"/>Aucune Playlist</p>
<p class="none-info">
Vous n'avez pas encore créé de Playlist. <br/> Cliquez sur le bouton ci-dessous pour en ajouter une.
</p>
<section class="playlist" :aria-labelledby="headingId">
<header class="playlist__header">
<h2 :id="headingId" class="playlist__heading">
<Icon class="playlist__heading-icon" icon="fa-solid fa-list-ul" aria-hidden="true" />
<span>Playlists</span>
<span v-if="listLoaded" class="playlist__count" :aria-label="countLabel">{{ fullList.length }}</span>
</h2>
<div v-if="fullList.length > 1" class="playlist__sort">
<!-- Inversion rapide du sens (inutile en ordre personnalisé) -->
<button
v-if="currentSort.mode !== 'manual'"
type="button"
class="playlist__dir"
:aria-label="dirToggleLabel"
:title="dirToggleLabel"
@click="playlistStore.toggleSortDir(scope)"
>
<Icon :icon="currentSort.dir === 'desc' ? 'fa-solid fa-arrow-down-wide-short' : 'fa-solid fa-arrow-up-short-wide'" aria-hidden="true" />
</button>
<PlaylistSortMenu :model-value="currentSort" @update:model-value="onSortChange" />
</div>
</header>
<SegmentedControl
v-if="playlistStore.guildEnabled"
class="playlist__scopes"
size="sm"
block
label="Playlists affichées"
:model-value="scope"
:options="scopeOptions"
@update:model-value="playlistStore.setScope($event)"
/>
<div v-if="showFilter" class="playlist__filter">
<Icon class="playlist__filter-icon" icon="fa-solid fa-magnifying-glass" aria-hidden="true" />
<label :for="filterId" class="sr-only">Filtrer les playlists</label>
<input
:id="filterId"
v-model="filter"
type="search"
class="playlist__filter-input"
placeholder="Filtrer…"
autocomplete="off"
@keydown.esc.stop="filter = ''"
/>
</div>
<Button icon="fa-add" class="add-playlist" @click="openModal()">Ajouter une playlist</Button>
</div>
<Modal icon="fa-list" title="Ajouter une Playlist" ref="modal">
<div class="p-modal-content">
<input type="text" v-if="!isYoutube" v-model="newPlaylistTitle" placeholder="Titre de la playlist" />
<input type="text" v-if="isYoutube" v-model="urlLink" placeholder="Lien de la playlist YouTube" />
<div @click="isYoutube = !isYoutube" class="youtube-import">
<input type="checkbox" v-model="isYoutube" />
<label>Importer depuis YouTube</label>
<p v-if="isGuild && listLoaded && !playlistStore.canEdit" class="playlist__note">
<Icon icon="fa-solid fa-lock" aria-hidden="true" />
Lecture seule : seuls les modérateurs modifient ces playlists.
</p>
<div class="playlist__scroll" @touchstart="onListTouchStart">
<div v-if="!listLoaded && listError" class="playlist__state">
<EmptyState icon="fa-solid fa-plug-circle-exclamation" title="Playlists indisponibles" compact>
Le serveur n'a pas répondu.
<template #actions>
<Button size="sm" variant="secondary" icon="fa-solid fa-rotate-right" @click="retry()">Réessayer</Button>
</template>
</EmptyState>
</div>
<div v-else-if="!listLoaded" class="playlist__state">
<Spinner label="Chargement des playlists…" visible-label />
</div>
<div v-else-if="fullList.length === 0" class="playlist__state">
<EmptyState v-if="isGuild" icon="fa-solid fa-users" title="Aucune playlist partagée">
<template v-if="playlistStore.canEdit">Créez une playlist pour tout le serveur, ou partagez l'une des vôtres depuis sa page.</template>
<template v-else>Les modérateurs du serveur n'ont encore rien partagé.</template>
<template v-if="playlistStore.canEdit" #actions>
<Button size="sm" icon="fa-solid fa-plus" @click="openCreate()">Créer une playlist partagée</Button>
</template>
</EmptyState>
<EmptyState v-else icon="fa-solid fa-compact-disc" title="Aucune playlist">
Créez votre première playlist, ou importez-en une depuis YouTube, Spotify ou SoundCloud.
<template #actions>
<Button size="sm" icon="fa-solid fa-plus" @click="openCreate()">Nouvelle playlist</Button>
</template>
</EmptyState>
</div>
<div v-else-if="visibleList.length === 0" class="playlist__state">
<EmptyState icon="fa-solid fa-magnifying-glass" title="Aucun résultat" compact>
Aucune playlist ne correspond à « {{ filter.trim() }} ».
<template #actions>
<Button size="sm" variant="ghost" icon="fa-solid fa-xmark" @click="clearFilter()">Effacer le filtre</Button>
</template>
</EmptyState>
</div>
<Button icon="fa-add" :disabled="(!isYoutube && newPlaylistTitle.trim() === '') || (isYoutube && !urlLink?.trim())" @click="addPlaylist">Ajouter</Button>
<p class="text-loading" v-if="isLoading"><Icon icon="fa-spinner" spin-pulse /> Création en cours…</p>
<p class="text-loading" v-if="error"><Error> {{ error }}</Error></p>
<draggable
v-else-if="canDrag"
v-model="manualList"
item-key="playlistId"
tag="ul"
class="playlist__list"
:aria-labelledby="headingId"
handle=".playlist__handle"
:animation="180"
ghost-class="playlist__row--ghost"
chosen-class="playlist__row--chosen"
drag-class="playlist__row--drag"
@start="dragging = true"
@end="onDragEnd"
>
<template #item="{ element, index }">
<li class="playlist__row playlist__row--sortable">
<button
type="button"
class="playlist__handle"
:data-handle-id="element.playlistId"
:aria-label="`Déplacer « ${element.title} », position ${index + 1} sur ${fullList.length}`"
:aria-describedby="helpId"
@keydown="onHandleKeydown($event, element)"
>
<Icon icon="fa-solid fa-grip-vertical" aria-hidden="true" />
</button>
<PlaylistItem
:playlist="element"
:scope="scope"
:selected="isSelected(element)"
:data-item-id="element.playlistId"
@click="open(element)"
@keydown="onItemKeydown($event, element)"
/>
</li>
</template>
</draggable>
<ul v-else class="playlist__list" :aria-labelledby="headingId">
<li v-for="element in visibleList" :key="element.playlistId" class="playlist__row">
<PlaylistItem
:playlist="element"
:scope="scope"
:selected="isSelected(element)"
@click="open(element)"
/>
</li>
</ul>
</div>
</Modal>
<p :id="helpId" class="sr-only">Flèches haut et bas pour déplacer la playlist dans l'ordre personnalisé.</p>
<p class="sr-only" aria-live="polite">{{ announcement }}</p>
<Button
v-if="!isGuild || playlistStore.canEdit"
class="playlist__create"
icon="fa-solid fa-plus"
block
@click="openCreate()"
>{{ isGuild ? 'Nouvelle playlist partagée' : 'Nouvelle playlist' }}</Button>
</section>
</template>
<script setup>
import { IORequest } from '@/utils/IORequest';
/**
* Barre latérale des playlists : mes playlists / playlists du serveur, tri (préférence locale),
* ordre personnalisé par glisser-déposer ou au clavier, filtre rapide.
*/
import { computed, nextTick, onMounted, ref, useId, watch } from 'vue';
import draggable from 'vuedraggable';
import Button from '../UI/Button.vue';
import PlaylistItem from '../UI/PlaylistItem.vue';
import { onMounted, ref, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import Modal from '../UI/Modal.vue';
import Error from '../UI/Error.vue';
import Events from '@/utils/Events';
import SegmentedControl from '../UI/SegmentedControl.vue';
import EmptyState from '../UI/EmptyState.vue';
import Spinner from '../UI/Spinner.vue';
import PlaylistSortMenu from '../Widget/Playlist/PlaylistSortMenu.vue';
import { useGlobalStore } from '@/stores/globalStore';
import { useUserStore } from '@/stores/userStore';
import { socket } from '@/socket';
import { refreshPlaylist } from '@/utils/Logic';
const modal = ref(null);
const playlists = ref([]);
const newPlaylistTitle = ref('');
const isYoutube = ref(false);
const urlLink = ref(null);
const isLoading = ref(false);
const error = ref(null);
import { usePlaylistStore } from '@/stores/playlistStore';
const globalStore = useGlobalStore();
const userStore = useUserStore();
const router = useRouter();
const playlistStore = usePlaylistStore();
//TODO: Faire la possibilité d'envoyer une playlist
const uid = useId();
const headingId = `playlists-heading-${uid}`;
const filterId = `playlists-filter-${uid}`;
const helpId = `playlists-help-${uid}`;
// Au-delà de ce nombre de playlists, un champ de filtre apparaît
const FILTER_THRESHOLD = 8;
function openModal() {
modal.value.open();
newPlaylistTitle.value = '';
isYoutube.value = false;
urlLink.value = null;
isLoading.value = false;
error.value = null;
}
const filter = ref('');
const announcement = ref('');
const dragging = ref(false);
Events.on("playlist:init", (data) => {
playlists.value = data;
})
const scope = computed(() => playlistStore.activeScope);
const isGuild = computed(() => scope.value === 'guild');
const listLoaded = computed(() => playlistStore.isLoaded(scope.value));
const listError = computed(() => (isGuild.value ? playlistStore.guildError : playlistStore.personalError));
const fullList = computed(() => playlistStore.sortedFor(scope.value));
const currentSort = computed(() => playlistStore.sort[scope.value]);
// Playlists modifiées hors de cette page (synchronisation YouTube depuis les paramètres, autre appareil, playlist reçue).
// routes.js retire tous les écouteurs socket à chaque navigation : réenregistrement après chacune.
function onPlaylistsUpdate() {
refreshPlaylist();
}
function listenPlaylistsUpdate() {
socket.off("/PLAYLISTS/UPDATE", onPlaylistsUpdate);
socket.on("/PLAYLISTS/UPDATE", onPlaylistsUpdate);
}
let removeHook = null;
onMounted(() => {
Events.emit("logic:init");
listenPlaylistsUpdate();
removeHook = router.afterEach(() => listenPlaylistsUpdate());
// Libellé du bouton d'inversion : décrit l'ordre obtenu après le clic
const dirToggleLabel = computed(() => {
const next = currentSort.value.dir === 'asc' ? 'desc' : 'asc';
switch (currentSort.value.mode) {
case 'name':
return next === 'asc' ? 'Trier de A à Z' : 'Trier de Z à A';
case 'created':
case 'updated':
return next === 'asc' ? "Plus anciennes d'abord" : "Plus récentes d'abord";
default:
return next === 'asc' ? "Plus petites d'abord" : "Plus grandes d'abord";
}
});
onUnmounted(() => {
removeHook?.();
socket.off("/PLAYLISTS/UPDATE", onPlaylistsUpdate);
const countLabel = computed(() => {
const n = fullList.value.length;
return `${n} playlist${n > 1 ? 's' : ''}`;
});
function addPlaylist() {
if ((!isYoutube.value && !newPlaylistTitle.value?.trim()) || (isYoutube.value && !urlLink.value?.trim())) {
const scopeOptions = computed(() => [
{ value: 'personal', label: 'Mes playlists', icon: 'fa-solid fa-user', badge: playlistStore.personalLoaded ? playlistStore.counts.personal : null },
{ value: 'guild', label: 'Serveur', icon: 'fa-solid fa-users', badge: playlistStore.guildLoaded ? playlistStore.counts.guild : null }
]);
const showFilter = computed(() => fullList.value.length > FILTER_THRESHOLD || filter.value !== '');
// Comparaison sans accents ni casse
function fold(text) {
return String(text || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
}
const visibleList = computed(() => {
const query = fold(filter.value.trim());
if (!query) return fullList.value;
return fullList.value.filter((playlist) => fold(playlist.title).includes(query));
});
// Glisser-déposer : ordre personnalisé, sans filtre, avec le droit de réordonner
const canDrag = computed(() =>
currentSort.value.mode === 'manual'
&& filter.value.trim() === ''
&& fullList.value.length > 1
&& playlistStore.canReorder(scope.value)
);
// Liste du glisser-déposer : l'écriture enregistre le nouvel ordre (optimiste, annulé en cas de refus)
const manualList = computed({
get: () => playlistStore.listFor(scope.value),
set: (list) => {
saveOrder(list.map((playlist) => String(playlist.playlistId)));
}
});
async function saveOrder(order) {
const result = await playlistStore.reorder(scope.value, order);
if (!result.ok) {
announce("Le nouvel ordre n'a pas pu être enregistré.");
playlistStore.notify(result.message, { tone: 'error' });
}
}
function announce(message) {
// Vidé puis rempli : un message identique est relu
announcement.value = '';
nextTick(() => {
announcement.value = message;
});
}
function onDragEnd(event) {
dragging.value = false;
if (event.oldIndex === event.newIndex) return;
const list = playlistStore.listFor(scope.value);
const moved = list[event.newIndex];
if (moved) announce(`« ${moved.title} » déplacée en position ${event.newIndex + 1} sur ${list.length}.`);
}
function onSortChange(value) {
playlistStore.setSort(scope.value, value);
}
function isSelected(playlist) {
return globalStore.actualPlaylistScope === scope.value && String(globalStore.actualPlaylistId) === String(playlist.playlistId);
}
function open(playlist) {
playlistStore.openPlaylist(scope.value, playlist);
}
function openCreate() {
playlistStore.requestCreate(isGuild.value && playlistStore.canEdit ? 'guild' : 'personal');
}
function clearFilter() {
filter.value = '';
nextTick(() => document.getElementById(filterId)?.focus());
}
function retry() {
playlistStore.fetchScope(scope.value);
}
// Alternative clavier au glisser-déposer
async function move(playlist, delta, focusSelector) {
const id = String(playlist.playlistId);
const list = playlistStore.listFor(scope.value);
const from = list.findIndex((item) => String(item.playlistId) === id);
const to = from + delta;
if (from === -1 || to < 0 || to >= list.length) {
announce(delta < 0 ? 'Déjà en première position.' : 'Déjà en dernière position.');
return;
}
if(isYoutube.value) newPlaylistTitle.value = urlLink.value;
isLoading.value = true;
IORequest("/PLAYLISTS/CREATE", (response) => {
isLoading.value = false;
if (response) {
Events.emit("logic:init");
modal.value.close();
} else {
error.value = "La playlist n'a pas pu être créée. Vérifier l'URL renseignée.";
}
newPlaylistTitle.value = '';
isYoutube.value = false;
urlLink.value = null;
}, { name: newPlaylistTitle.value, url: urlLink.value });
const pending = playlistStore.movePlaylist(scope.value, id, delta);
announce(`« ${playlist.title} » déplacée en position ${to + 1} sur ${list.length}.`);
// Le nœud a changé de place dans le DOM : on lui rend le focus
await nextTick();
document.querySelector(`${focusSelector}="${CSS.escape(id)}"]`)?.focus();
const result = await pending;
if (!result.ok) {
announce("Le nouvel ordre n'a pas pu être enregistré.");
playlistStore.notify(result.message, { tone: 'error' });
}
}
function onHandleKeydown(event, playlist) {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
move(playlist, event.key === 'ArrowUp' ? -1 : 1, '[data-handle-id');
}
}
function onItemKeydown(event, playlist) {
if (!event.altKey || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return;
event.preventDefault();
move(playlist, event.key === 'ArrowUp' ? -1 : 1, '[data-item-id');
}
// Sur mobile, la liste est dans un carrousel qui change de page au moindre glissement horizontal :
// un toucher sur la poignée ne doit pas lui parvenir
function onListTouchStart(event) {
if (event.target instanceof Element && event.target.closest('.playlist__handle')) event.stopPropagation();
}
// Le filtre ne suit pas d'une liste à l'autre
watch(scope, () => {
filter.value = '';
});
onMounted(() => {
// Les listes sont aussi chargées à l'entrée sur un serveur (GUILD_JOINED) : ici seulement si elles manquent
if (!playlistStore.personalLoaded && !playlistStore.personalLoading) playlistStore.fetchPersonal();
if (!playlistStore.guildLoaded && !playlistStore.guildLoading && globalStore.actualServer) playlistStore.fetchGuild();
});
</script>
<style scoped>
label {
color: var(--text-secondary);
}
.text-loading {
color: var(--text-secondary);
display: flex;
align-items: center;
font-size: 0.9em;
gap: 5px;
cursor: pointer;
text-align: center;
justify-content: center;
}
.p-modal-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.playlist {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
min-height: 0;
width: 100%;
}
.playlist__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.playlist__sort {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.playlist__dir {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid var(--border);
border-radius: 50%;
background: transparent;
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
outline: 2px solid transparent;
transition: background-color var(--dur-fast) ease, color var(--dur-fast) ease;
}
.playlist__dir:hover {
background-color: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--text);
}
.playlist__dir:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.playlist__heading {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
min-width: 0;
font-size: 1.05em;
font-weight: 600;
line-height: 1.3;
}
.playlist__heading-icon {
color: var(--main);
}
.playlist__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 20px;
padding: 0 7px;
box-sizing: border-box;
border-radius: var(--radius-pill);
background-color: color-mix(in srgb, var(--text) 10%, transparent);
color: var(--text-secondary);
font-size: 0.72rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.playlist__filter {
position: relative;
display: flex;
align-items: center;
}
.playlist__filter-icon {
position: absolute;
left: 10px;
font-size: 0.8em;
color: var(--text-secondary);
pointer-events: none;
}
.playlist__filter-input {
width: 100%;
box-sizing: border-box;
padding-left: 30px;
font-size: 0.875rem;
}
.playlist__note {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: 0.78em;
line-height: 1.35;
color: var(--text-secondary);
}
.playlist__scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
margin-right: -6px;
padding-right: 6px;
display: flex;
flex-direction: column;
}
.playlist-items {
.playlist__state {
flex: 1;
overflow-y: auto;
display: flex;
position: relative;
align-items: center;
justify-content: center;
min-height: 140px;
}
.playlist__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.playlist__row {
display: flex;
align-items: center;
min-width: 0;
border-radius: var(--radius);
}
.playlist__row :deep(.playlist-item) {
flex: 1;
}
/* Poignée de déplacement : visible au survol / au focus (toujours sur écran tactile) */
.playlist__handle {
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;
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;
}
.playlist__row:hover .playlist__handle,
.playlist__row:focus-within .playlist__handle,
.playlist__handle:focus-visible {
opacity: 1;
}
.playlist__handle:hover {
color: var(--text);
background-color: color-mix(in srgb, var(--text) 8%, transparent);
}
.playlist__handle:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--main);
color: var(--text);
}
.playlist__handle:active {
cursor: grabbing;
}
@media (hover: none) {
.playlist__handle {
opacity: 1;
}
}
.playlist__row--ghost {
opacity: 0.45;
background-color: var(--main-soft);
}
.playlist__row--chosen :deep(.playlist-item) {
background-color: color-mix(in srgb, var(--text) 9%, transparent);
}
.playlist__row--drag {
background-color: var(--tertiary);
box-shadow: var(--shadow);
}
.playlist__create {
flex-shrink: 0;
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.playlist-items {
flex-direction: column;
.playlist {
gap: 8px;
}
.playlist__heading {
font-size: 1em;
}
}
.playlist-items-list {
position: absolute;
display: flex;
flex-direction: column;
gap: 10px;
}
.add-playlist {
width: 100%;
}
p {
margin: 2px 0px;
}
.title {
font-size: 18px;
margin: 0;
}
.none {
text-align: center;
color: var(--text-secondary);
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
font-size: 1.5em;
justify-content: center;
flex: 1;
}
.none-title {
gap: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.none-info {
text-align: center;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 5px;
font-size: 0.6em;
justify-content: center;
}
</style>
+61 -79
View File
@@ -1,118 +1,100 @@
<template>
<section class="queue-div">
<Glider v-model="selectedTab">
<span>Liste de lecture</span>
<section class="queue-div" aria-label="File de lecture">
<Glider v-model="selectedTab" label="Listes du player" :id-prefix="tabsId">
<span>À suivre</span>
<span>Historique</span>
</Glider>
<div class="list-wrapper">
<Transition :name="direction">
<component :is="selectedTab === 0 ? NextList : PreviousList" :key="selectedTab" :online="online" v-bind="selectedTab === 0 ? { nextList, nextCount } : { previousList }"></component>
</Transition>
<div
class="list-wrapper"
role="tabpanel"
:id="`${tabsId}-panel-${selectedTab}`"
:aria-labelledby="`${tabsId}-tab-${selectedTab}`"
>
<Transition :name="direction">
<component
:is="selectedTab === 0 ? NextList : PreviousList"
:key="selectedTab"
:online="online"
v-bind="selectedTab === 0 ? { nextList, nextCount } : { previousList }"
></component>
</Transition>
</div>
</section>
</template>
<script setup>
import { useRouter } from 'vue-router';
import { useGlobalStore } from '@/stores/globalStore';
import { useUserStore } from '@/stores/userStore';
import Glider from '../UI/Glider.vue';
import { computed, ref, watch } from 'vue';
import NextList from '../Widget/Queue/NextList.vue';
import PreviousList from '../Widget/Queue/PreviousList.vue';
import { usePlayerStore } from '@/stores/playerStore';
const router = useRouter();
const globalStore = useGlobalStore();
const userStore = useUserStore();
// Files lues directement dans le store du player (plus d'écoute d'événement à retirer)
const playerStore = usePlayerStore();
const online = computed(() => playerStore.online)
const nextList = computed(() => playerStore.next)
const nextCount = computed(() => playerStore.nextCount)
const previousList = computed(() => playerStore.previous)
const direction = ref('forward')
const selectedTab = ref(0)
watch(selectedTab, (newTab, oldTab) => {
if (newTab > oldTab) {
direction.value = 'forward'; // glide vers la gauche
} else if (newTab < oldTab) {
direction.value = 'backward'; // glide vers la droite
}
});
/**
* File de lecture : onglets « À suivre » (réordonnable) et « Historique ».
* Les listes sont lues directement dans le store du player.
*/
import { computed, ref, useId, watch } from 'vue';
import Glider from '../UI/Glider.vue';
import NextList from '../Widget/Queue/NextList.vue';
import PreviousList from '../Widget/Queue/PreviousList.vue';
import { usePlayerStore } from '@/stores/playerStore';
const tabsId = `queue-${useId()}`;
const playerStore = usePlayerStore();
const online = computed(() => playerStore.online);
const nextList = computed(() => playerStore.next);
const nextCount = computed(() => playerStore.nextCount);
const previousList = computed(() => playerStore.previous);
const direction = ref('forward');
const selectedTab = ref(0);
watch(selectedTab, (newTab, oldTab) => {
direction.value = newTab > oldTab ? 'forward' : 'backward';
});
</script>
<style scoped>
.list-wrapper {
position: relative; /* conteneur pour les sections absolues */
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
flex: 1;
display: flex;
position: relative; /* conteneur des listes (positionnées en absolu pendant la transition) */
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
flex: 1;
display: flex;
min-height: 0;
}
/* Forward animation */
/* Glissement entre les onglets */
.forward-enter-active {
animation: slide-left-in 0.3s forwards;
animation: slide-left-in var(--dur) var(--ease) forwards;
}
.forward-leave-active {
animation: slide-left-out 0.3s forwards;
animation: slide-left-out var(--dur) var(--ease) forwards;
}
/* Backward animation */
.backward-enter-active {
animation: slide-right-in 0.3s forwards;
animation: slide-right-in var(--dur) var(--ease) forwards;
}
.backward-leave-active {
animation: slide-right-out 0.3s forwards;
animation: slide-right-out var(--dur) var(--ease) forwards;
}
/* Keyframes */
@keyframes slide-left-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slide-left-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-100%); opacity: 0; }
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-right-in {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slide-right-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* Ce bloc empêche l'animation au premier montage */
.slide-appear-from,
.slide-appear-to,
.slide-appear-active {
transition: none !important;
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
.queue-div {
display: flex;
flex: 1;
flex-direction: column;
background-color: var(--background);
border-radius: 9px;
gap: 10px;
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
min-height: 0;
border-radius: var(--radius);
gap: 12px;
}
</style>
+344 -68
View File
@@ -1,101 +1,310 @@
<template>
<div class="search">
<Icon color="#FFFFFF" icon="fa-solid fa-magnifying-glass" style="width: 20px;" />
<form class="search" role="search" aria-label="Rechercher de la musique" @submit.prevent="submit()">
<Icon class="search-icon" icon="fa-solid fa-magnifying-glass" aria-hidden="true" />
<div class="search-bar">
<input autocomplete="off" name="search" ref="searchBar" type="text" placeholder="Insérer votre recherche ici" v-model="searchQuery" />
<Icon color="#FFFFFF" v-if="searchQuery.trim() !== ''" icon="fa-solid fa-xmark" class="clear-icon" @click="searchQuery = ''" />
<SearchSuggestions v-if="searchQuery.trim() !== '' && suggestClose" :query="searchQuery" />
<label class="sr-only" :for="inputId">Rechercher un titre, un artiste ou un lien</label>
<input
:id="inputId"
ref="searchBar"
v-model="searchQuery"
type="search"
name="search"
role="combobox"
aria-autocomplete="list"
:aria-expanded="showSuggestions ? 'true' : 'false'"
:aria-controls="listId"
:aria-activedescendant="showSuggestions && activeIndex >= 0 ? optionId(activeIndex) : undefined"
:aria-busy="searching ? 'true' : undefined"
autocomplete="off"
spellcheck="false"
enterkeyhint="search"
placeholder="Rechercher un titre, un artiste ou coller un lien"
@input="onInput"
@keydown="onKeydown"
@focus="focused = true"
@blur="onBlur"
/>
<span v-if="searching" class="search-spinner" aria-hidden="true"></span>
<button
v-else-if="searchQuery.trim() !== ''"
type="button"
class="clear-button"
aria-label="Effacer la recherche"
title="Effacer la recherche"
@click="clearQuery()"
>
<Icon icon="fa-solid fa-xmark" aria-hidden="true" />
</button>
<SearchSuggestions
v-show="showSuggestions"
:list-id="listId"
:items="suggestions"
:active-index="activeIndex"
:option-id="optionId"
@select="selectSuggestion"
@hover="activeIndex = $event"
/>
</div>
<p class="sr-only" aria-live="polite">{{ liveMessage }}</p>
<div class="search-actions">
<IconAction
icon="fa-solid fa-cloud-arrow-up"
color="#FFFFFF"
title="Mes fichiers"
color="var(--main-contrast)"
label="Mes fichiers"
@click="Events.emit('VIEW_FILES')"
/>
<!-- Mobile : l'accueil n'est pas affiché par défaut, ce bouton l'ouvre (et le referme) -->
<IconAction
icon="fa-solid fa-clipboard-list"
title="Lire les informations"
class="docs-icon"
color="#FFFFFF"
icon="fa-solid fa-house"
label="Accueil"
class="home-icon"
color="var(--main-contrast)"
@click="Events.emit('VIEW_INFO')"
/>
</div>
</div>
</form>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
/**
* Barre de recherche (combobox ARIA) : suggestions YouTube au fil de la frappe,
* flèches haut / bas pour les parcourir, Entrée pour chercher, Échap pour fermer puis effacer.
* Émet sur le bus : SEARCH_STARTED, SEARCH_RESULT {data, query}, SEARCH_RESULT_SENDED.
* Écoute : VIEW_CLOSED (efface), SEARCH_RESULT_SELECTED (lance une recherche), SEARCH_FOCUS (accueil).
*/
import { computed, nextTick, onBeforeUnmount, ref, useId } from 'vue';
import IconAction from '../UI/IconAction.vue';
import { IORequest } from '@/utils/IORequest';
import Events from '@/utils/Events';
import SearchSuggestions from '@/components/Widget/View/Search/SearchSuggestions.vue';
const SUGGEST_DELAY = 200;
const SUGGEST_MAX = 8;
const uid = useId();
const inputId = `search-${uid}`;
const listId = `search-list-${uid}`;
const searchBar = ref(null);
const searchQuery = ref('');
const suggestClose = ref(true);
const suggestions = ref([]);
const activeIndex = ref(-1);
const focused = ref(false);
// Suggestions masquées après une recherche, jusqu'à la prochaine frappe
const suggestionsDismissed = ref(false);
const searching = ref(false);
const liveMessage = ref('');
onMounted(() => {
searchBar.value.addEventListener('change', () => {
find();
suggestClose.value = true;
});
let suggestTimer = null;
let suggestSeq = 0;
let pendingQuery = null;
searchBar.value.addEventListener('keydown', (event) => {
suggestClose.value = true;
});
});
const showSuggestions = computed(() => focused.value && !suggestionsDismissed.value && suggestions.value.length > 0 && searchQuery.value.trim() !== '');
function find(queryByResult) {
// If on mobile close the keyboard
if (window.innerWidth < 768) {
searchBar.value.blur();
}
if (searchQuery.value.trim() === '') {
return;
}
if(queryByResult == "result") {
Events.emit("SEARCH_RESULT_SENDED");
}
Events.emit("SEARCH_STARTED");
IORequest("/SEARCH", (data) => {
Events.emit("SEARCH_RESULT", {data: data, query: searchQuery.value});
if(queryByResult == "result") {
Events.emit("SEARCH_RESULT_SENDED");
}
}, searchQuery.value);
function optionId(index) {
return `${listId}-opt-${index}`;
}
Events.on("VIEW_CLOSED", () => {
// Suggestions de YouTube (JSONP : l'API ne permet pas de requête directe depuis le navigateur)
function fetchSuggest(query) {
return new Promise((resolve, reject) => {
const callbackName = "jsonp_callback_" + Math.random().toString(36).slice(2, 9);
const script = document.createElement("script");
const cleanup = () => {
delete window[callbackName];
script.remove();
};
window[callbackName] = (data) => {
cleanup();
resolve(data);
};
script.src = `https://suggestqueries.google.com/complete/search?client=youtube&ds=yt&q=${encodeURIComponent(query)}&callback=${callbackName}`;
script.onerror = () => {
cleanup();
reject(new Error("Suggestions indisponibles"));
};
document.body.appendChild(script);
});
}
function closeSuggestions() {
clearTimeout(suggestTimer);
suggestSeq++;
suggestions.value = [];
activeIndex.value = -1;
}
function onInput() {
suggestionsDismissed.value = false;
activeIndex.value = -1;
clearTimeout(suggestTimer);
const query = searchQuery.value.trim();
// Un lien ne donne pas de suggestion utile
if (query === '' || /^https?:\/\//i.test(query)) {
closeSuggestions();
return;
}
const seq = ++suggestSeq;
suggestTimer = setTimeout(async () => {
try {
const data = await fetchSuggest(query);
if (seq !== suggestSeq) return;
const list = Array.isArray(data?.[1]) ? data[1] : [];
suggestions.value = list
.map((entry) => (Array.isArray(entry) ? entry[0] : entry))
.filter((text) => typeof text === 'string' && text.trim() !== '')
.slice(0, SUGGEST_MAX);
activeIndex.value = -1;
} catch {
if (seq === suggestSeq) suggestions.value = [];
}
}, SUGGEST_DELAY);
}
function onKeydown(event) {
const count = suggestions.value.length;
switch (event.key) {
case 'ArrowDown':
if (count === 0) return;
event.preventDefault();
suggestionsDismissed.value = false;
activeIndex.value = activeIndex.value >= count - 1 ? 0 : activeIndex.value + 1;
break;
case 'ArrowUp':
if (count === 0) return;
event.preventDefault();
suggestionsDismissed.value = false;
activeIndex.value = activeIndex.value <= 0 ? count - 1 : activeIndex.value - 1;
break;
case 'Enter':
if (showSuggestions.value && activeIndex.value >= 0) {
event.preventDefault();
selectSuggestion(suggestions.value[activeIndex.value]);
}
// Sinon : soumission normale du formulaire
break;
case 'Escape':
if (showSuggestions.value) {
event.preventDefault();
suggestionsDismissed.value = true;
activeIndex.value = -1;
} else if (searchQuery.value !== '') {
event.preventDefault();
clearQuery();
}
break;
default:
break;
}
}
function onBlur() {
focused.value = false;
activeIndex.value = -1;
}
function submit() {
find();
}
function find() {
const query = searchQuery.value.trim();
if (query === '') return;
suggestionsDismissed.value = true;
activeIndex.value = -1;
// Sur mobile, on referme le clavier
if (window.innerWidth < 768) {
searchBar.value?.blur();
}
// Les réponses /SEARCH ne sont pas associées à leur requête : une seule recherche à la fois
if (searching.value) {
pendingQuery = query;
return;
}
searching.value = true;
liveMessage.value = `Recherche de « ${query} »…`;
Events.emit("SEARCH_STARTED");
IORequest("/SEARCH", (data) => {
searching.value = false;
Events.emit("SEARCH_RESULT", { data, query });
Events.emit("SEARCH_RESULT_SENDED");
const count = Array.isArray(data) ? data.length : (data && data.form ? 1 : 0);
liveMessage.value = count > 0 ? `Résultats pour « ${query} » affichés` : `Aucun résultat pour « ${query} »`;
if (pendingQuery !== null) {
const next = pendingQuery;
pendingQuery = null;
searchQuery.value = next;
find();
}
}, query);
}
function selectSuggestion(text) {
if (typeof text !== 'string' || text === '') return;
searchQuery.value = text;
closeSuggestions();
find();
}
function clearQuery() {
searchQuery.value = '';
});
closeSuggestions();
// Le bouton disparaît : le focus revient dans le champ plutôt que sur le document
searchBar.value?.focus();
}
Events.on("SEARCH_RESULT_SELECTED", (result) => {
function onViewClosed() {
searchQuery.value = '';
closeSuggestions();
}
// Historique : un autre composant peut lancer une recherche
function onResultSelected(result) {
if (typeof result !== 'string' || result.trim() === '') return;
searchQuery.value = result;
suggestClose.value = false;
Events.emit("SEARCH_RESULT_SENDED");
find("result");
closeSuggestions();
find();
}
// Accès rapide « Rechercher » de l'accueil
function onFocusRequest() {
nextTick(() => {
const input = searchBar.value;
if (!input) return;
input.scrollIntoView({ block: 'nearest' });
input.focus();
input.select();
});
}
Events.on("VIEW_CLOSED", onViewClosed);
Events.on("SEARCH_RESULT_SELECTED", onResultSelected);
Events.on("SEARCH_FOCUS", onFocusRequest);
onBeforeUnmount(() => {
clearTimeout(suggestTimer);
suggestSeq++;
Events.off("VIEW_CLOSED", onViewClosed);
Events.off("SEARCH_RESULT_SELECTED", onResultSelected);
Events.off("SEARCH_FOCUS", onFocusRequest);
});
</script>
<style scoped>
.search {
display: flex;
align-items: center;
padding: 10px;
gap: 5px;
margin: 0;
padding: 10px 12px;
gap: 10px;
background-color: var(--main);
border-radius: 10px;
box-shadow: 2px 2px 10px 0px rgba(0, 0, 0, 0.50);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.clear-icon {
cursor: pointer;
position: absolute;
right: 10px;
.search-icon {
width: 20px;
flex-shrink: 0;
color: var(--main-contrast);
}
.search-bar {
@@ -103,37 +312,104 @@ Events.on("SEARCH_RESULT_SELECTED", (result) => {
flex: 1;
display: flex;
align-items: center;
min-width: 0;
}
.search input {
border: none;
outline: none;
background: var(--secondary);
color: var(--text-primary);
border-radius: 10px;;
flex: 1;
padding: 10px 10px;
border-radius: 20px;
min-width: 0;
border: none;
background: var(--secondary);
color: var(--text);
padding: 10px 40px 10px 16px;
border-radius: var(--radius-pill);
font-size: 0.95em;
}
/* L'anneau global (fond + rose) se perdrait sur la barre rose : anneau clair à la place */
.search input:focus {
box-shadow: 0 0 0 2px var(--main-contrast);
}
.search input::placeholder {
color: var(--text-secondary);
opacity: .8;
}
.docs-icon {
/* type="search" : on garde notre propre bouton d'effacement */
.search input::-webkit-search-cancel-button,
.search input::-webkit-search-decoration {
-webkit-appearance: none;
appearance: none;
}
.clear-button {
position: absolute;
right: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-secondary);
font: inherit;
cursor: pointer;
transition: background-color var(--dur-fast) ease, color var(--dur-fast) ease;
}
.clear-button:hover {
background-color: color-mix(in srgb, var(--text) 12%, transparent);
color: var(--text);
}
.clear-button:focus-visible {
outline: 2px solid var(--main);
outline-offset: 0;
}
.search-spinner {
position: absolute;
right: 12px;
width: 16px;
height: 16px;
box-sizing: border-box;
border: 2px solid color-mix(in srgb, var(--main) 30%, transparent);
border-top-color: var(--main);
border-radius: 50%;
animation: u-spin 0.7s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.search-spinner {
animation-duration: 1.6s !important;
animation-iteration-count: infinite !important;
}
}
.home-icon {
display: none;
}
.search-actions {
display: flex;
align-items: center;
gap: 10px;
gap: 14px;
padding: 0 4px;
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.docs-icon {
display: block;
.home-icon {
display: inline-flex;
}
.search {
padding: 8px 10px;
gap: 8px;
}
}
</style>
+123 -83
View File
@@ -5,6 +5,7 @@
@click="returnDefault()"
v-if="!actualComponent.unclosable"
icon="fa-solid fa-xmark"
label="Fermer"
class="close"
/>
@@ -23,17 +24,19 @@
<script setup>
import Box from '../UI/Box.vue';
import Events from '@/utils/Events';
import { shallowRef, ref, watch, onMounted } from 'vue';
import { shallowRef, ref, watch, onMounted, onUnmounted } from 'vue';
import LoadingView from '@/components/Widget/View/LoadingView.vue';
import SearchResults from '@/components/Widget/View/SearchResults.vue';
import Default from '../Widget/View/Default.vue';
import IconAction from '../UI/IconAction.vue';
import { useGlobalStore } from '@/stores/globalStore';
import { usePlayerStore } from '@/stores/playerStore';
import UploadFiles from '../Widget/View/UploadFiles.vue';
import PlaylistView from '../Widget/View/Playlist/PlaylistView.vue';
import Advice from '../Widget/View/Home/Advice.vue';
import History from '../Widget/View/Home/History.vue';
import LyricsView from '../Widget/View/LyricsView.vue';
const defaultComponent = {
component: Default,
@@ -43,6 +46,7 @@ const defaultComponent = {
};
const globalStore = useGlobalStore();
const playerStore = usePlayerStore();
const actualComponent = shallowRef(defaultComponent);
// Nouveaux états
@@ -56,6 +60,8 @@ onMounted(() => {
watch(() => actualComponent.value, (value) => {
Events.emit('view:change', value.component);
globalStore.isViewShowing = !(value && value.default);
// Le bouton « Paroles » du player affiche l'état ouvert / fermé
playerStore.setLyricsOpen(value && value.component === LyricsView);
});
function returnDefault() {
@@ -73,97 +79,131 @@ function finishLoading() {
}
// === EVENTS ===
Events.on("SEARCH_STARTED", () => {
setLoading("Recherche en cours");
});
// Gestionnaires nommés : retirés au démontage (évite les doublons si la vue est remontée sans navigation)
const handlers = {
SEARCH_STARTED() {
setLoading("Recherche en cours");
},
Events.on("SEARCH_RESULT", async (data) => {
setLoading("Chargement des résultats de recherche...");
async SEARCH_RESULT(data) {
setLoading("Chargement des résultats de recherche...");
const isPlaylist = data?.data?.form === "PLAYLIST";
const isOneSong = data?.data?.form === "SONG";
if (isOneSong) data.data = [data.data];
const isPlaylist = data?.data?.form === "PLAYLIST";
const isOneSong = data?.data?.form === "SONG";
if (isOneSong) data.data = [data.data];
actualComponent.value = {
component: SearchResults,
props: {
results: data.data,
playlist: isPlaylist,
query: data.query,
},
};
actualComponent.value = {
component: SearchResults,
props: {
results: data.data,
playlist: isPlaylist,
query: data.query,
},
};
finishLoading(); // on enlève le loader seulement une fois que le composant est prêt
});
finishLoading(); // on enlève le loader seulement une fois que le composant est prêt
},
Events.on("VIEW_INFO", () => {
setLoading("Chargement des informations...");
VIEW_INFO() {
setLoading("Chargement des informations...");
if (actualComponent.value.onlyMobile) {
if (actualComponent.value.onlyMobile) {
returnDefault();
finishLoading();
return;
}
actualComponent.value = {
component: Default,
onlyMobile: true,
props: {}
};
finishLoading();
},
VIEW_FILES() {
setLoading("Chargement des fichiers...");
actualComponent.value = {
component: UploadFiles,
props: {}
};
finishLoading();
},
VIEW_PLAYLIST(data) {
setLoading("Chargement de la playlist...");
actualComponent.value = {
component: PlaylistView,
props: {
playlist: data.playlist,
hasBeenActualized: data.hasBeenActualized,
// Portée explicite (personnel / serveur) quand l'émetteur la connaît
scope: data.scope,
},
};
finishLoading();
},
VIEW_RESET() {
returnDefault();
finishLoading();
return;
},
VIEW_HELP() {
setLoading("Chargement de l'aide...");
actualComponent.value = {
component: Advice,
props: {}
};
finishLoading();
},
VIEW_HISTORY() {
setLoading("Chargement de l'historique...");
actualComponent.value = {
component: History,
props: {}
};
finishLoading();
},
// Paroles : bascule ouverte / fermée ({ toggle: false } force l'ouverture).
// LyricsView lit le player en direct : elle suit les changements de titre sans être recréée.
VIEW_LYRICS(data) {
if (actualComponent.value.component === LyricsView) {
if (data && data.toggle === false) return;
returnDefault();
finishLoading();
return;
}
actualComponent.value = {
component: LyricsView,
props: {}
};
finishLoading();
},
};
for (const [name, handler] of Object.entries(handlers)) {
Events.on(name, handler);
}
onUnmounted(() => {
for (const [name, handler] of Object.entries(handlers)) {
Events.off(name, handler);
}
actualComponent.value = {
component: Default,
onlyMobile: true,
props: {}
};
finishLoading();
});
Events.on("VIEW_FILES", () => {
setLoading("Chargement des fichiers...");
actualComponent.value = {
component: UploadFiles,
props: {}
};
finishLoading();
});
Events.on("VIEW_PLAYLIST", (data) => {
setLoading("Chargement de la playlist...");
actualComponent.value = {
component: PlaylistView,
props: {
playlist: data.playlist,
hasBeenActualized: data.hasBeenActualized,
},
};
finishLoading();
});
Events.on("VIEW_RESET", () => {
returnDefault();
finishLoading();
});
Events.on("VIEW_HELP", () => {
setLoading("Chargement de l'aide...");
actualComponent.value = {
component: Advice,
props: {}
};
finishLoading();
});
Events.on("VIEW_HISTORY", () => {
setLoading("Chargement de l'historique...");
actualComponent.value = {
component: History,
props: {}
};
finishLoading();
playerStore.setLyricsOpen(false);
});
</script>
<style scoped>
+7 -6
View File
@@ -1,14 +1,14 @@
<template>
<Teleport to="body">
<div class="announcements">
<div class="announcements" role="region" aria-label="Annonces" aria-live="polite">
<TransitionGroup name="announce">
<div v-for="item in items" :key="item.id" :class="['announcement', item.kind]">
<Icon class="announcement-icon" :icon="item.icon" :spin-pulse="item.kind === 'restart' && !item.back"/>
<Icon class="announcement-icon" :icon="item.icon" :spin-pulse="item.kind === 'restart' && !item.back" aria-hidden="true"/>
<div class="announcement-content">
<p class="announcement-title">{{ item.title }}</p>
<p class="announcement-message">{{ item.message }}</p>
</div>
<IconAction icon="fa-solid fa-xmark" size="1em" title="Fermer" :color="item.kind === 'restart' ? '#FFFFFF' : 'var(--text)'" @click="dismiss(item.id)"/>
<IconAction icon="fa-solid fa-xmark" size="1em" label="Fermer l'annonce" :color="item.kind === 'restart' ? 'var(--main-contrast)' : 'var(--text)'" @click="dismiss(item.id)"/>
</div>
</TransitionGroup>
</div>
@@ -128,7 +128,8 @@ onUnmounted(() => {
border-radius: 10px;
background-color: var(--secondary);
color: var(--text);
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.35);
box-shadow: var(--shadow-lg);
border: 1px solid var(--border);
border-left: 5px solid var(--main);
pointer-events: auto;
}
@@ -150,12 +151,12 @@ onUnmounted(() => {
.announcement.restart {
background: linear-gradient(90deg, var(--main-active), var(--main));
border-left-color: var(--main-active);
color: #FFFFFF;
color: var(--main-contrast);
}
.announcement.restart .announcement-icon,
.announcement.restart .announcement-message {
color: #FFFFFF;
color: var(--main-contrast);
}
.announcement-content {
+68 -24
View File
@@ -1,25 +1,37 @@
<template>
<div>
<img :class="imgClass ? imgClass : mobile ? 'avatar-mobile' : 'img'" :style="width ? `width: ${width}; height: ${height};` : ''" :src="`https://cdn.discordapp.com/avatars/${userId}/${avatarUrl}`" alt='User Avatar'>
<Icon v-if="tag === 'admin'" style="color: var(--admin-color);" icon="fa-solid fa-star" class="tag" />
<Icon v-if="tag === 'owner'" style="color: var(--owner-color);" icon="fa-solid fa-crown" class="tag" />
<Icon v-if="tag === 'mod'" style="color: var(--mod-color);" icon="fa-solid fa-shield-halved" class="tag" />
<div class="ui-avatar">
<img
:class="imgClass ? imgClass : mobile ? 'avatar-mobile' : 'img'"
:style="width ? `width: ${width}; height: ${height || width};` : ''"
:src="source"
:alt="alt"
loading="lazy"
decoding="async"
@error="failed = true"
>
<span v-if="tag" class="tag" :title="tag.label">
<Icon :style="{ color: tag.color }" :icon="tag.icon" aria-hidden="true" />
<span class="sr-only">{{ tag.label }}</span>
</span>
</div>
</template>
<script setup>
import { computed } from 'vue';
/**
* Avatar Discord rond d'un utilisateur, avec pastille de rôle (administrateur, propriétaire, modérateur).
* Props : userId, avatarUrl (hash Discord ; vide ou invalide → avatar par défaut), isMod / isOwner / isAdmin,
* width / height (taille CSS), imgClass (classe de l'image), mobile, alt (texte alternatif ; vide par
* défaut car le nom est presque toujours affiché à côté).
*/
import { computed, ref, watch } from 'vue';
const props = defineProps({
userId: {
type: String,
required: true
default: null
},
avatarUrl: {
type: String,
required: true
default: null
},
isMod: {
type: Boolean,
@@ -48,16 +60,41 @@ const props = defineProps({
mobile: {
type: Boolean,
default: false
},
alt: {
type: String,
default: ''
}
});
const tag = computed(() => {
if (props.isAdmin) return 'admin';
if (props.isOwner) return 'owner';
if (props.isMod) return 'mod';
return '';
const failed = ref(false);
watch(() => [props.userId, props.avatarUrl], () => {
failed.value = false;
});
// Avatar par défaut de Discord : (id >> 22) % 6 (identifiant trop long pour un Number exact → BigInt)
const fallback = computed(() => {
const id = String(props.userId || '');
let index = 0;
try {
if (/^\d+$/.test(id)) index = Number((BigInt(id) >> 22n) % 6n);
} catch {
index = 0;
}
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
});
const source = computed(() => {
if (failed.value || !props.userId || !props.avatarUrl || props.avatarUrl === 'null') return fallback.value;
return `https://cdn.discordapp.com/avatars/${props.userId}/${props.avatarUrl}`;
});
const tag = computed(() => {
if (props.isAdmin) return { label: 'Administrateur', icon: 'fa-solid fa-star', color: 'var(--admin-color)' };
if (props.isOwner) return { label: 'Propriétaire du serveur', icon: 'fa-solid fa-crown', color: 'var(--owner-color)' };
if (props.isMod) return { label: 'Modérateur', icon: 'fa-solid fa-shield-halved', color: 'var(--mod-color)' };
return null;
});
</script>
<style scoped>
.img {
@@ -70,21 +107,30 @@ const tag = computed(() => {
img {
border-radius: 100%;
object-fit: cover;
}
div {
.ui-avatar {
position: relative;
display: inline-block;
flex-shrink: 0;
line-height: 0;
}
.tag {
position: absolute;
bottom: 0;
right: 0;
font-size: 0.8em;
color: white;
bottom: -2px;
right: -2px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
padding: 2px 5px;
background-color: var(--secondary);
box-shadow: 0 0 0 2px var(--secondary);
font-size: 0.7em;
line-height: 1;
}
.avatar-welcome {
@@ -101,6 +147,4 @@ div {
height: 80px !important;
}
}
</style>
+13 -5
View File
@@ -6,11 +6,11 @@
<style scoped>
div {
background-color: var(--secondary);
border-radius: 10px;
border-radius: var(--radius);
}
.box-shadow {
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.35);
box-shadow: var(--shadow);
}
@@ -23,13 +23,19 @@
}
.first {
background-color: var(--secondary);
background-color: var(--surface-1);
}
.second {
background-color: var(--tertiary);
background-color: var(--surface-2);
}
</style>
<script setup>
/**
* Panneau de fond (ancienne primitive de mise en page : en-têtes, file, vue…).
* Props : level ('first' = --surface-1, 'second' = --surface-2), padding ('far' 30 px, 'closed' 10 px),
* boxClass (classes ajoutées), width (largeur max. ; sinon largeur naturelle), noShadow, overbox (coins bas carrés).
* Pour un nouveau bloc avec titre / actions, préférer Card.
*/
import { computed } from 'vue';
const props = defineProps({
@@ -62,7 +68,9 @@ const props = defineProps({
const widthStyle = computed(() => {
if (props.width) {
return `max-width: ${props.width}; width: 70%;`;
// Largeur max. demandée, mais jamais collée aux bords sur petit écran (avant : 70 %, trop étroit sur mobile)
const width = props.width.replace(/;+\s*$/, '');
return `max-width: ${width}; width: calc(100% - 40px); box-sizing: border-box;`;
}
return '';
});
+267 -42
View File
@@ -1,83 +1,308 @@
<template>
<button :disabled="props.disabled" :class="disableClass" @click="$emit('click')">
<Icon v-if="props.icon" :icon="props.icon" />
<!-- Lien interne (vue-router) ou externe rendu avec l'apparence d'un bouton -->
<router-link
v-if="isRouterLink"
:to="to"
:class="classes"
:aria-busy="loading ? 'true' : undefined"
>
<span v-if="loading" class="ui-btn__spinner" aria-hidden="true"></span>
<Icon v-else-if="icon" class="ui-btn__icon" :icon="icon" aria-hidden="true" />
<slot></slot>
<Icon v-if="iconRight" class="ui-btn__icon" :icon="iconRight" aria-hidden="true" />
</router-link>
<a
v-else-if="isAnchor"
:href="href"
:target="target || undefined"
:rel="anchorRel"
:class="classes"
:aria-busy="loading ? 'true' : undefined"
@click="onClick"
>
<span v-if="loading" class="ui-btn__spinner" aria-hidden="true"></span>
<Icon v-else-if="icon" class="ui-btn__icon" :icon="icon" aria-hidden="true" />
<slot></slot>
<Icon v-if="iconRight" class="ui-btn__icon" :icon="iconRight" aria-hidden="true" />
</a>
<button
v-else
:type="type"
:class="classes"
:disabled="disabled || loading"
:aria-busy="loading ? 'true' : undefined"
@click="onClick"
>
<span v-if="loading" class="ui-btn__spinner" aria-hidden="true"></span>
<Icon v-else-if="icon" class="ui-btn__icon" :icon="icon" aria-hidden="true" />
<slot></slot>
<Icon v-if="iconRight" class="ui-btn__icon" :icon="iconRight" aria-hidden="true" />
</button>
</template>
<script setup>
/**
* Bouton du kit UI.
* Props :
* variant 'primary' (rose, défaut) | 'secondary' (surface) | 'ghost' (transparent) | 'danger'
* size 'sm' | 'md' (défaut) | 'lg'
* icon / iconRight icône Font Awesome avant / après le texte (décorative)
* loading spinner + aria-busy, clics ignorés ; disabled ; block (pleine largeur) ; type (button par défaut)
* to route vue-router → rendu <router-link> (lien, pas de bouton imbriqué dans un lien)
* href / target lien externe → rendu <a> (rel="noopener" ajouté pour target="_blank")
* colorLower (historique) équivaut à variant="secondary"
* Événement : click (non émis si disabled / loading).
* Un lien désactivé ou en chargement est rendu comme un <button disabled> (un lien ne se désactive pas).
* Icône seule : utiliser plutôt IconAction (nom accessible obligatoire).
*/
import { computed } from 'vue';
const events = defineEmits(['click']);
const emit = defineEmits(['click']);
const props = defineProps({
disabled: {
type: Boolean,
default: false
variant: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'ghost', 'danger'].includes(value)
},
colorLower: {
type: Boolean,
default: false
size: {
type: String,
default: 'md',
validator: (value) => ['sm', 'md', 'lg'].includes(value)
},
icon: {
type: String,
default: null
},
iconRight: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
block: {
type: Boolean,
default: false
},
type: {
type: String,
default: 'button'
},
to: {
type: [String, Object],
default: null
},
href: {
type: String,
default: null
},
target: {
type: String,
default: null
},
colorLower: {
type: Boolean,
default: false
}
});
const disableClass = computed(() => {
return props.disabled ? props.colorLower ? 'disabled color-lower' : 'disabled' : '';
const inactive = computed(() => props.disabled || props.loading);
const isRouterLink = computed(() => !!props.to && !inactive.value);
const isAnchor = computed(() => !props.to && !!props.href && !inactive.value);
const anchorRel = computed(() => (props.target === '_blank' ? 'noopener noreferrer' : undefined));
const resolvedVariant = computed(() => {
// Ancien attribut color-lower : il ne s'appliquait qu'aux boutons désactivés (bug), il vaut désormais "secondary"
if (props.colorLower && props.variant === 'primary') return 'secondary';
return props.variant;
});
const classes = computed(() => [
'ui-btn',
`ui-btn--${resolvedVariant.value}`,
`ui-btn--${props.size}`,
{
'ui-btn--block': props.block,
'ui-btn--loading': props.loading
}
]);
function onClick(event) {
if (inactive.value) {
event.preventDefault();
return;
}
emit('click', event);
}
</script>
<style scoped>
button {
background-color: var(--main);
color: rgb(255, 255, 255);
border: none;
border-radius: 5px;
padding: 7px;
/*
* Sélecteurs en :where(...) : spécificité minimale, pour qu'une classe posée par le parent
* (ex. .cancel, .add-playlist, .admin-button) garde la priorité comme avant.
* La couleur du texte passe par --btn-fg-current : un <a> rendu en bouton n'hérite pas des couleurs globales des liens.
*/
:where(.ui-btn) {
--btn-bg: var(--main);
--btn-fg: var(--main-contrast);
--btn-border: transparent;
--btn-bg-hover: var(--main-hover);
--btn-bg-active: var(--main-active);
--btn-fg-hover: var(--btn-fg);
--btn-fg-current: var(--btn-fg);
/* flex (et non inline-flex) comme avant : un bouton reste seul sur sa ligne dans un bloc */
display: flex;
gap: 5px;
justify-content: center;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
padding: 0 14px;
box-sizing: border-box;
border: 1px solid var(--btn-border);
border-radius: var(--radius-sm);
background-color: var(--btn-bg);
color: var(--btn-fg-current);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.2;
text-decoration: none;
transition: all 0.3s ease;
}
button:hover {
background-color: var(--main-hover);
text-align: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
outline: 2px solid transparent;
outline-offset: 2px;
transition:
background-color var(--dur-fast) ease,
color var(--dur-fast) ease,
border-color var(--dur-fast) ease,
box-shadow var(--dur-fast) ease,
transform var(--dur-fast) ease;
}
button:active {
background-color: var(--main-active);
/* Les liens globaux (a, a:hover) sont plus spécifiques que :where() : on les neutralise pour les liens-boutons */
a.ui-btn,
a.ui-btn:hover {
color: var(--btn-fg-current);
text-decoration: none;
}
/* Variantes */
:where(.ui-btn--secondary) {
--btn-bg: var(--tertiary);
--btn-fg: var(--text);
--btn-bg-hover: var(--quaternary);
--btn-bg-active: color-mix(in srgb, var(--quaternary) 80%, var(--primary));
}
:where(.ui-btn--ghost) {
--btn-bg: transparent;
--btn-fg: var(--text);
--btn-bg-hover: var(--main-soft);
--btn-bg-active: color-mix(in srgb, var(--main) 26%, transparent);
}
:where(.ui-btn--danger) {
--btn-bg: color-mix(in srgb, var(--text-error) 14%, transparent);
--btn-fg: var(--text-error);
--btn-border: color-mix(in srgb, var(--text-error) 45%, transparent);
--btn-bg-hover: var(--text-error);
--btn-bg-active: color-mix(in srgb, var(--text-error) 80%, black);
--btn-fg-hover: #fff;
}
/* Tailles */
:where(.ui-btn--sm) {
min-height: 30px;
padding: 0 10px;
gap: 5px;
font-size: 0.8125rem;
}
:where(.ui-btn--lg) {
min-height: 44px;
padding: 0 18px;
gap: 8px;
font-size: 1rem;
}
:where(.ui-btn--block) {
width: 100%;
}
/* États */
:where(.ui-btn:hover:not(:disabled)) {
background-color: var(--btn-bg-hover);
--btn-fg-current: var(--btn-fg-hover);
}
:where(.ui-btn:active:not(:disabled)) {
background-color: var(--btn-bg-active);
--btn-fg-current: var(--btn-fg-hover);
transform: scale(0.98);
}
.disabled {
/* Hors :where() : doit l'emporter sur le contour global :focus-visible (remplacé par l'anneau doublé) */
.ui-btn:focus-visible {
outline-color: transparent;
box-shadow: var(--focus-ring);
}
:where(.ui-btn:disabled) {
cursor: not-allowed;
transform: none;
}
/* Bouton principal désactivé : gris, comme avant (plus lisible qu'un rose délavé) */
:where(.ui-btn--primary:disabled:not(.ui-btn--loading)) {
background-color: var(--tertiary);
color: var(--text-secondary);
cursor: not-allowed;
--btn-fg-current: var(--text-secondary);
}
.disabled:hover {
background-color: var(--tertiary) !important;
cursor: not-allowed;
:where(.ui-btn--secondary:disabled, .ui-btn--ghost:disabled, .ui-btn--danger:disabled):where(:not(.ui-btn--loading)) {
opacity: 0.55;
}
.disabled:active {
background-color: var(--tertiary) !important;
transform: none !important ;
:where(.ui-btn--loading) {
cursor: progress;
}
.color-lower {
background-color: var(--secondary) !important;
.ui-btn__icon {
flex-shrink: 0;
}
.color-lower:hover {
background-color: var(--secondary) !important;
.ui-btn__spinner {
width: 1em;
height: 1em;
flex-shrink: 0;
box-sizing: border-box;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: u-spin 0.7s linear infinite;
}
.color-lower:active {
background-color: var(--secondary) !important;
@media (prefers-reduced-motion: reduce) {
.ui-btn__spinner {
/* Le spinner reste visible mais tourne lentement : c'est une information, pas une décoration */
animation-duration: 1.6s !important;
animation-iteration-count: infinite !important;
}
:where(.ui-btn:active:not(:disabled)) {
transform: none;
}
}
/* Mode contraste élevé : le contour transparent devient visible au focus */
@media (forced-colors: active) {
.ui-btn:focus-visible {
outline-color: Highlight;
}
}
</style>
+206
View File
@@ -0,0 +1,206 @@
<template>
<component
:is="as"
:class="[
'ui-card',
`ui-card--${level === 'first' ? 'first' : 'second'}`,
`ui-card--tone-${tone}`,
{ 'ui-card--flush': flush }
]"
>
<header v-if="hasHeader" class="ui-card__header">
<span v-if="icon" class="ui-card__chip" aria-hidden="true"><Icon :icon="icon" /></span>
<div v-if="title || subtitle" class="ui-card__heading">
<component :is="headingTag" v-if="title" class="ui-card__title">{{ title }}</component>
<p v-if="subtitle" class="ui-card__subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.actions" class="ui-card__actions">
<slot name="actions"></slot>
</div>
</header>
<div class="ui-card__body">
<slot></slot>
</div>
<footer v-if="$slots.footer" class="ui-card__footer">
<slot name="footer"></slot>
</footer>
</component>
</template>
<script setup>
/**
* Carte générique (accueil, paroles, paramètres…).
* Props : title, subtitle, icon (pastille teintée), tone (default | main | success | warning | error | info),
* level ('first' = fond --secondary, 'second' = --tertiary), flush (corps sans marge),
* as (balise racine, 'section' par défaut), headingLevel (2 à 6, h3 par défaut).
* Slots : `actions` (à droite de l'en-tête), défaut (corps), `footer` (bas de carte).
*/
import { computed, useSlots } from 'vue';
const props = defineProps({
title: {
type: String,
default: null
},
icon: {
type: String,
default: null
},
subtitle: {
type: String,
default: null
},
tone: {
type: String,
default: 'default',
validator: (value) => ['default', 'main', 'success', 'warning', 'error', 'info'].includes(value)
},
level: {
type: String,
default: 'second',
validator: (value) => ['first', 'second'].includes(value)
},
flush: {
type: Boolean,
default: false
},
as: {
type: String,
default: 'section'
},
headingLevel: {
type: [Number, String],
default: 3
}
});
const slots = useSlots();
const headingTag = computed(() => {
const level = Number(props.headingLevel);
return Number.isInteger(level) && level >= 2 && level <= 6 ? `h${level}` : 'h3';
});
const hasHeader = computed(() => !!(props.title || props.icon || props.subtitle || slots.actions));
</script>
<style scoped>
.ui-card {
--card-tone: var(--text);
--card-chip-bg: color-mix(in srgb, var(--text) 10%, transparent);
--card-pad: 14px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
box-sizing: border-box;
padding: var(--card-pad);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
}
.ui-card--first {
background-color: var(--secondary);
}
.ui-card--second {
background-color: var(--tertiary);
}
/* Tons : teinte de la pastille d'icône */
.ui-card--tone-main { --card-tone: var(--main); --card-chip-bg: var(--main-soft); }
.ui-card--tone-success { --card-tone: var(--text-success); --card-chip-bg: color-mix(in srgb, var(--text-success) 16%, transparent); }
.ui-card--tone-warning { --card-tone: var(--text-warning); --card-chip-bg: color-mix(in srgb, var(--text-warning) 16%, transparent); }
.ui-card--tone-error { --card-tone: var(--text-error); --card-chip-bg: color-mix(in srgb, var(--text-error) 16%, transparent); }
.ui-card--tone-info { --card-tone: var(--text-info); --card-chip-bg: color-mix(in srgb, var(--text-info) 16%, transparent); }
.ui-card__header {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.ui-card__chip {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
background-color: var(--card-chip-bg);
color: var(--card-tone);
font-size: 0.95em;
}
.ui-card__heading {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.ui-card__title {
margin: 0;
font-size: 1em;
font-weight: 600;
line-height: 1.3;
overflow-wrap: anywhere;
}
.ui-card__subtitle {
margin: 0;
font-size: 0.85em;
line-height: 1.35;
color: var(--text-secondary);
}
.ui-card__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-left: auto;
}
.ui-card__body {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.ui-card__footer {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
/* Corps sans marge (liste, image pleine largeur…) : en-tête et pied gardent leur marge */
.ui-card--flush {
padding: 0;
gap: 0;
overflow: hidden;
}
.ui-card--flush .ui-card__header {
padding: var(--card-pad) var(--card-pad) 12px var(--card-pad);
}
.ui-card--flush .ui-card__footer {
padding: 12px var(--card-pad);
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.ui-card {
--card-pad: 12px;
}
}
</style>
+52 -22
View File
@@ -1,7 +1,7 @@
<template>
<div class="carousel">
<div ref="wrapper" class="component-wrapper">
<IconAction v-if="!disableArrow" class="carousel-arrow" icon="fa-solid fa-arrow-left" @click="changeSlot((actualIndex - 1 + allSlots.length) % allSlots.length, 'backward')" />
<IconAction v-if="!disableArrow" class="carousel-arrow" icon="fa-solid fa-arrow-left" label="Page précédente" @click="changeSlot((actualIndex - 1 + allSlots.length) % allSlots.length, 'backward')" />
<span class="component">
<Transition :name="direction" appear mode="out-in" backwards>
<span class="component-child" v-if="actualComponent" :key="actualIndex">
@@ -10,14 +10,28 @@
<p v-else :key="'empty'"><Error>Aucun composant trouvé</Error></p>
</Transition>
</span>
<IconAction v-if="!disableArrow" class="carousel-arrow" icon="fa-solid fa-arrow-right" @click="changeSlot((actualIndex + 1) % allSlots.length, 'forward')" />
<IconAction v-if="!disableArrow" class="carousel-arrow" icon="fa-solid fa-arrow-right" label="Page suivante" @click="changeSlot((actualIndex + 1) % allSlots.length, 'forward')" />
</div>
<div v-if="allSlots.length > 1 && ready" :class="props.onlyMobile ? 'onlymobile selector' : 'selector'">
<IconAction @click="() => {changeSlot(allSlots.indexOf(slot))}" :class="{'notactive': actualIndex !== allSlots.indexOf(slot), 'point': true }" v-for="slot in allSlots" :key="allSlots.indexOf(slot)" v-if="allSlots" icon="fa-solid fa-circle" />
<div v-if="allSlots.length > 1 && ready" :class="props.onlyMobile ? 'onlymobile selector' : 'selector'" role="group" aria-label="Pages">
<IconAction
v-for="(slot, index) in allSlots"
:key="index"
:class="{'notactive': actualIndex !== index, 'point': true }"
:label="pageLabel(index)"
:aria-current="actualIndex === index ? 'true' : undefined"
icon="fa-solid fa-circle"
@click="changeSlot(index)"
/>
</div>
</div>
</template>
<script setup>
/**
* Carrousel de composants (un enfant du slot par page), avec flèches et points de pagination.
* Props : disableArrow, autoPlay (inutilisé), onlyMobile (une seule page au-delà de 1280 px ; les enfants
* portant la prop `mobile` ne sont gardés que sur petit écran), pageLabels (noms lus des pages).
* Glissement horizontal (> 40 px, plus horizontal que vertical) pour changer de page sur écran tactile.
*/
import { ref, useSlots, onMounted, watch, onUnmounted, nextTick } from 'vue';
import Error from '@/components/UI/Error.vue';
import IconAction from './IconAction.vue';
@@ -44,9 +58,18 @@ const props = defineProps({
onlyMobile: {
type: Boolean,
default: false
},
pageLabels: {
type: Array,
default: () => []
}
});
function pageLabel(index) {
const name = props.pageLabels[index];
return name ? `Afficher : ${name}` : `Afficher la page ${index + 1} sur ${allSlots.value.length}`;
}
// Detect the swipe of mobile
function changeSlot(newIndex, dir) {
@@ -57,26 +80,28 @@ function changeSlot(newIndex, dir) {
actualIndex.value = newIndex;
}
// Glissement : décidé à la fin du geste, seulement s'il est franchement horizontal
// (avant : 5 px suffisaient, un défilement vertical un peu de travers changeait de page)
const SWIPE_MIN = 40;
let swipeStart = null;
function handleTouchStart(ev) {
const touch = ev.touches[0];
const startX = touch.clientX;
swipeStart = touch ? { x: touch.clientX, y: touch.clientY } : null;
}
function handleTouchMove(ev) {
const touch = ev.touches[0];
const deltaX = touch.clientX - startX;
if (deltaX > 5) {
changeSlot((actualIndex.value - 1 + allSlots.value.length) % allSlots.value.length, "backward");
} else if (deltaX < -5) {
changeSlot((actualIndex.value + 1) % allSlots.value.length, "forward");
}
wrapper.value.removeEventListener('touchmove', handleTouchMove, false);
function handleTouchEnd(ev) {
if (!swipeStart || allSlots.value.length < 2) return;
const touch = ev.changedTouches[0];
const deltaX = touch.clientX - swipeStart.x;
const deltaY = touch.clientY - swipeStart.y;
swipeStart = null;
if (Math.abs(deltaX) < SWIPE_MIN || Math.abs(deltaX) < Math.abs(deltaY) * 1.5) return;
if (deltaX > 0) {
changeSlot((actualIndex.value - 1 + allSlots.value.length) % allSlots.value.length, "backward");
} else {
changeSlot((actualIndex.value + 1) % allSlots.value.length, "forward");
}
wrapper.value.addEventListener('touchmove', handleTouchMove, false);
}
watch(() => actualIndex.value, (newIndex) => {
@@ -110,15 +135,20 @@ onMounted(() => {
updateSlots();
});
}, 10);
wrapper.value.addEventListener('touchstart', handleTouchStart, false);
wrapper.value.addEventListener('touchstart', handleTouchStart, { passive: true });
wrapper.value.addEventListener('touchend', handleTouchEnd, { passive: true });
window.addEventListener('resize', onResize);
})
window.addEventListener('resize', () => {
function onResize() {
nextTick(() => {
updateSlots();
});
}
onUnmounted(() => {
window.removeEventListener('resize', onResize);
});
function updateSlots() {
+12 -4
View File
@@ -1,16 +1,24 @@
<template>
<div><slot></slot></div>
<div class="ui-circle"><slot></slot></div>
</template>
<script setup>
/**
* Pastille ronde de 50 px (ex. « +3 » à la suite d'une pile d'avatars). Slot par défaut : le contenu.
*/
</script>
<style scoped>
div {
.ui-circle {
border-radius: 100%;
background-color: var(--secondary);
color: var(--text);
width: 50px;
height: 50px;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
</style>
+341 -89
View File
@@ -1,113 +1,365 @@
<template>
<div ref="contextMenu" class="contextmenu">
<slot></slot>
</div>
<Teleport to="body">
<div
ref="contextMenu"
class="contextmenu ui-menu"
role="menu"
:aria-label="label"
tabindex="-1"
@click.capture="onItemClickCapture"
@click="onItemClick"
@keydown="onKeydown"
@mouseleave="onMouseLeave"
@contextmenu.prevent
>
<slot></slot>
</div>
</Teleport>
</template>
<script setup>
import { onMounted, ref } from 'vue';
/**
* Menu contextuel flottant (role="menu"), téléporté dans <body>.
* Slot par défaut : les entrées, de préférence des <button type="button"> (les <div @click> historiques
* restent gérés : rôle menuitem et activation Entrée / Espace ajoutés automatiquement).
* Props : label (nom accessible du menu, « Actions » par défaut).
* Méthodes exposées :
* show(eventOrElement?) ouvre le menu : sous le pointeur pour un vrai clic, sous l'élément déclencheur
* pour un clic clavier (event.detail === 0) ou un élément passé directement ;
* sans argument, à la dernière position de la souris. Le focus va sur la 1re entrée.
* hide() ferme le menu et rend le focus au déclencheur.
* isOpen état (ref).
* Clavier : flèches haut/bas, Début/Fin, Échap (ferme), Tab (ferme). Fermeture aussi au clic extérieur,
* au défilement, au redimensionnement et quand la souris quitte un menu ouvert à la souris.
* Événements : open, close.
*/
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import useMouse from '@/utils/Mouse';
const contextMenu = ref(null);
const mouse = useMouse();
onMounted(() => {
contextMenu.value.addEventListener('rightclick', (e) => {
e.preventDefault(); // Prevent default context menu
});
contextMenu.value.addEventListener('click', () => {
contextMenu.value.style.display = 'none'; // Hide context menu on click
});
contextMenu.value.addEventListener('mouseleave', () => {
contextMenu.value.style.display = 'none'; // Hide context menu on mouse leave
});
defineProps({
label: {
type: String,
default: 'Actions'
}
});
function show() {
const menu = contextMenu.value
const { x, y } = mouse.value
const emit = defineEmits(['open', 'close']);
// Affiche temporairement pour mesurer
menu.style.display = 'flex'
menu.style.left = '0px'
menu.style.top = '0px'
const contextMenu = ref(null);
const isOpen = ref(false);
const mouse = useMouse();
// Force le DOM à calculer les dimensions
const menuRect = menu.getBoundingClientRect()
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
let opener = null;
let openedWithPointer = false;
let focusHandled = false;
let left = x
let top = y
const MARGIN = 8;
// Ajuste si dépasse à droite
if (x + menuRect.width > windowWidth) {
left = windowWidth - menuRect.width
} else {
left = x - 5
}
// Ajuste si dépasse en bas
if (y + menuRect.height > windowHeight) {
top = windowHeight - menuRect.height
} else {
top = y - 5
}
// Applique la position corrigée
menu.style.left = `${Math.max(left, 0)}px`
menu.style.top = `${Math.max(top, 0)}px`
function items() {
if (!contextMenu.value) return [];
return Array.from(contextMenu.value.children)
.filter((element) => element instanceof HTMLElement && element.tagName !== 'HR'
&& element.getClientRects().length > 0 && !element.hasAttribute('disabled'));
}
// Les entrées historiques (<div @click>) deviennent des menuitem focalisables
function prepareItems() {
if (!contextMenu.value) return;
for (const element of contextMenu.value.children) {
if (!(element instanceof HTMLElement)) continue;
if (element.tagName === 'HR') {
element.setAttribute('role', 'separator');
continue;
}
if (!element.hasAttribute('role')) element.setAttribute('role', 'menuitem');
element.setAttribute('tabindex', '-1');
}
}
function anchorRect(arg) {
if (arg instanceof Element) return arg.getBoundingClientRect();
const target = arg && typeof arg === 'object' && arg.currentTarget instanceof Element ? arg.currentTarget : null;
if (target) return target.getBoundingClientRect();
if (document.activeElement instanceof Element && document.activeElement !== document.body) {
return document.activeElement.getBoundingClientRect();
}
return null;
}
function show(arg) {
const menu = contextMenu.value;
if (!menu) return;
// Lu tout de suite : currentTarget n'existe que pendant la propagation de l'événement
const event = arg && typeof arg === 'object' && 'clientX' in arg ? arg : null;
const fromPointer = event ? event.detail > 0 || event.clientX !== 0 || event.clientY !== 0 : !(arg instanceof Element) && mouse.value.x + mouse.value.y > 0;
const point = event ? { x: event.clientX, y: event.clientY } : { x: mouse.value.x, y: mouse.value.y };
opener = (arg && arg.currentTarget instanceof HTMLElement && arg.currentTarget)
|| (arg instanceof HTMLElement && arg)
|| (document.activeElement instanceof HTMLElement ? document.activeElement : null);
openedWithPointer = fromPointer && !(arg instanceof Element);
const anchor = openedWithPointer ? null : anchorRect(arg);
isOpen.value = true;
addGlobalListeners();
// Après le rendu : les entrées conditionnelles (v-if) dépendent souvent de l'élément visé
nextTick(() => {
if (!isOpen.value) return;
prepareItems();
// Mesure : le menu doit être affiché avant de calculer sa taille
menu.style.display = 'flex';
menu.style.left = '0px';
menu.style.top = '0px';
const rect = menu.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left;
let top;
if (openedWithPointer) {
left = point.x - 5;
top = point.y - 5;
} else {
left = anchor ? anchor.right - rect.width : MARGIN;
top = anchor ? anchor.bottom + 4 : MARGIN;
// Pas la place en dessous : au-dessus du déclencheur
if (anchor && top + rect.height > vh - MARGIN) top = anchor.top - rect.height - 4;
}
left = Math.min(Math.max(left, MARGIN), vw - rect.width - MARGIN);
top = Math.min(Math.max(top, MARGIN), vh - rect.height - MARGIN);
menu.style.left = `${Math.max(left, 0)}px`;
menu.style.top = `${Math.max(top, 0)}px`;
const first = items()[0];
(first || menu).focus({ preventScroll: true });
emit('open');
});
}
function hide({ restoreFocus = true } = {}) {
if (!isOpen.value) return;
const menu = contextMenu.value;
const focusInside = menu && menu.contains(document.activeElement);
isOpen.value = false;
if (menu) menu.style.display = '';
removeGlobalListeners();
if (restoreFocus && focusInside && opener && opener.isConnected) opener.focus({ preventScroll: true });
opener = null;
emit('close');
}
function focusItem(index) {
const list = items();
if (list.length === 0) return;
const target = list[(index + list.length) % list.length];
target.focus({ preventScroll: true });
}
function onKeydown(event) {
const list = items();
const current = list.indexOf(document.activeElement);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
focusItem(current + 1);
break;
case 'ArrowUp':
event.preventDefault();
focusItem(current === -1 ? -1 : current - 1);
break;
case 'Home':
event.preventDefault();
focusItem(0);
break;
case 'End':
event.preventDefault();
focusItem(-1);
break;
case 'Escape':
event.preventDefault();
event.stopPropagation();
hide();
break;
case 'Tab':
event.preventDefault();
hide();
break;
case 'Enter':
case ' ': {
// Les <button> s'activent seuls ; les anciennes entrées <div> sont cliquées ici
const target = event.target;
if (target instanceof HTMLElement && list.includes(target) && !['BUTTON', 'A'].includes(target.tagName)) {
event.preventDefault();
target.click();
}
break;
}
default:
break;
}
}
function isItemEvent(event) {
const menu = contextMenu.value;
if (!menu || !(event.target instanceof Element)) return false;
const item = event.target.closest('.ui-menu > *');
return !!item && item.parentElement === menu;
}
// Avant l'action de l'entrée : le focus revient au déclencheur, pour qu'une modale ouverte par l'entrée
// le mémorise (et le rende à sa fermeture) au lieu d'une entrée de menu masquée
function onItemClickCapture(event) {
focusHandled = false;
if (!isItemEvent(event)) return;
if (opener && opener.isConnected) {
opener.focus({ preventScroll: true });
focusHandled = true;
}
}
function onItemClick(event) {
if (!isItemEvent(event)) return;
hide({ restoreFocus: !focusHandled });
focusHandled = false;
}
function onMouseLeave() {
if (openedWithPointer) hide({ restoreFocus: false });
}
function onDocumentPointerDown(event) {
if (contextMenu.value && !contextMenu.value.contains(event.target)) hide({ restoreFocus: false });
}
function onScroll(event) {
if (contextMenu.value && event.target instanceof Node && contextMenu.value.contains(event.target)) return;
hide({ restoreFocus: false });
}
function onResize() {
hide({ restoreFocus: false });
}
function addGlobalListeners() {
document.addEventListener('pointerdown', onDocumentPointerDown, true);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
}
function removeGlobalListeners() {
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
}
onMounted(prepareItems);
onBeforeUnmount(() => {
removeGlobalListeners();
});
defineExpose({
show
})
show,
hide,
isOpen
});
</script>
<style scoped>
.contextmenu {
position: fixed;
display: none;
flex-direction: column;
gap: 0px;
z-index: 1000;
border-radius: 10px;
}
<!-- Non scopé : les entrées viennent du slot du parent (classes propres au composant, préfixe ui-menu) -->
<style>
.ui-menu {
position: fixed;
display: none;
flex-direction: column;
gap: 2px;
min-width: 200px;
max-width: min(320px, calc(100vw - 16px));
padding: 4px;
box-sizing: border-box;
z-index: 4000;
background-color: var(--secondary);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
animation: u-fade-in var(--dur-fast) ease-out;
}
:deep(div) {
background-color: var(--primary);
opacity: 0.9;
width: 100%;
gap: 10px;;
display: flex;
align-items: center;
justify-content: start;
padding: 0 7px;
font-size: 12px;
transition: all 0.2s ease-in-out;
}
.ui-menu:focus {
outline: none;
}
:deep(div:first-child) {
border-radius: 5px 5px 0px 0px;
}
.ui-menu > * {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
width: 100%;
min-height: 34px;
margin: 0;
padding: 6px 10px;
box-sizing: border-box;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font: inherit;
font-size: 0.8125rem;
line-height: 1.3;
text-align: left;
cursor: pointer;
outline: 2px solid transparent;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--dur-fast) ease, color var(--dur-fast) ease;
}
:deep(div:last-child) {
border-radius: 0px 0px 5px 5px;
}
.ui-menu > * p {
margin: 0;
}
:deep(div:only-child) {
border-radius: 5px;
}
.ui-menu > * svg {
width: 1.1em;
flex-shrink: 0;
color: var(--text-secondary);
}
:deep(div:hover) {
background-color: var(--primary-hover);
color: var(--text-inverse);
cursor: pointer;
}
.ui-menu > *:hover,
.ui-menu > *:focus-visible,
.ui-menu > *:focus {
background-color: color-mix(in srgb, var(--text) 10%, transparent);
}
.ui-menu > *:focus-visible {
box-shadow: inset 0 0 0 2px var(--main);
}
.ui-menu > *:hover svg,
.ui-menu > *:focus svg {
color: var(--text);
}
/* Entrée destructive : <button class="ui-menu__danger"> */
.ui-menu > .ui-menu__danger,
.ui-menu > .ui-menu__danger svg {
color: var(--text-error);
}
.ui-menu > .ui-menu__danger:hover,
.ui-menu > .ui-menu__danger:focus {
background-color: color-mix(in srgb, var(--text-error) 14%, transparent);
}
.ui-menu > [disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.ui-menu > hr {
min-height: 0;
height: 1px;
margin: 3px 4px;
padding: 0;
width: auto;
background-color: var(--border);
cursor: default;
}
</style>
+77 -27
View File
@@ -1,31 +1,91 @@
<template>
<div class="icon-action">
<span class="icon"><slot></slot></span>
<span v-if="active" class="active-indicator"></span>
</div>
<button
type="button"
:class="['icon-action', { 'icon-action--active': active }]"
:disabled="disabled"
:aria-label="accessibleName()"
:title="accessibleName()"
>
<span class="icon" aria-hidden="true"><slot></slot></span>
<span v-if="active" class="active-indicator" aria-hidden="true"></span>
</button>
</template>
<script setup>
/**
* Bouton icône dont l'icône est fournie en slot (SVG maison du player…).
* `label` donne le nom accessible ; un attribut `title` est repris à défaut.
*/
import { useAttrs } from 'vue';
const props = defineProps({
label: {
type: String,
default: null
},
active: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
});
// useAttrs() n'est pas réactif : relu à chaque rendu
const attrs = useAttrs();
function accessibleName() {
return props.label || attrs.title || attrs['aria-label'] || undefined;
}
</script>
<style scoped>
.icon {
font-size: 1.3em;
transition: filter 0.3s ease;
}
.fixed {
font-size: 1.3em;
transition: filter 0.3s ease;
}
.icon-action {
/* Spécificité minimale (button / button:where) : les classes du parent restent prioritaires */
button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-sizing: content-box;
padding: 0.3em;
margin: -0.3em;
border: none;
border-radius: 50%;
background: transparent;
color: inherit;
font: inherit;
line-height: 1;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
outline: 2px solid transparent;
outline-offset: 1px;
transition: background-color var(--dur-fast) ease, opacity var(--dur-fast) ease;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.3em;
}
button:where(:hover:not(:disabled)) {
background-color: color-mix(in srgb, currentColor 14%, transparent);
}
button:where(:active:not(:disabled)) {
background-color: color-mix(in srgb, currentColor 22%, transparent);
}
button:where(:focus-visible) {
outline-color: currentColor;
}
button:where(:disabled) {
opacity: 0.4;
cursor: not-allowed;
}
.active-indicator {
@@ -33,19 +93,9 @@ const props = defineProps({
border-radius: 100%;
width: 4px;
height: 4px;
bottom: -15%;
bottom: 0;
left: 50%;
transform: translate(-50%, 0);
background: var(--text);
}
.icon:hover {
cursor: pointer;
filter: brightness(0.8);
}
.icon:active {
cursor: pointer;
filter: brightness(0.7);
background: currentColor;
}
</style>
+4
View File
@@ -7,6 +7,10 @@
</Splash>
</template>
<script setup>
/**
* Écran plein (fond rose animé) avec une boîte centrée : connexion, erreurs, chargement…
* Props : gap (espacement vertical du contenu), width (largeur max. de la boîte). Slot par défaut : le contenu.
*/
import Splash from '@/components/UI/Splash.vue';
import Box from '@/components/UI/Box.vue';
+149 -32
View File
@@ -1,31 +1,56 @@
<template>
<div v-if="!showLoader" class="duration-bar">
<p v-if="!inactive">{{ getVideoDuration(displaySec) }}</p>
<!-- Une seule racine : la barre (et son focus clavier) survit au passage en chargement pendant un seek -->
<div class="duration-bar">
<p v-if="showLoader" :class="{'offline': !showLoaderTime}">{{ showLoaderTime ? getVideoDuration(displaySec) : '-:--' }}</p>
<p v-else-if="!inactive">{{ getVideoDuration(displaySec) }}</p>
<p class="offline" v-else>-:--</p>
<div ref="progressBar" :class="{'progress-bar': true, 'progress-bar-mobile': mobile, 'progress-bar-locked': !canSeek}" @mousemove="hoverSeek" @mouseleave="hoverPercent = null" @pointerdown="startDrag">
<span class="progress" :style="{ width: percent + '%' }"></span>
<span class="progress-hover" v-if="hoverPercent !== null" :style="{ width: hoverPercent + '%' }"></span>
<span :class="{ 'progress-selector': true, 'progress-selector-active': canSeek }" :style="{ left: percent + '%' }"></span>
<div
ref="progressBar"
:class="{'progress-bar': true, 'progress-bar-mobile': mobile, 'progress-bar-locked': !canSeek, 'progress-bar-loading': showLoader}"
role="slider"
:tabindex="inactive || isLive ? -1 : 0"
aria-label="Position de lecture"
aria-valuemin="0"
:aria-valuemax="durationSec"
:aria-valuenow="Math.min(displaySec, durationSec)"
:aria-valuetext="valueText"
:aria-disabled="canSeek ? undefined : 'true'"
:aria-busy="showLoader ? 'true' : undefined"
@mousemove="hoverSeek"
@mouseleave="hoverPercent = null"
@pointerdown="startDrag"
@keydown="onKeydown"
@blur="flushKeySeek"
>
<!-- Chargement (morceau ou seek) : position cible figée + loader -->
<span v-if="showLoader" class="multi-loader" aria-hidden="true">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</span>
<template v-else>
<span class="progress" :style="{ width: percent + '%' }"></span>
<span class="progress-hover" v-if="hoverPercent !== null" :style="{ width: hoverPercent + '%' }"></span>
<span :class="{ 'progress-selector': true, 'progress-selector-active': canSeek }" :style="{ left: percent + '%' }"></span>
</template>
</div>
<p v-if="!inactive && isLive"><Icon color="var(--text-error)" icon="fa-circle"/> LIVE</p>
<p v-if="showLoader" :class="{'offline': !durationSec}">{{ durationSec ? getVideoDuration(durationSec) : '-:--' }}</p>
<p v-else-if="!inactive && isLive" class="live"><Icon color="var(--text-error)" icon="fa-circle" aria-hidden="true"/> LIVE</p>
<p v-else-if="!inactive">{{ getVideoDuration(durationSec) }}</p>
<p class="offline" v-else>-:--</p>
</div>
<div v-else class="duration-bar">
<!-- Chargement (morceau ou seek) : position cible figée + loader -->
<p :class="{'offline': !showLoaderTime}">{{ showLoaderTime ? getVideoDuration(displaySec) : '-:--' }}</p>
<div class="multi-loader">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<p :class="{'offline': !durationSec}">{{ durationSec ? getVideoDuration(durationSec) : '-:--' }}</p>
</div>
</template>
<script setup>
/**
* Barre de progression du player (lecture seule hors seek).
* Props : positionMs, durationMs (null = direct), status ('offline' | 'idle' | 'loading' | …), seekable, mobile,
* revision (compteur d'états du store), onSeek (listener @seek : renvoie une Promise, false = refusé).
* Accessibilité : role="slider" ; flèches ±5 s, Page préc./suiv. ±30 s, Début / Fin. Les appuis clavier
* sont regroupés (un seul seek envoyé 600 ms après la dernière touche, ou à la perte du focus).
*/
import { getVideoDuration } from "@/utils/TimeConverter"
import { ref, shallowRef, computed, watch, onBeforeUnmount } from "vue"
@@ -61,8 +86,14 @@ const isLive = computed(() => !props.durationMs)
const canSeek = computed(() => !inactive.value && !isLive.value && props.seekable && props.status !== 'loading' && !pendingSeek.value)
const showLoader = computed(() => !inactive.value && (props.status === 'loading' || !!pendingSeek.value))
// Position choisie au clavier, pas encore envoyée (null hors réglage clavier)
const keyMs = ref(null)
let keyTimer = null
const KEY_SEEK_DELAY = 600
const displayMs = computed(() => {
if (isDragging.value) return dragMs.value
if (keyMs.value !== null) return keyMs.value
if (pendingSeek.value) return pendingSeek.value.ms
return props.positionMs
})
@@ -95,6 +126,60 @@ function hoverSeek(e) {
e.currentTarget.title = getVideoDuration(Math.floor(pos * props.durationMs / 1000))
}
const valueText = computed(() => {
if (inactive.value) return "Aucune lecture"
if (showLoader.value) return "Chargement…"
if (isLive.value) return "Direct"
return `${getVideoDuration(displaySec.value)} sur ${getVideoDuration(durationSec.value)}`
})
// === CLAVIER ===
function onKeydown(e) {
if (!canSeek.value && keyMs.value === null) return
if (!props.durationMs) return
const base = keyMs.value !== null ? keyMs.value : displayMs.value
const max = Math.max(0, props.durationMs - 1000)
let target = null
switch (e.key) {
case 'ArrowRight':
case 'ArrowUp':
target = base + 5000
break
case 'ArrowLeft':
case 'ArrowDown':
target = base - 5000
break
case 'PageUp':
target = base + 30000
break
case 'PageDown':
target = base - 30000
break
case 'Home':
target = 0
break
case 'End':
target = max
break
default:
return
}
e.preventDefault()
keyMs.value = Math.max(0, Math.min(max, target))
clearTimeout(keyTimer)
keyTimer = setTimeout(flushKeySeek, KEY_SEEK_DELAY)
}
function flushKeySeek() {
clearTimeout(keyTimer)
keyTimer = null
if (keyMs.value === null) return
const ms = Math.floor(keyMs.value / 1000) * 1000
keyMs.value = null
if (!canSeek.value) return
requestSeek(ms)
}
// === DRAG LOGIC === (un clic simple = pointerdown + pointerup, un seul seek émis)
function startDrag(e) {
if (!canSeek.value) return
@@ -170,6 +255,8 @@ watch(() => props.revision, () => {
watch(inactive, (value) => {
if (value) {
clearTimeout(keyTimer)
keyMs.value = null
cancelDrag()
clearPendingSeek()
}
@@ -178,6 +265,7 @@ watch(inactive, (value) => {
onBeforeUnmount(() => {
removeDragListeners()
clearTimeout(pendingTimer)
clearTimeout(keyTimer)
})
</script>
@@ -192,7 +280,27 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
gap: 10px;
font-family: monospace;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.duration-bar p {
white-space: nowrap;
}
.live {
display: flex;
align-items: center;
gap: 4px;
}
.progress-bar:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.progress-bar:focus-visible .progress-selector-active {
display: block;
}
.progress-bar {
@@ -259,16 +367,19 @@ onBeforeUnmount(() => {
transition: left 0.1s ease;
border-radius: 3px;
}
.multi-loader {
position: relative;
width: 100%;
flex: 1;
height: 6px;
background: var(--tertiary);
border-radius: 4px;
.progress-bar-loading {
cursor: progress;
overflow: hidden;
}
.multi-loader {
position: absolute;
inset: 0;
display: block;
overflow: hidden;
border-radius: 3px;
}
.multi-loader span {
position: absolute;
top: 0;
@@ -279,11 +390,6 @@ onBeforeUnmount(() => {
animation: move 2.5s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.multi-loader {
background: var(--quaternary);
}
}
.multi-loader span:nth-child(1) { animation-delay: 0s; }
.multi-loader span:nth-child(2) { animation-delay: 0.3s; }
@@ -299,4 +405,15 @@ onBeforeUnmount(() => {
90% { left: 100%; width: 10px; opacity: 0; }
100% { left: 100%; width: 0px; opacity: 0; }
}
/* Mouvement réduit : pas de segments animés, la barre se teinte pendant le chargement */
@media (prefers-reduced-motion: reduce) {
.multi-loader span {
display: none;
}
.progress-bar-loading {
background: color-mix(in srgb, var(--main) 35%, var(--tertiary));
}
}
</style>
+96
View File
@@ -0,0 +1,96 @@
<template>
<div :class="['ui-empty', { 'ui-empty--compact': compact }]">
<span v-if="icon" class="ui-empty__icon" aria-hidden="true"><Icon :icon="icon" /></span>
<p v-if="title" class="ui-empty__title">{{ title }}</p>
<div v-if="$slots.default" class="ui-empty__text">
<slot></slot>
</div>
<div v-if="$slots.actions" class="ui-empty__actions">
<slot name="actions"></slot>
</div>
</div>
</template>
<script setup>
/**
* État vide / message d'absence de contenu, centré et discret.
* Props : icon (Font Awesome, pastille ronde), title, compact (version resserrée pour les panneaux étroits).
* Slot par défaut : description ; slot `actions` : boutons d'action.
* Pour une erreur annoncée, envelopper dans un conteneur role="alert" (ou utiliser Error).
*/
defineProps({
icon: {
type: String,
default: 'fa-solid fa-inbox'
},
title: {
type: String,
default: null
},
compact: {
type: Boolean,
default: false
}
});
</script>
<style scoped>
.ui-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px 16px;
text-align: center;
color: var(--text-secondary);
min-width: 0;
}
.ui-empty__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-bottom: 4px;
border-radius: 50%;
background-color: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--text-secondary);
font-size: 1.5em;
}
.ui-empty__title {
margin: 0;
font-size: 1em;
font-weight: 600;
color: var(--text);
}
.ui-empty__text {
max-width: 42ch;
font-size: 0.875em;
line-height: 1.45;
}
.ui-empty__text :slotted(p) {
margin: 0;
}
.ui-empty__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 8px;
}
.ui-empty--compact {
gap: 6px;
padding: 16px 12px;
}
.ui-empty--compact .ui-empty__icon {
width: 40px;
height: 40px;
font-size: 1.1em;
}
</style>
+24 -22
View File
@@ -1,30 +1,32 @@
<template>
<div>
<Icon icon="fa-solid fa-circle-exclamation"></Icon>
<div class="error-message">
<slot></slot>
</div>
<div class="feedback" role="alert">
<Icon class="feedback-icon" icon="fa-solid fa-circle-exclamation" aria-hidden="true"></Icon>
<div class="error-message">
<slot></slot>
</div>
</div>
</template>
<script setup>
/**
* Message d'erreur en rouge (role="alert" : annoncé immédiatement). Slot par défaut : le message.
*/
</script>
<style scoped>
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
/* Spécificité de « div » (comme avant) : une classe posée par le parent reste prioritaire */
div:where(.feedback) {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-error);
animation: u-fade-in 0.5s ease-in-out;
}
div {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-error);
animation: fadeIn 0.5s ease-in-out;
}
.feedback-icon {
flex-shrink: 0;
}
.error-message {
display: flex;
text-align: justify;
}
.error-message {
display: flex;
text-align: justify;
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<template>
<div :class="['ui-field', { 'ui-field--error': !!error }]">
<div v-if="label || $slots.aside" class="ui-field__top">
<label v-if="label" :for="controlId" class="ui-field__label">
{{ label }}<span v-if="required" class="ui-field__required" aria-hidden="true"> *</span>
<span v-if="required" class="sr-only"> (obligatoire)</span>
</label>
<span v-if="$slots.aside" class="ui-field__aside"><slot name="aside"></slot></span>
</div>
<slot :id="controlId" :describedby="describedBy" :invalid="!!error"></slot>
<p v-if="hint" :id="hintId" class="ui-field__hint">{{ hint }}</p>
<p v-if="error" :id="errorId" class="ui-field__error" role="alert">
<Icon icon="fa-solid fa-circle-exclamation" aria-hidden="true" />
<span>{{ error }}</span>
</p>
</div>
</template>
<script setup>
/**
* Enveloppe de champ de formulaire : libellé, aide, erreur.
* Le contrôle est placé dans le slot par défaut, qui reçoit { id, describedby, invalid } :
* <Field label="Nom" for="pl-name" hint="100 caractères max." :error="err" v-slot="{ id, describedby, invalid }">
* <input type="text" :id="id" :aria-describedby="describedby" :aria-invalid="invalid" v-model="name"/>
* </Field>
* Slot `aside` : élément aligné à droite du libellé (compteur, lien…).
*/
import { computed, useId } from 'vue';
const props = defineProps({
label: {
type: String,
default: null
},
hint: {
type: String,
default: null
},
error: {
type: String,
default: null
},
for: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
}
});
const generatedId = `field-${useId()}`;
const controlId = computed(() => props.for || generatedId);
const hintId = computed(() => `${controlId.value}-hint`);
const errorId = computed(() => `${controlId.value}-error`);
// Valeur à poser sur aria-describedby du contrôle
const describedBy = computed(() => {
const ids = [];
if (props.hint) ids.push(hintId.value);
if (props.error) ids.push(errorId.value);
return ids.length ? ids.join(' ') : undefined;
});
</script>
<style scoped>
.ui-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.ui-field__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ui-field__label {
font-size: 0.9em;
font-weight: 600;
color: var(--text);
}
.ui-field__required {
color: var(--main);
}
.ui-field__aside {
font-size: 0.8em;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.ui-field__hint {
margin: 0;
font-size: 0.8em;
line-height: 1.4;
color: var(--text-secondary);
}
.ui-field__error {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: 0.85em;
line-height: 1.4;
color: var(--text-error);
}
/* Contrôle en erreur : liseré rouge (input / select / textarea posés directement dans le slot) */
.ui-field--error :slotted(input),
.ui-field--error :slotted(select),
.ui-field--error :slotted(textarea) {
box-shadow: 0 0 0 1px var(--text-error);
}
.ui-field--error :slotted(input:focus),
.ui-field--error :slotted(select:focus),
.ui-field--error :slotted(textarea:focus) {
box-shadow: 0 0 0 2px var(--primary), 0 0 0 4px var(--text-error);
}
</style>
+71 -11
View File
@@ -1,11 +1,27 @@
<script setup>
import { ref, useSlots, computed } from 'vue';
/**
* Onglets en pilule (v-model = index de l'onglet). Chaque enfant du slot par défaut est un onglet.
* Props : label (aria-label du tablist), idPrefix (facultatif) : onglet i = `${idPrefix}-tab-${i}`,
* relié par aria-controls au panneau `${idPrefix}-panel-${i}` que le parent doit porter
* (role="tabpanel" + aria-labelledby="${idPrefix}-tab-${i}").
* Slot `tab-content` ({ tab, index }) : contenu personnalisé d'un onglet.
* Accessibilité : role="tablist" / role="tab", aria-selected, tabindex itinérant,
* flèches gauche/droite + Début/Fin pour naviguer au clavier.
*/
import { ref, useSlots, computed, nextTick } from 'vue';
// on définit une prop spéciale pour le v-model
const props = defineProps({
modelValue: {
type: Number,
default: 0
},
label: {
type: String,
default: null
},
idPrefix: {
type: String,
default: null
}
});
@@ -14,28 +30,57 @@ const emit = defineEmits(['update:modelValue']);
const slots = useSlots();
const tabs = computed(() => slots.default?.() || []);
const hoveredTab = ref(null);
// Tableau simple (non réactif) : seulement utilisé pour déplacer le focus
const tabRefs = [];
const selectedTab = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
// Onglet posé sur l'indicateur (survolé, sinon sélectionné) : texte contrasté sur le rose
const litIndex = computed(() => (hoveredTab.value !== null ? hoveredTab.value : selectedTab.value));
const setSelectedTab = (index) => {
selectedTab.value = index;
};
function onKeydown(event, index) {
const count = tabs.value.length;
if (count === 0) return;
let target = null;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') target = (index + 1) % count;
else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') target = (index - 1 + count) % count;
else if (event.key === 'Home') target = 0;
else if (event.key === 'End') target = count - 1;
if (target === null) return;
event.preventDefault();
setSelectedTab(target);
nextTick(() => tabRefs[target]?.focus());
}
</script>
<template>
<div
class="glider-container"
role="tablist"
:aria-label="label || undefined"
@mouseleave="hoveredTab = null"
>
<button
v-for="(tab, index) in tabs"
:key="index"
:ref="(el) => { tabRefs[index] = el }"
type="button"
role="tab"
:id="idPrefix ? `${idPrefix}-tab-${index}` : undefined"
:aria-controls="idPrefix ? `${idPrefix}-panel-${index}` : undefined"
:aria-selected="selectedTab === index ? 'true' : 'false'"
:tabindex="selectedTab === index ? 0 : -1"
@click="setSelectedTab(index)"
@keydown="onKeydown($event, index)"
@mouseenter="hoveredTab = index"
:class="['glider-tab', { 'glider-tab--selected': selectedTab === index }]"
:class="['glider-tab', { 'glider-tab--selected': selectedTab === index, 'glider-tab--lit': litIndex === index }]"
>
<slot name="tab-content" :tab="tab" :index="index">
{{ tab.children }}
@@ -43,8 +88,9 @@ const setSelectedTab = (index) => {
</button>
<div
class="glider-indicator"
aria-hidden="true"
:style="{
left: `${(hoveredTab !== null ? hoveredTab : selectedTab) * (100 / tabs.length)}%`,
left: `${litIndex * (100 / tabs.length)}%`,
width: `${100 / tabs.length}%`
}"
></div>
@@ -57,26 +103,40 @@ const setSelectedTab = (index) => {
display: flex;
position: relative;
background-color: var(--quaternary);
border-radius: 20px;
border-radius: var(--radius-pill);
overflow: hidden;
}
.glider-tab {
flex: 1;
padding: 6px 0;
min-width: 0;
padding: 6px 4px;
position: relative;
z-index: 99;
background-color: transparent;
border: 0;
outline: none;
border-radius: var(--radius-pill);
color: var(--text);
cursor: pointer;
font-family: inherit;
font-weight: 500;
transition: color 0.2s ease-out;
outline: 2px solid transparent;
outline-offset: -2px;
transition: color var(--dur-fast) ease-out;
}
/* Anneau intérieur (le conteneur coupe ce qui déborde) */
.glider-tab:focus-visible {
outline: 2px solid var(--text);
outline-offset: -3px;
}
.glider-tab--lit {
color: var(--main-contrast) !important;
}
.glider-tab--selected {
color: #FFFFFF !important;
font-weight: 600;
}
.glider-indicator {
@@ -84,7 +144,7 @@ const setSelectedTab = (index) => {
height: 100%;
background: var(--main);
z-index: 9;
border-radius: 20px;
transition: left 0.2s ease-out, width 0.2s ease-out;
border-radius: var(--radius-pill);
transition: left 0.2s var(--ease), width 0.2s var(--ease);
}
</style>
+105 -22
View File
@@ -1,18 +1,45 @@
<template>
<div class="icon-action">
<Icon :style="{'font-size': size ? size : ''}" :color="color" :class="!fixed ? 'icon' : 'fixed'" :icon="icon" />
<span v-if="active" :style="{'background': color}" class="active-indicator"></span>
</div>
<!-- Sans action ni nom accessible, l'icône est purement décorative (chevron d'un parent cliquable…) -->
<span v-if="isDecorative()" class="icon-action icon-action--decorative" :style="colorStyle" aria-hidden="true">
<Icon :style="iconStyle" class="icon-action__icon" :icon="icon" />
</span>
<button
v-else
type="button"
:class="['icon-action', { 'icon-action--fixed': fixed, 'icon-action--active': active }]"
:style="colorStyle"
:disabled="disabled"
:aria-label="accessibleName()"
:title="accessibleName()"
>
<Icon :style="iconStyle" class="icon-action__icon" :icon="icon" />
<span v-if="active" class="active-indicator" aria-hidden="true"></span>
</button>
</template>
<script setup>
/**
* Bouton icône (sans texte visible).
* Props : icon, label, color (couleur de l'icône), size (taille CSS de l'icône), active (point sous l'icône),
* fixed (pas de fond au survol), disabled.
* `label` donne le nom accessible (aria-label + infobulle) ; un attribut `title` est repris à défaut.
* Bascule : passer aussi :aria-pressed="état ? 'true' : 'false'" (transmis au bouton).
* Sans @click ni nom accessible, l'icône est rendue décorative (<span aria-hidden>).
* Les écouteurs (@click, @click.stop…) et classes du parent sont transmis au <button>.
*/
import { computed, useAttrs } from 'vue';
const props = defineProps({
icon: {
type: String,
required: true
},
label: {
type: String,
default: null
},
color: {
type: String,
default: 'var(--text)'
default: null
},
fixed: {
type: Boolean,
@@ -25,23 +52,83 @@ const props = defineProps({
active: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
});
// useAttrs() n'est pas réactif : on relit les attributs à chaque rendu (fonctions plutôt que computed)
const attrs = useAttrs();
function accessibleName() {
return props.label || attrs.title || attrs['aria-label'] || undefined;
}
function isDecorative() {
return !attrs.onClick && !accessibleName();
}
const colorStyle = computed(() => (props.color ? { '--icon-action-color': props.color } : null));
const iconStyle = computed(() => (props.size ? { 'font-size': props.size } : null));
</script>
<style scoped>
.icon {
font-size: 1.3em;
transition: filter 0.3s ease;
}
.fixed {
font-size: 1.3em;
transition: filter 0.3s ease;
}
.icon-action {
/*
* Styles de base en button:where(...) / span:where(...) : spécificité minimale,
* pour que les classes des parents (positionnement absolu, display, couleur…) restent prioritaires.
*/
button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-sizing: content-box;
/* Zone cliquable élargie sans décaler la mise en page existante */
padding: 0.3em;
margin: -0.3em;
border: none;
border-radius: 50%;
background: transparent;
color: var(--icon-action-color, var(--text));
font: inherit;
line-height: 1;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
outline: 2px solid transparent;
outline-offset: 1px;
transition: background-color var(--dur-fast) ease, color var(--dur-fast) ease, opacity var(--dur-fast) ease;
}
span:where(.icon-action--decorative) {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--icon-action-color, inherit);
line-height: 1;
}
.icon-action__icon {
font-size: 1.3em;
}
button:where(:hover:not(:disabled):not(.icon-action--fixed)) {
background-color: color-mix(in srgb, currentColor 14%, transparent);
}
button:where(:active:not(:disabled):not(.icon-action--fixed)) {
background-color: color-mix(in srgb, currentColor 22%, transparent);
}
/* Contour de la couleur de l'icône : reste visible même sur la barre de recherche rose */
button:where(:focus-visible) {
outline-color: currentColor;
}
button:where(:disabled) {
opacity: 0.4;
cursor: not-allowed;
}
.active-indicator {
@@ -49,13 +136,9 @@ const props = defineProps({
border-radius: 100%;
width: 4px;
height: 4px;
bottom: -30%;
bottom: -0.1em;
left: 50%;
transform: translate(-50%, 0);
}
.icon:hover {
cursor: pointer;
filter: brightness(0.8);
background: currentColor;
}
</style>
+27 -25
View File
@@ -1,14 +1,17 @@
<template>
<div :class="{ second: secondary }">
<Icon icon="fa-solid fa-circle-info"></Icon>
<div class="info-message">
<slot></slot>
</div>
<div :class="['feedback', { second: secondary }]" role="status">
<Icon class="feedback-icon" icon="fa-solid fa-circle-info" aria-hidden="true"></Icon>
<div class="info-message">
<slot></slot>
</div>
</div>
</template>
<script setup>
/**
* Message d'information (icône « i »), role="status" : annoncé s'il apparaît après coup.
* Prop : secondary (petit texte gris). Slot par défaut : le message.
* Voir aussi Error (role="alert") et Success (role="status").
*/
defineProps({
secondary: {
type: Boolean,
@@ -17,26 +20,25 @@ defineProps({
})
</script>
<style scoped>
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
/* Spécificité de « div » (comme avant) : une classe posée par le parent reste prioritaire */
div:where(.feedback) {
display: flex;
align-items: center;
gap: 10px;
animation: u-fade-in 0.5s ease-in-out;
}
div {
display: flex;
align-items: center;
gap: 10px;
animation: fadeIn 0.5s ease-in-out;
}
.feedback-icon {
flex-shrink: 0;
}
.info-message {
display: flex;
text-align: justify;
}
.info-message {
display: flex;
text-align: justify;
}
.second {
color: var(--text-secondary) !important;
font-size: 12px;
}
.second {
color: var(--text-secondary) !important;
font-size: 12px;
}
</style>
+35 -10
View File
@@ -1,24 +1,49 @@
<template>
<div class="listen-box">
<Icon font-size="12px" class="listen-icon" icon="fa-solid fa-user" />
<p><slot></slot></p>
<div class="listen-box" :title="title">
<Icon class="listen-icon" icon="fa-solid fa-user" aria-hidden="true" />
<p><slot></slot><span class="sr-only"> {{ srSuffix }}</span></p>
</div>
</template>
<script setup>
/**
* Pastille rose avec un compteur d'utilisateurs (slot par défaut : le nombre).
* Props : title (infobulle), srSuffix (texte lu après le nombre, « connectés » par défaut).
*/
defineProps({
title: {
type: String,
default: 'Utilisateurs connectés'
},
srSuffix: {
type: String,
default: 'utilisateurs connectés'
}
});
</script>
<style scoped>
.listen-box {
color: white;
background-color: var(--main);
padding: 4px 8px;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
gap: 5px;
min-height: 24px;
padding: 0 8px;
box-sizing: border-box;
border-radius: var(--radius-pill);
background-color: var(--main);
color: var(--main-contrast);
font-size: 0.8rem;
font-weight: 600;
}
.listen-icon {
font-size: 0.75em;
}
p {
margin: 0;
width: 9px;
height: 16px;
min-width: 1ch;
text-align: center;
font-variant-numeric: tabular-nums;
}
</style>
+5 -2
View File
@@ -1,10 +1,13 @@
<template>
<div>
<LogoDark class="img" v-if="globalStore.theme == 'light'"/>
<LogoLight class="img" v-else/>
<LogoDark class="img" v-if="globalStore.theme == 'light'" aria-hidden="true" focusable="false"/>
<LogoLight class="img" v-else aria-hidden="true" focusable="false"/>
</div>
</template>
<script setup>
/**
* Logo Subsonics seul (décoratif), adapté au thème clair / sombre.
*/
import LogoDark from '@/assets/LogoDark.vue';
import LogoLight from '@/assets/LogoLight.vue';
import { useGlobalStore } from '@/stores/globalStore';
+101 -70
View File
@@ -1,10 +1,11 @@
<template>
<div ref="container" class="marquee-container">
<div ref="scroller" class="marquee-scroller" :style="scrollerStyle">
<div ref="container" :class="['marquee-container', { 'marquee-container--static': reducedMotion }]">
<div class="marquee-scroller" :style="scrollerStyle">
<div ref="content" class="marquee-content">
<slot></slot>
</div>
<div v-if="shouldScroll" class="marquee-content">
<!-- Copie visuelle pour la boucle : ignorée par les lecteurs d'écran -->
<div v-if="shouldScroll" class="marquee-content" aria-hidden="true">
<slot></slot>
</div>
</div>
@@ -12,101 +13,115 @@
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
/**
* Texte défilant quand il dépasse de son conteneur (titres longs).
* Props : speed (px/s, 50), delay (ms, 3000 : pause avant chaque passage).
* Slot par défaut : le contenu (une ligne). Mouvement réduit : pas de défilement, texte tronqué avec « … ».
*/
import { ref, onMounted, onBeforeUnmount, nextTick } from "vue";
const props = defineProps({
speed: { type: Number, default: 50 }, // px/s
delay: { type: Number, default: 3000 }, // délai initial et pause entre chaque loop
speed: { type: Number, default: 50 },
delay: { type: Number, default: 3000 },
});
const container = ref(null);
const content = ref(null);
const scroller = ref(null);
const distance = ref(0);
const shouldScroll = ref(false);
const scrollerStyle = ref({ transform: "translateX(0)" });
const motionQuery = typeof window !== "undefined" && window.matchMedia
? window.matchMedia("(prefers-reduced-motion: reduce)")
: null;
const reducedMotion = ref(!!motionQuery?.matches);
let resizeObserver = null;
let mutationObserver = null;
let animationTimeout = null;
// Un seul minuteur à la fois : pause, puis passage, puis pause…
let timer = null;
watch(shouldScroll, (newValue) => {
if (newValue) {
startAnimation();
} else {
stopAnimation()
}
});
function recalc() {
if (!container.value || !content.value) return;
const containerWidth = container.value.offsetWidth;
const contentWidth = content.value.scrollWidth;
if (contentWidth > containerWidth) {
distance.value = contentWidth;
shouldScroll.value = true;
startAnimation();
} else {
shouldScroll.value = false;
scrollerStyle.value.transform = "translateX(0)";
}
function resetPosition() {
scrollerStyle.value = { transition: "none", transform: "translateX(0)" };
}
function startAnimation() {
if (!shouldScroll.value) return;
const duration = distance.value / props.speed * 1000; // ms
function stop() {
clearTimeout(timer);
timer = null;
resetPosition();
}
scrollerStyle.value.transition = "none";
scrollerStyle.value.transform = "translateX(0)";
// petite pause avant le début
setTimeout(() => {
if (!shouldScroll.value) return;
scrollerStyle.value.transition = `transform ${duration}ms linear`;
scrollerStyle.value.transform = `translateX(-${distance.value}px)`;
// après la fin, rappeler la fonction avec pause de 5s
animationTimeout = setTimeout(() => {
startAnimation();
}, duration + props.delay);
function cycle() {
clearTimeout(timer);
resetPosition();
timer = setTimeout(() => {
if (!shouldScroll.value) return;
const duration = (distance.value / props.speed) * 1000;
scrollerStyle.value = {
transition: `transform ${duration}ms linear`,
transform: `translateX(-${distance.value}px)`,
};
// Fin du passage : la copie occupe exactement la place de l'original, le retour à 0 est invisible
timer = setTimeout(cycle, duration);
}, props.delay);
}
function stopAnimation() {
if (animationTimeout) {
clearTimeout(animationTimeout);
animationTimeout = null;
recalc()
scrollerStyle.value.transition = "none";
scrollerStyle.value.transform = "translateX(0)";
function recalc() {
if (!container.value || !content.value) return;
const element = content.value;
const padding = parseFloat(getComputedStyle(element).paddingRight) || 0;
const overflow = !reducedMotion.value && element.offsetWidth - padding > container.value.clientWidth + 1;
const nextDistance = element.offsetWidth;
if (!overflow) {
if (shouldScroll.value) {
shouldScroll.value = false;
stop();
}
return;
}
// Redémarre seulement si quelque chose a changé (le ResizeObserver se déclenche souvent)
if (!shouldScroll.value || nextDistance !== distance.value) {
distance.value = nextDistance;
shouldScroll.value = true;
cycle();
}
}
function onMotionChange(event) {
reducedMotion.value = event.matches;
if (event.matches) {
shouldScroll.value = false;
stop();
}
nextTick(recalc);
}
onMounted(() => {
recalc();
recalc();
// observer taille
resizeObserver = new ResizeObserver(() => recalc());
resizeObserver.observe(container.value);
resizeObserver.observe(content.value);
resizeObserver = new ResizeObserver(() => recalc());
resizeObserver.observe(container.value);
resizeObserver.observe(content.value);
// observer slot
mutationObserver = new MutationObserver(() => {
nextTick(() => recalc());
});
mutationObserver.observe(content.value, {
childList: true,
characterData: true,
subtree: true,
});
// Le contenu du slot change (nouveau titre)
mutationObserver = new MutationObserver(() => {
nextTick(() => recalc());
});
mutationObserver.observe(content.value, {
childList: true,
characterData: true,
subtree: true,
});
motionQuery?.addEventListener?.("change", onMotionChange);
});
onBeforeUnmount(() => {
if (resizeObserver) resizeObserver.disconnect();
if (mutationObserver) mutationObserver.disconnect();
clearTimeout(animationTimeout);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
motionQuery?.removeEventListener?.("change", onMotionChange);
clearTimeout(timer);
});
</script>
@@ -115,6 +130,7 @@ onBeforeUnmount(() => {
overflow: hidden;
position: relative;
width: 100%;
min-width: 0;
}
.marquee-scroller {
@@ -126,4 +142,19 @@ onBeforeUnmount(() => {
flex-shrink: 0;
padding-right: 2rem;
}
/* Mouvement réduit : une ligne tronquée */
.marquee-container--static .marquee-content {
flex-shrink: 1;
min-width: 0;
padding-right: 0;
overflow: hidden;
}
.marquee-container--static :deep(.marquee-content > *) {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+5 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="metric">
<div class="metric-header">
<Icon class="metric-icon" :icon="getIcons(metricName)" />
<Icon class="metric-icon" :icon="getIcons(metricName)" aria-hidden="true" />
<div class="metric-info">
<p class="metric-name">{{ label }}</p>
<p class="metric-id">{{ metricName }}</p>
@@ -12,6 +12,10 @@
</div>
</template>
<script setup>
/**
* Tuile d'une statistique de serveur (icône, libellé, valeur formatée en durée ou en nombre).
* Props : metric ({ name, description, value }), server ({ id, name } : retirés du libellé).
*/
import { computed } from 'vue';
import { formatStatDuration, formatStatCount } from '@/utils/TimeConverter';
+384 -94
View File
@@ -1,147 +1,437 @@
<template>
<Teleport to="body">
<div ref="modal" class="modal-overlay">
<div ref="modalContent" class="modal">
<div class="modal-header">
<div class="modal-title">
<Icon font-size="1.5em" v-if="icon" :icon="icon" />
<p>{{ title }}</p>
<Transition name="modal">
<div
v-show="isOpen"
ref="overlay"
class="modal-overlay"
:style="{ zIndex }"
@pointerdown="onOverlayPointerDown"
@click="onOverlayClick"
>
<div
ref="panel"
:class="['modal', `modal--${size}`]"
role="dialog"
aria-modal="true"
:aria-labelledby="title ? titleId : undefined"
:aria-label="title ? undefined : 'Boîte de dialogue'"
tabindex="-1"
>
<header class="modal-header">
<div class="modal-title">
<span v-if="icon" class="modal-title-icon" aria-hidden="true"><Icon :icon="icon" /></span>
<h2 v-if="title" :id="titleId" class="modal-title-text">{{ title }}</h2>
</div>
<div class="modal-header-actions">
<slot name="header-actions"></slot>
<IconAction v-if="closable" class="modal-close" label="Fermer" icon="fa-solid fa-xmark" @click="close()" />
</div>
</header>
<div class="modal-content">
<slot></slot>
</div>
<footer v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</footer>
</div>
<IconAction @click="close()" icon="fa-solid fa-xmark" />
</div>
<div class="modal-content">
<slot></slot>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
<script>
// État partagé par toutes les modales : pile des modales ouvertes (Échap / Tab ne concernent que
// la plus haute) et verrouillage du défilement de la page tant qu'au moins une est ouverte.
const openStack = [];
let savedBodyOverflow = null;
import IconAction from '../UI/IconAction.vue';
import { onMounted, onUnmounted, ref } from 'vue';
import Events from '@/utils/Events';
const modal = ref(null);
const modalContent = ref(null);
// Close the modal when clicking outside of it
onMounted(() => {
document.addEventListener('pointerdown', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('pointerdown', handleClickOutside);
});
// Only clicking modal-overlay but not modal-content will close the modal
function handleClickOutside(event) {
if (event.pointerType === "mouse" && event.target === modal.value && modalContent.value && !modalContent.value.contains(event.target)) {
close();
function lockBody() {
if (savedBodyOverflow === null) {
savedBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
}
}
function unlockBody() {
if (openStack.length === 0 && savedBodyOverflow !== null) {
document.body.style.overflow = savedBodyOverflow;
savedBodyOverflow = null;
}
}
const FOCUSABLE = [
'a[href]', 'area[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]', 'audio[controls]', 'video[controls]', 'summary'
].join(',');
</script>
<script setup>
/**
* Fenêtre modale accessible (role="dialog", focus piégé, Échap, retour du focus au déclencheur).
* Props : title, icon, size ('sm' 420 | 'md' 600 | 'lg' 820 | 'xl' 1100 px), closable (croix, Échap, clic sur le fond).
* Slots : défaut (contenu défilant), `footer` (barre d'actions en bas), `header-actions` (à gauche de la croix).
* Exposé : open(), close(), isOpen. Un élément [data-autofocus] / [autofocus] du contenu reçoit le focus à l'ouverture.
* - Événements Vue `open` / `close` (à préférer) + événements globaux historiques `modal:open` / `modal:close`.
* - Le contenu reste monté quand la modale est fermée (v-show), comme avant.
* - Le contenu peut réagir à SA modale via utils/ModalContext.js (onModalOpen / onModalClose).
*/
import { nextTick, onBeforeUnmount, provide, readonly, ref, useId } from 'vue';
import IconAction from './IconAction.vue';
import Events from '@/utils/Events';
import { MODAL_CONTEXT } from '@/utils/ModalContext';
const props = defineProps({
title: {
type: String,
default: 'Modal Title'
default: ''
},
icon: {
type: String,
default: null
},
size: {
type: String,
default: 'md',
validator: (value) => ['sm', 'md', 'lg', 'xl'].includes(value)
},
closable: {
type: Boolean,
default: true
}
});
})
const emit = defineEmits(['open', 'close']);
function close() {
if (modal.value) {
modal.value.style.display = 'none';
const titleId = `modal-title-${useId()}`;
const overlay = ref(null);
const panel = ref(null);
const isOpen = ref(false);
const zIndex = ref(3500);
// Objet propre à cette instance, utilisé comme identifiant dans la pile
const token = {};
const subscribers = { open: new Set(), close: new Set() };
let previousFocus = null;
let pointerDownOnOverlay = false;
function notify(kind) {
for (const callback of [...subscribers[kind]]) {
try {
callback();
} catch (error) {
console.error(`[Modal] Erreur dans un écouteur "${kind}"`, error);
}
}
Events.emit('modal:close');
}
function open() {
if (modal.value) {
modal.value.style.display = 'flex';
}
if (isOpen.value) return;
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
openStack.push(token);
zIndex.value = 3500 + openStack.length;
lockBody();
document.addEventListener('keydown', onKeydown);
isOpen.value = true;
emit('open');
notify('open');
Events.emit('modal:open');
nextTick(focusInitial);
}
function close() {
if (!isOpen.value) return;
teardown();
isOpen.value = false;
emit('close');
notify('close');
Events.emit('modal:close');
restoreFocus();
}
// Retire la modale de la pile et libère les écouteurs (fermeture ou démontage)
function teardown() {
const index = openStack.indexOf(token);
if (index !== -1) openStack.splice(index, 1);
document.removeEventListener('keydown', onKeydown);
unlockBody();
}
function restoreFocus() {
const target = previousFocus;
previousFocus = null;
if (target && target.isConnected && typeof target.focus === 'function') {
target.focus({ preventScroll: true });
}
}
function isTopmost() {
return openStack[openStack.length - 1] === token;
}
function focusableElements() {
if (!panel.value) return [];
return Array.from(panel.value.querySelectorAll(FOCUSABLE))
.filter((element) => element.getClientRects().length > 0 && !element.closest('[inert]'));
}
function focusInitial() {
if (!isOpen.value || !panel.value) return;
// Un champ marqué autofocus / data-autofocus est prioritaire ; sinon le dialogue lui-même
// (le lecteur d'écran annonce alors son titre, sans faire sauter le clavier mobile).
const preferred = panel.value.querySelector('[data-autofocus], [autofocus]');
if (preferred && typeof preferred.focus === 'function') {
preferred.focus({ preventScroll: true });
} else {
panel.value.focus({ preventScroll: true });
}
}
function onKeydown(event) {
if (!isOpen.value || !isTopmost() || event.defaultPrevented || event.isComposing) return;
if (event.key === 'Escape') {
if (!props.closable) return;
event.preventDefault();
close();
return;
}
if (event.key === 'Tab') {
trapTab(event);
}
}
function trapTab(event) {
const elements = focusableElements();
const active = document.activeElement;
if (elements.length === 0) {
event.preventDefault();
panel.value?.focus({ preventScroll: true });
return;
}
const first = elements[0];
const last = elements[elements.length - 1];
const inside = panel.value.contains(active);
if (event.shiftKey) {
if (!inside || active === first || active === panel.value) {
event.preventDefault();
last.focus();
}
} else if (!inside || active === last) {
event.preventDefault();
first.focus();
}
}
// Ferme seulement si le clic commence ET finit sur le fond (pas après une sélection de texte qui déborde)
function onOverlayPointerDown(event) {
pointerDownOnOverlay = event.target === overlay.value;
}
function onOverlayClick(event) {
const fromOverlay = pointerDownOnOverlay && event.target === overlay.value;
pointerDownOnOverlay = false;
if (fromOverlay && props.closable) close();
}
function subscribe(kind, callback) {
subscribers[kind]?.add(callback);
return () => subscribers[kind]?.delete(callback);
}
provide(MODAL_CONTEXT, {
isOpen: readonly(isOpen),
open,
close,
subscribe
});
onBeforeUnmount(() => {
if (isOpen.value) {
teardown();
isOpen.value = false;
restoreFocus();
}
subscribers.open.clear();
subscribers.close.clear();
});
defineExpose({
open,
close
close,
isOpen
});
</script>
<style scoped>
.modal-content {
padding: 0 20px 20px 20px;
color: var(--text-primary);
display: flex;
flex-direction: column;
overflow-y: auto;
max-height: 75vh;
gap: 10px;
}
.modal-title {
.modal-overlay {
position: fixed;
inset: 0;
background-color: var(--overlay);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
display: flex;
align-items: center;
gap: 10px;
justify-content: center;
padding: 20px 15px;
box-sizing: border-box;
z-index: 3500;
}
.modal {
--modal-max: 600px;
display: flex;
flex-direction: column;
width: 100%;
max-width: var(--modal-max);
max-height: calc(100vh - 40px);
max-height: calc(100dvh - 40px);
background-color: var(--secondary);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
/* Le dialogue reçoit le focus à l'ouverture pour être annoncé : pas d'anneau sur ce conteneur non interactif */
.modal:focus {
outline: none;
}
.modal--sm { --modal-max: 420px; }
.modal--md { --modal-max: 600px; }
.modal--lg { --modal-max: 820px; }
.modal--xl { --modal-max: 1100px; }
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
gap: 12px;
flex-shrink: 0;
padding: 16px 20px;
background-color: var(--primary);
border-radius: 10px 10px 0px 0px ;
padding: 20px;
border-bottom: 1px solid var(--border);
}
.modal {
background-color: var(--secondary);
border-radius: 10px;
.modal-title {
display: flex;
flex-direction: column;
gap: 15px;
max-width: 600px !important;
width: 100%;
max-width: 90%;
margin-right: 15px;
margin-left: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
animation: appear 0.2s ease-in-out;
animation-fill-mode: forwards;
}
.modal p {
margin: 0px;
font-size: 1.3em;
font-weight: 600;
align-items: center;
gap: 12px;
min-width: 0;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
display: none;
.modal-title-icon {
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 3500;
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
background-color: var(--main-soft);
color: var(--main);
font-size: 1em;
}
.modal-title-text {
margin: 0;
font-size: 1.2em;
font-weight: 600;
line-height: 1.25;
overflow-wrap: anywhere;
}
.modal-header-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.modal-close {
font-size: 0.95em;
}
.modal-content {
flex: 1 1 auto;
min-height: 0;
padding: 16px 20px 20px 20px;
color: var(--text);
display: flex;
flex-direction: column;
overflow-y: auto;
overscroll-behavior: contain;
gap: 10px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 8px;
flex-shrink: 0;
padding: 12px 20px;
background-color: var(--secondary);
border-top: 1px solid var(--border);
}
/* Animations d'ouverture / fermeture */
.modal-enter-active,
.modal-leave-active {
transition: opacity var(--dur) var(--ease);
}
.modal-enter-active .modal {
animation: modal-in var(--dur) var(--ease);
}
.modal-leave-active .modal {
animation: modal-in var(--dur-fast) var(--ease) reverse;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
@keyframes modal-in {
from {
transform: translateY(8px) scale(0.97);
}
to {
transform: none;
}
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.modal-overlay {
padding: 10px;
}
.modal {
max-height: calc(100vh - 20px);
max-height: calc(100dvh - 20px);
}
.modal-header {
padding: 12px 16px;
}
.modal-content {
padding: 14px 16px 16px 16px;
}
.modal-footer {
padding: 10px 16px;
}
}
</style>
+23 -10
View File
@@ -1,17 +1,21 @@
<template>
<div class="settings-container">
<Icon style="width: 20px;" class="settings-icon" :icon="icon"/>
<section class="settings-container">
<Icon class="settings-icon" :icon="icon" aria-hidden="true"/>
<div class="settings-item">
<p class="settings-title"><Icon style="width: 20px;" class="settings-icon-replace" :icon="icon"/> {{ title }}</p>
<slot></slot>
<h3 class="settings-title"><Icon class="settings-icon-replace" :icon="icon" aria-hidden="true"/>{{ title }}</h3>
<slot></slot>
</div>
</div>
</section>
</template>
<script setup>
/**
* Section titrée d'une modale de paramètres (icône dans la marge sur grand écran, dans le titre sur mobile).
* Props : title, icon. Slot par défaut : le contenu de la section.
*/
defineProps({
title: {
type: String,
default: 'Settings item'
default: 'Paramètre'
},
icon: {
type: String,
@@ -33,19 +37,27 @@ defineProps({
}
.settings-title {
display: flex;
align-items: center;
gap: 7px;
font-size: 1.1em;
color: var(--text-primary);
line-height: 1.3;
color: var(--text);
font-weight: 600;
margin: 0;
}
.settings-icon {
margin-top: 1px;
width: 20px;
margin-top: 3px;
flex-shrink: 0;
color: var(--text-secondary);
}
.settings-icon-replace {
width: 20px;
display: none;
color: var(--text-secondary);
}
.settings-container {
@@ -54,7 +66,8 @@ defineProps({
}
.settings-item {
width: 100%;
width: 100%;
min-width: 0;
}
</style>
+2 -3
View File
@@ -1,5 +1,6 @@
<!-- Animation d'égaliseur (décorative) pour les écrans de chargement -->
<template>
<div class="animation">
<div class="animation" aria-hidden="true">
<span></span>
<span></span>
<span></span>
@@ -8,8 +9,6 @@
</div>
</template>
<style scoped>
/* Import Google font - Poppins */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@200;300;400;500;600;700&display=swap');
.animation{
height: 65px;
display: flex;
+225 -32
View File
@@ -1,66 +1,259 @@
<template>
<div class="playlist-item">
<Icon :class="{'p-icon': true, 'selected': selected}" :icon="type === 'youtube' ? 'fa-brands fa-youtube' : 'fas fa-music'" />
<p>{{ title }}</p>
</div>
<component
:is="as"
:type="as === 'button' ? 'button' : undefined"
:class="['playlist-item', { 'playlist-item--selected': selected, 'playlist-item--compact': compact }]"
:aria-current="selected && as === 'button' ? 'true' : undefined"
:title="tooltip"
>
<span :class="['playlist-item__cover', `playlist-item__cover--${source.key}`]" aria-hidden="true">
<img v-if="cover && !coverFailed" :src="cover" alt="" loading="lazy" decoding="async" @error="coverFailed = true" />
<Icon v-else class="playlist-item__fallback" :icon="source.icon" />
<span v-if="cover && !coverFailed && source.key !== 'manual'" class="playlist-item__badge">
<Icon :icon="source.icon" />
</span>
</span>
<span class="playlist-item__text">
<span class="playlist-item__title u-ellipsis">{{ displayTitle }}</span>
<span v-if="playlist || meta" class="playlist-item__meta">
<img
v-if="scope === 'guild' && creator"
class="playlist-item__avatar"
:src="creatorAvatar"
alt=""
loading="lazy"
aria-hidden="true"
/>
<span class="u-ellipsis">{{ meta }}</span>
<span v-if="scope === 'guild' && creator" class="sr-only">, créée par {{ creator }}</span>
<span v-if="synced" class="sr-only">, synchronisée avec YouTube</span>
</span>
</span>
<slot name="end"></slot>
</component>
</template>
<script setup>
/**
* Ligne de playlist (barre latérale, sélecteurs).
* Racine : <button> par défaut (prop `as` pour un autre élément, ex. dans une option de liste).
* Les écouteurs (@click, @keydown) et attributs (aria-*) du parent sont posés sur la racine.
* `title` / `type` restent acceptés seuls pour les anciens appels sans objet playlist.
*/
import { computed, ref, watch } from 'vue';
import {
playlistSource,
playlistCover,
playlistMetaLine,
isSyncedPlaylist,
creatorName,
discordAvatarUrl
} from '@/components/Widget/Playlist/playlistMeta';
const props = defineProps({
playlist: {
type: Object,
default: null
},
title: {
type: String,
required: true,
default: 'My Playlist'
default: null
},
type: {
type: String,
required: true
default: null
},
selected: {
type: Boolean,
default: false
},
scope: {
type: String,
default: 'personal'
},
compact: {
type: Boolean,
default: false
},
as: {
type: String,
default: 'button'
}
});
const coverFailed = ref(false);
const source = computed(() => playlistSource(props.playlist || { type: props.type }));
const cover = computed(() => playlistCover(props.playlist));
const displayTitle = computed(() => props.playlist?.title || props.title || 'Playlist sans titre');
const meta = computed(() => (props.playlist ? playlistMetaLine(props.playlist) : null));
const synced = computed(() => isSyncedPlaylist(props.playlist));
const creator = computed(() => (props.scope === 'guild' ? creatorName(props.playlist) : null));
const creatorAvatar = computed(() => {
const createdBy = props.playlist?.createdBy;
return discordAvatarUrl(createdBy?.id, createdBy?.avatar, 32);
});
// Infobulle : titre complet (souvent tronqué) + origine + créateur
const tooltip = computed(() => {
const parts = [displayTitle.value, source.value.label];
if (creator.value) parts.push(`Créée par ${creator.value}`);
return parts.join(' — ');
});
// Nouvelle pochette : on retente l'affichage
watch(cover, () => {
coverFailed.value = false;
});
</script>
<style scoped>
.playlist-item {
display: flex;
align-items: center;
transition: 0.2s;
gap: 10px;
cursor: pointer;
}
.playlist-item:hover .p-icon {
background-color: var(--text);
color: var(--text-inverse);
}
.playlist-item p {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 6px 8px 6px 6px;
margin: 0;
font-size: 18px;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
position: relative;
-webkit-tap-highlight-color: transparent;
outline: 2px solid transparent;
outline-offset: 0;
transition: background-color var(--dur-fast) ease, box-shadow var(--dur-fast) ease;
}
.p-icon {
.playlist-item:hover {
background-color: color-mix(in srgb, var(--text) 7%, transparent);
}
.playlist-item:active {
background-color: color-mix(in srgb, var(--text) 11%, transparent);
}
.playlist-item:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--main);
}
.playlist-item--selected,
.playlist-item--selected:hover {
background-color: var(--main-soft);
}
/* Repère de sélection à gauche (la couleur seule ne suffit pas) */
.playlist-item--selected::before {
content: '';
position: absolute;
left: 0;
top: 10px;
bottom: 10px;
width: 3px;
border-radius: var(--radius-pill);
background-color: var(--main);
}
.playlist-item__cover {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 8px;
background-color: var(--tertiary);
color: var(--text-secondary);
font-size: 1.15em;
}
.playlist-item__cover img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.playlist-item__cover--youtube { color: #ff3b3b; }
.playlist-item__cover--spotify { color: #1ed760; }
.playlist-item__cover--soundcloud { color: #ff7700; }
.playlist-item__cover--google { color: var(--text-info); }
.playlist-item__cover--manual { color: var(--main); background-color: var(--main-soft); }
.playlist-item--selected .playlist-item__cover--manual {
background-color: var(--main);
color: var(--main-contrast);
}
/* Origine en pastille sur la pochette (YouTube, Spotify…) */
.playlist-item__badge {
position: absolute;
right: -4px;
bottom: -4px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--secondary);
box-shadow: 0 0 0 2px var(--secondary);
font-size: 0.6em;
}
.playlist-item__text {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.playlist-item__title {
font-size: 0.95em;
font-weight: 600;
line-height: 1.3;
}
.playlist-item__meta {
display: flex;
align-items: center;
background-color: transparent;
padding: 15px;
border: 2px solid var(--text);
border-radius: 10px;
font-size: 25px;
transition: 0.2s;
gap: 5px;
min-width: 0;
font-size: 0.78em;
line-height: 1.3;
color: var(--text-secondary);
}
.p-icon.selected {
background-color: var(--text);
color: var(--text-inverse);
.playlist-item__avatar {
width: 14px;
height: 14px;
flex-shrink: 0;
border-radius: 50%;
object-fit: cover;
background-color: var(--tertiary);
}
.p-icon {
width: 25px;
height: 25px;
.playlist-item--compact {
padding: 4px 6px 4px 4px;
gap: 8px;
}
.playlist-item--compact .playlist-item__cover {
width: 34px;
height: 34px;
border-radius: 6px;
font-size: 0.95em;
}
.playlist-item--compact .playlist-item__badge {
width: 15px;
height: 15px;
}
</style>
+5 -8
View File
@@ -5,19 +5,16 @@
<Tag color="var(--text-secondary)" v-else>Membre</Tag>
</template>
<script setup>
/**
* Étiquette du rôle d'un utilisateur (Administrateur, Propriétaire, Modérateur, Membre).
* Prop : user ({ identity: { isAdmin, isOwner, isMod } }).
*/
import Tag from '@/components/UI/Tag.vue';
const props = defineProps({
user: {
type: Object,
required: true,
default: {
identity: {
isAdmin: false,
isMod: false,
isOwner: false
}
}
required: true
}
});
</script>
+224
View File
@@ -0,0 +1,224 @@
<template>
<div
role="radiogroup"
:aria-label="label || undefined"
:class="['ui-seg', `ui-seg--${size === 'sm' ? 'sm' : 'md'}`, { 'ui-seg--block': block }]"
>
<button
v-for="(option, index) in options"
:key="String(option.value)"
:ref="(el) => { buttons[index] = el }"
type="button"
role="radio"
:aria-checked="isSelected(option) ? 'true' : 'false'"
:tabindex="index === focusIndex ? 0 : -1"
:disabled="option.disabled"
:class="['ui-seg__option', { 'ui-seg__option--selected': isSelected(option) }]"
@click="select(option)"
@keydown="onKeydown($event, index)"
>
<Icon v-if="option.icon" class="ui-seg__icon" :icon="option.icon" aria-hidden="true" />
<span class="ui-seg__label">{{ option.label }}</span>
<span v-if="hasBadge(option)" class="ui-seg__badge">{{ option.badge }}</span>
</button>
</div>
</template>
<script setup>
/**
* Choix unique en pilules (role="radiogroup").
* options : [{ value, label, icon?, disabled?, badge? }] ; v-model = value sélectionnée.
* Clavier : flèches (sélectionnent l'option voisine active), Début / Fin.
*/
import { computed, nextTick } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number, Boolean],
default: null
},
options: {
type: Array,
default: () => []
},
size: {
type: String,
default: 'md',
validator: (value) => ['sm', 'md'].includes(value)
},
label: {
type: String,
default: null
},
block: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'change']);
// Tableau simple (non réactif) : seulement utilisé pour déplacer le focus
const buttons = [];
function isSelected(option) {
return option.value === props.modelValue;
}
function hasBadge(option) {
return option.badge !== undefined && option.badge !== null && option.badge !== '';
}
// Tabindex itinérant : l'option sélectionnée, sinon la première option active
const focusIndex = computed(() => {
const selected = props.options.findIndex((option) => isSelected(option) && !option.disabled);
if (selected !== -1) return selected;
return props.options.findIndex((option) => !option.disabled);
});
function select(option) {
if (!option || option.disabled || isSelected(option)) return;
emit('update:modelValue', option.value);
emit('change', option.value);
}
function nextEnabled(from, step) {
const count = props.options.length;
for (let i = 1; i <= count; i++) {
const index = (from + step * i + count * count) % count;
if (!props.options[index]?.disabled) return index;
}
return -1;
}
function onKeydown(event, index) {
let target = -1;
switch (event.key) {
case 'ArrowRight':
case 'ArrowDown':
target = nextEnabled(index, 1);
break;
case 'ArrowLeft':
case 'ArrowUp':
target = nextEnabled(index, -1);
break;
case 'Home':
target = nextEnabled(-1, 1);
break;
case 'End':
target = nextEnabled(props.options.length, -1);
break;
default:
return;
}
event.preventDefault();
if (target === -1) return;
select(props.options[target]);
nextTick(() => buttons[target]?.focus());
}
</script>
<style scoped>
.ui-seg {
display: inline-flex;
align-items: stretch;
gap: 2px;
max-width: 100%;
padding: 3px;
box-sizing: border-box;
background-color: var(--secondary);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
}
.ui-seg--block {
display: flex;
width: 100%;
}
.ui-seg__option {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
min-height: 32px;
padding: 0 14px;
border: none;
border-radius: var(--radius-pill);
background: transparent;
color: var(--text-secondary);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
cursor: pointer;
outline: 2px solid transparent;
outline-offset: 2px;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--dur-fast) ease, color var(--dur-fast) ease, box-shadow var(--dur-fast) ease;
}
.ui-seg--block .ui-seg__option {
flex: 1 1 0;
}
.ui-seg--sm .ui-seg__option {
min-height: 26px;
padding: 0 10px;
gap: 5px;
font-size: 0.8rem;
}
.ui-seg__option:hover:not(:disabled):not(.ui-seg__option--selected) {
color: var(--text);
background-color: color-mix(in srgb, var(--quaternary) 45%, transparent);
}
.ui-seg__option:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.ui-seg__option--selected {
background-color: var(--main);
color: var(--main-contrast);
box-shadow: var(--shadow-sm);
}
.ui-seg__option:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ui-seg__label {
overflow: hidden;
text-overflow: ellipsis;
}
.ui-seg__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 6px;
box-sizing: border-box;
border-radius: var(--radius-pill);
background-color: color-mix(in srgb, var(--text) 14%, transparent);
color: inherit;
font-size: 0.72rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ui-seg__option--selected .ui-seg__badge {
background-color: rgba(255, 255, 255, 0.25);
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.ui-seg__option {
padding: 0 10px;
}
}
</style>
+397 -141
View File
@@ -1,182 +1,438 @@
<template>
<div ref="root" :class="['ui-select', { 'ui-select--open': open, 'ui-select--disabled': disabled }]">
<button
ref="trigger"
type="button"
class="ui-select__trigger firstbox"
aria-haspopup="listbox"
:aria-expanded="open ? 'true' : 'false'"
:aria-controls="listId"
:aria-label="label || undefined"
:aria-labelledby="labelledby || undefined"
:disabled="disabled"
@click="toggle()"
@keydown="onTriggerKeydown"
>
<span class="ui-select__value">
<component :is="selectedNode()" v-if="hasSelection()" />
<span v-else class="ui-select__placeholder">{{ placeholder }}</span>
</span>
<Icon class="ui-select__chevron" icon="fa-solid fa-angle-down" aria-hidden="true" />
</button>
<ul
v-show="open"
:id="listId"
ref="list"
class="ui-select__list"
role="listbox"
tabindex="-1"
:aria-label="label || undefined"
:aria-labelledby="labelledby || undefined"
:aria-activedescendant="open && activeIndex >= 0 ? optionId(activeIndex) : undefined"
:style="listStyle"
@keydown="onListKeydown"
>
<li
v-for="(option, index) in renderedOptions()"
:id="optionId(index)"
:key="optionKey(option, index)"
role="option"
:aria-selected="isCurrent(option) ? 'true' : 'false'"
:class="['ui-select__option', { 'ui-select__option--active': index === activeIndex, 'ui-select__option--selected': isCurrent(option) }]"
@click="choose(index)"
@mousemove="activeIndex = index"
>
<span class="ui-select__option-content"><component :is="option.vnode" /></span>
<Icon v-if="isCurrent(option)" class="ui-select__check" icon="fa-solid fa-check" aria-hidden="true" />
</li>
</ul>
</div>
</template>
<script setup>
import IconAction from './IconAction.vue';
import { ref, onMounted, watch, onUnmounted } from 'vue';
import { useSlots } from 'vue';
const slots = useSlots();
const boxWidth = ref(0);
const box = ref(null);
var slotsAll = slots.default ? slots.default() : [];
var allSlots = null
if(slotsAll.length > 0) {
if(typeof slotsAll[0].children !== "string") {
allSlots = slotsAll[0].children
} else {
allSlots = slotsAll
}
}
var firstSlot = allSlots[0] || null;
var otherSlots = allSlots.slice(1);
const showMenu = ref(false);
function selectOption(index) {
// Make it like a select
if (otherSlots[index]) {
const oldFirstSlot = firstSlot;
firstSlot = otherSlots[index];
otherSlots.splice(index, 1);
otherSlots.push(oldFirstSlot);
showMenu.value = false; // Hide the menu after selection
}
updateModelValue(firstSlot ? firstSlot.props ? firstSlot.props.value : '' : '');
}
/**
* Liste déroulante (bouton + role="listbox"), dont les options sont des éléments quelconques.
* Slot par défaut : les options, chacune portant un attribut / une prop `value`
* (ex. <Tag :value="role.id">…</Tag>, <p :value="id">…</p>, ou une boucle v-for).
* v-model (facultatif) : la valeur choisie. Sans valeur valide, la première option est choisie
* et émise (comportement historique).
* Props : label (nom accessible) ou labelledby (id d'un libellé visible), placeholder, disabled.
* Exposé : firstSlot() → vnode de l'option choisie (historique : .props.value, .children), value() → valeur.
* Clavier : Entrée / Espace / flèches ouvrent ; flèches, Début / Fin, Entrée (choisir), Échap (fermer).
* Événements : update:modelValue, change.
*/
import { Comment, Fragment, Text, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, useId, useSlots } from 'vue';
const props = defineProps({
modelValue: String
modelValue: {
type: [String, Number],
default: undefined
},
label: {
type: String,
default: null
},
labelledby: {
type: String,
default: null
},
placeholder: {
type: String,
default: 'Choisir…'
},
disabled: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue']);
const emit = defineEmits(['update:modelValue', 'change']);
function updateModelValue(value) {
emit('update:modelValue', value);
const slots = useSlots();
const uid = useId();
const listId = `select-${uid}`;
const root = ref(null);
const trigger = ref(null);
const list = ref(null);
const open = ref(false);
const activeIndex = ref(-1);
const internal = ref(undefined);
const listStyle = ref(null);
// Options du dernier rendu : le slot est relu à chaque rendu (hasSelection, appelé en premier dans le
// gabarit) pour rester réactif. Variable simple : la modifier pendant le rendu ne doit rien redéclencher.
let lastOptions = [];
function flatten(vnodes, out = []) {
for (const vnode of vnodes || []) {
if (!vnode || vnode.type === Comment) continue;
if (vnode.type === Text) continue;
if (vnode.type === Fragment && Array.isArray(vnode.children)) {
flatten(vnode.children, out);
continue;
}
out.push(vnode);
}
return out;
}
onMounted(() => {
function readOptions() {
const nodes = flatten(slots.default ? slots.default() : []);
return nodes.map((vnode) => ({ vnode, value: vnode.props?.value }));
}
window.addEventListener('rightclick', (e) => {
e.preventDefault(); // Prevent default context menu
});
function optionId(index) {
return `${listId}-opt-${index}`;
}
window.addEventListener('click', (e) => {
hideMenu(e);
});
function optionKey(option, index) {
return option.value !== undefined && option.value !== null ? `v-${String(option.value)}-${index}` : `i-${index}`;
}
window.addEventListener('resize', () => {
showMenu.value = false;
function currentValue() {
const options = lastOptions;
const has = (value) => value !== undefined && options.some((option) => option.value === value);
if (has(props.modelValue)) return props.modelValue;
if (has(internal.value)) return internal.value;
return options[0]?.value;
}
});
updateModelValue(firstSlot ? firstSlot.props ? firstSlot.props.value : '' : '');
function isCurrent(option) {
return option.value === currentValue();
}
});
function currentIndex() {
return lastOptions.findIndex((option) => isCurrent(option));
}
onUnmounted(() => {
window.removeEventListener('rightclick', (e) => {
e.preventDefault(); // Prevent default context menu
});
window.removeEventListener('click', (e) => {
hideMenu(e);
});
function hasSelection() {
// Relit le slot à chaque rendu : les options suivent les données du parent
lastOptions = readOptions();
return currentIndex() !== -1;
}
window.removeEventListener('resize', () => {
showMenu.value = false;
});
});
function renderedOptions() {
return lastOptions;
}
function hideMenu(e) {
// Check if the click is outside the context menu
if (box.value && !box.value.contains(e.target)) {
showMenu.value = false; // Hide context menu on click outside
function selectedNode() {
return lastOptions[currentIndex()]?.vnode || null;
}
// Synchronise le v-model quand la valeur affichée (1re option par défaut) n'est pas celle du parent
function syncModel() {
const value = currentValue();
if (value === undefined || value === props.modelValue) return;
emit('update:modelValue', value);
}
function setValue(value) {
internal.value = value;
if (value !== props.modelValue) {
emit('update:modelValue', value);
emit('change', value);
}
}
function positionList() {
const button = trigger.value;
if (!button) return;
const rect = button.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const maxHeight = 260;
const above = spaceBelow < Math.min(maxHeight, 160) && rect.top > spaceBelow;
listStyle.value = {
left: `${rect.left}px`,
width: `${rect.width}px`,
maxHeight: `${Math.max(120, Math.min(maxHeight, (above ? rect.top : spaceBelow) - 12))}px`,
...(above ? { bottom: `${window.innerHeight - rect.top + 4}px`, top: 'auto' } : { top: `${rect.bottom + 4}px`, bottom: 'auto' })
};
}
function openList(focusIndex) {
if (props.disabled || lastOptions.length === 0) return;
positionList();
open.value = true;
activeIndex.value = focusIndex ?? Math.max(0, currentIndex());
addGlobalListeners();
nextTick(() => {
list.value?.focus({ preventScroll: true });
scrollActiveIntoView();
});
}
function closeList(focusTrigger = true) {
if (!open.value) return;
open.value = false;
removeGlobalListeners();
if (focusTrigger) trigger.value?.focus({ preventScroll: true });
}
function toggle() {
if (open.value) closeList();
else openList();
}
function choose(index) {
const option = lastOptions[index];
if (!option) return;
setValue(option.value);
closeList();
}
function scrollActiveIntoView() {
const element = list.value?.querySelector(`#${CSS.escape(optionId(activeIndex.value))}`);
element?.scrollIntoView({ block: 'nearest' });
}
function moveActive(target) {
const count = lastOptions.length;
if (count === 0) return;
activeIndex.value = Math.max(0, Math.min(count - 1, target));
nextTick(scrollActiveIntoView);
}
function onTriggerKeydown(event) {
if (['ArrowDown', 'ArrowUp'].includes(event.key)) {
event.preventDefault();
openList(event.key === 'ArrowUp' ? Math.max(0, currentIndex() - 1) : undefined);
}
}
function onListKeydown(event) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
moveActive(activeIndex.value + 1);
break;
case 'ArrowUp':
event.preventDefault();
moveActive(activeIndex.value - 1);
break;
case 'Home':
event.preventDefault();
moveActive(0);
break;
case 'End':
event.preventDefault();
moveActive(lastOptions.length - 1);
break;
case 'Enter':
case ' ':
event.preventDefault();
choose(activeIndex.value);
break;
case 'Escape':
event.preventDefault();
event.stopPropagation();
closeList();
break;
case 'Tab':
closeList(false);
break;
default:
break;
}
}
function onDocumentPointerDown(event) {
if (root.value && !root.value.contains(event.target) && !list.value?.contains(event.target)) closeList(false);
}
function onScroll(event) {
if (list.value && event.target instanceof Node && list.value.contains(event.target)) return;
closeList(false);
}
function onResize() {
closeList(false);
}
function addGlobalListeners() {
document.addEventListener('pointerdown', onDocumentPointerDown, true);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
}
function removeGlobalListeners() {
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
}
onMounted(syncModel);
onUpdated(syncModel);
onBeforeUnmount(removeGlobalListeners);
defineExpose({
firstSlot: () => {
return firstSlot;
}
})
firstSlot: () => selectedNode(),
value: () => currentValue()
});
</script>
<template>
<section>
<div ref="box" :class="showMenu ? `showed firstbox` : 'firstbox'" @click="showMenu = !showMenu">
<template v-if="firstSlot">
<component style="white-space: break-spaces;" :is="firstSlot" />
</template>
<IconAction
class="icon"
:icon="showMenu ? 'fa-solid fa-angle-up' : 'fa-solid fa-angle-down'"
/>
</div>
<template v-if="otherSlots.length && showMenu">
<div class="container">
<div v-for="(slot, index) in otherSlots" :style="`width: ${box.offsetWidth - 10}px;`" class="option" @click="selectOption(index)" >
<component :is="slot" :key="index" />
</div>
</div>
</template>
</section>
</template>
<style scoped>
.firstbox {
background-color: var(--tertiary);
color: var(--text);
border: none;
border-radius: 5px;
padding: 5px;
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
.ui-select {
position: relative;
min-width: 0;
}
.container {
color: var(--text);
border: none;
border-radius: 0px 0px 5px 5px;
font-size: 14px;
display: flex;
flex-direction: column;
position: fixed;
justify-content: space-between;
user-select: none;
overflow-y: auto;
max-height: 16vh;
z-index: 4000;
.ui-select__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-height: 38px;
padding: 6px 10px;
box-sizing: border-box;
border: none;
border-radius: var(--radius-sm);
background-color: var(--tertiary);
color: var(--text);
font: inherit;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
outline: 2px solid transparent;
transition: background-color var(--dur-fast) ease, box-shadow var(--dur-fast) ease;
}
.container .option:last-child {
border-radius: 0px 0px 5px 5px !important;
.ui-select__trigger:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--tertiary) 80%, var(--text));
}
.container .option:only-child {
border-radius: 0px 0px 5px 5px !important;
.ui-select__trigger:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.container .option:first-child {
border-top: 1px solid var(--secondary) !important;
.ui-select--open .ui-select__trigger {
box-shadow: 0 0 0 2px var(--main);
}
.option {
width: 100%;
padding: 5px;
background-color: var(--tertiary);
cursor: pointer;
white-space: break-spaces;
.ui-select__trigger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.option:hover {
background-color: var(--primary-hover);
color: var(--text-inverse);
.ui-select__value {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
white-space: break-spaces;
}
.showed {
/* margin-bottom: 0 !important;
padding-bottom: 0; */
border-radius: 5px 5px 0 0 !important;
.ui-select__placeholder {
color: var(--text-secondary);
}
.icon {
display: flex;
align-items: center;
.ui-select__chevron {
flex-shrink: 0;
color: var(--text-secondary);
transition: transform var(--dur-fast) ease;
}
.ui-select--open .ui-select__chevron {
transform: rotate(180deg);
}
.ui-select__list {
position: fixed;
z-index: 4000;
margin: 0;
padding: 4px;
box-sizing: border-box;
list-style: none;
overflow-y: auto;
overscroll-behavior: contain;
background-color: var(--secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
outline: none;
}
.ui-select__option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 6px 10px;
border-radius: var(--radius-sm);
font-size: 0.875rem;
cursor: pointer;
user-select: none;
white-space: break-spaces;
}
.ui-select__option--active {
background-color: color-mix(in srgb, var(--text) 10%, transparent);
}
/* L'option active au clavier est entourée (le focus reste sur la liste, aria-activedescendant) */
.ui-select__list:focus-visible .ui-select__option--active {
box-shadow: inset 0 0 0 2px var(--main);
}
.ui-select__option--selected {
font-weight: 600;
}
.ui-select__option-content {
display: flex;
align-items: center;
min-width: 0;
}
.ui-select__check {
flex-shrink: 0;
color: var(--main);
}
/* Les anciennes options en <p> gardent leur mise en page sans marge */
.ui-select__value :deep(p),
.ui-select__option-content :deep(p) {
margin: 0;
}
</style>
+44 -5
View File
@@ -3,11 +3,21 @@
<ServerAvatar :server-id="server.id" :src="server.icon"/>
<div class="info">
<p class="name">{{ server.name }}</p>
<p class="data"><Icon style="font-size: 10px;" :color="server.connected ? '#0BFF89' : '#FF0A0A'" icon="fa-solid fa-circle"/> {{ server.connected ? 'En ligne' : 'Hors ligne' }} - {{ server.serverMember }} membres</p>
<p class="data">
<span :class="['status-dot', server.connected ? 'status-dot--on' : 'status-dot--off']" aria-hidden="true"></span>
<span>{{ server.connected ? 'En ligne' : 'Hors ligne' }}</span>
<span aria-hidden="true">·</span>
<span>{{ memberLabel }}</span>
</p>
</div>
</div>
</template>
<script setup>
/**
* Résumé d'un serveur : icône, nom, état du bot (en ligne / hors ligne) et nombre de membres.
* Prop : server ({ id, name, icon, connected, serverMember }).
*/
import { computed } from 'vue';
import ServerAvatar from '../UI/ServerAvatar.vue';
const props = defineProps({
@@ -15,7 +25,7 @@ const props = defineProps({
type: Object,
default: () => ({
id: '',
name: 'Aucun serveur séléctionné',
name: 'Aucun serveur sélectionné',
owner: false,
icon: '',
members: [],
@@ -25,30 +35,59 @@ const props = defineProps({
}
});
const memberLabel = computed(() => {
const count = Number(props.server?.serverMember) || 0;
return `${count} membre${count > 1 ? 's' : ''}`;
});
</script>
<style scoped>
.container-avatar {
display: flex;
align-items: center;
gap: 10px;
gap: 12px;
width: 100%;
min-width: 0;
}
p {
margin: 0;
}
.name {
font-weight: 600;
line-height: 1.3;
overflow-wrap: anywhere;
}
.data {
color: var(--text-secondary);
font-size: 0.8em;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 3px;
gap: 5px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot--on {
background-color: var(--text-success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-success) 20%, transparent);
}
.status-dot--off {
background-color: var(--text-error);
}
.info {
display: flex;
flex-direction: column;
gap: 5px;
gap: 4px;
min-width: 0;
}
</style>
+59 -15
View File
@@ -1,23 +1,67 @@
<template>
<img v-if="src" :src="src" />
<img v-else :src="`https://cdn.discordapp.com/embed/avatars/${serverId % 5}.png`" />
<img
:src="source"
:alt="alt"
class="ui-server-avatar"
:style="size ? { '--server-avatar-size': size } : null"
loading="lazy"
decoding="async"
@error="failed = true"
/>
</template>
<script setup>
const props = defineProps({
src: {
type: String,
default: null
},
serverId: {
type: String,
default: null
}
});
/**
* Icône d'un serveur Discord (carré arrondi). Sans icône (ou en cas d'erreur de chargement) :
* avatar par défaut de Discord.
* Props : src, serverId, alt (vide par défaut : le nom du serveur est affiché à côté),
* size (longueur CSS, 70px par défaut ; aussi réglable par la variable --server-avatar-size
* ou directement par une classe du parent : les styles internes sont de spécificité nulle).
*/
import { computed, ref, watch } from 'vue';
const props = defineProps({
src: {
type: String,
default: null
},
serverId: {
type: String,
default: null
},
alt: {
type: String,
default: ''
},
size: {
type: String,
default: null
}
});
const failed = ref(false);
watch(() => props.src, () => {
failed.value = false;
});
const source = computed(() => {
if (props.src && !failed.value) return props.src;
// Identifiant Discord (chaîne trop longue pour un Number exact) : BigInt, sinon somme des caractères
const id = String(props.serverId || '0');
let index = 0;
try {
index = /^\d+$/.test(id) ? Number(BigInt(id) % 5n) : [...id].reduce((sum, char) => sum + char.charCodeAt(0), 0) % 5;
} catch {
index = 0;
}
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
});
</script>
<style scoped>
img {
width: 70px;
height: 70px;
:where(.ui-server-avatar) {
width: var(--server-avatar-size, 70px);
height: var(--server-avatar-size, 70px);
flex-shrink: 0;
object-fit: cover;
border-radius: 15px;
background-color: var(--main);
}
+72
View File
@@ -0,0 +1,72 @@
<template>
<span :class="['ui-spinner', `ui-spinner--${sizeClass}`]" :style="sizeStyle" role="status">
<span class="ui-spinner__ring" aria-hidden="true"></span>
<span :class="visibleLabel ? 'ui-spinner__label' : 'sr-only'">{{ label }}</span>
</span>
</template>
<script setup>
/**
* Petit indicateur de chargement en ligne.
* `label` est lu par les lecteurs d'écran (visible si `visibleLabel`).
* `size` : 'sm' | 'md' | 'lg' ou une longueur CSS ('24px', '2em'…).
*/
import { computed } from 'vue';
const props = defineProps({
label: {
type: String,
default: 'Chargement…'
},
size: {
type: String,
default: 'md'
},
visibleLabel: {
type: Boolean,
default: false
}
});
const PRESETS = ['sm', 'md', 'lg'];
const sizeClass = computed(() => (PRESETS.includes(props.size) ? props.size : 'custom'));
const sizeStyle = computed(() => (PRESETS.includes(props.size) ? null : { '--spinner-size': props.size }));
</script>
<style scoped>
.ui-spinner {
--spinner-size: 1.25em;
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
vertical-align: middle;
}
.ui-spinner--sm { --spinner-size: 14px; }
.ui-spinner--md { --spinner-size: 20px; }
.ui-spinner--lg { --spinner-size: 32px; }
.ui-spinner__ring {
display: inline-block;
flex-shrink: 0;
width: var(--spinner-size);
height: var(--spinner-size);
box-sizing: border-box;
border: max(2px, calc(var(--spinner-size) / 9)) solid color-mix(in srgb, var(--main) 25%, transparent);
border-top-color: var(--main);
border-radius: 50%;
animation: u-spin 0.75s linear infinite;
}
.ui-spinner__label {
font-size: 0.9em;
}
/* Le spinner porte une information : il continue de tourner, plus lentement */
@media (prefers-reduced-motion: reduce) {
.ui-spinner__ring {
animation-duration: 1.6s !important;
animation-iteration-count: infinite !important;
}
}
</style>
+7 -2
View File
@@ -1,13 +1,14 @@
<template>
<div class="splash-container">
<div class="splash-title">
<LogoWhite class="img"/>
<LogoWhite class="img" aria-hidden="true" focusable="false"/>
<h1>Subsonics</h1>
</div>
<span class="high"><slot></slot></span>
<span v-for="(note, index) in notes"
:key="index"
class="note delayed"
aria-hidden="true"
:style="{ left: `${note.x}%`, top: `${note.y}%`, '--i': index }"
>
{{ note.value }}
@@ -16,6 +17,10 @@
</template>
<script setup>
/**
* Fond plein écran rose dégradé avec notes de musique flottantes (décoratives) et logo.
* Slot par défaut : contenu centré au premier plan.
*/
import LogoWhite from '@/assets/LogoWhite.vue';
import { computed, ref } from 'vue';
@@ -85,7 +90,7 @@ generateNotes();
h1 {
font-family: 'Gunship';
font-size: 30px;
color: white;
color: var(--main-contrast);
user-select: none;
}
+6 -3
View File
@@ -1,15 +1,18 @@
<template>
<div class="subsonics-logo">
<LogoDark class="img" v-if="globalStore.theme == 'light'"/>
<LogoLight class="img" v-else/>
<LogoDark class="img" v-if="globalStore.theme == 'light'" aria-hidden="true" focusable="false"/>
<LogoLight class="img" v-else aria-hidden="true" focusable="false"/>
<div v-if="dev" class="text-p">
<h1>Subsonics</h1>
<p>Developement</p>
<p>Développement</p>
</div>
<h1 v-if="!dev">Subsonics</h1>
</div>
</template>
<script setup>
/**
* Logo + nom « Subsonics » (mention « Développement » en mode dev). Décoratif : le titre est le texte.
*/
import LogoDark from '@/assets/LogoDark.vue';
import LogoLight from '@/assets/LogoLight.vue';
import { useGlobalStore } from '@/stores/globalStore';
+24 -22
View File
@@ -1,30 +1,32 @@
<template>
<div>
<Icon icon="fa-solid fa-check-circle"></Icon>
<div class="success-message">
<slot></slot>
</div>
<div class="feedback" role="status">
<Icon class="feedback-icon" icon="fa-solid fa-check-circle" aria-hidden="true"></Icon>
<div class="success-message">
<slot></slot>
</div>
</div>
</template>
<script setup>
/**
* Message de réussite en vert (role="status"). Slot par défaut : le message.
*/
</script>
<style scoped>
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
/* Spécificité de « div » (comme avant) : une classe posée par le parent reste prioritaire */
div:where(.feedback) {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-success);
animation: u-fade-in 0.5s ease-in-out;
}
div {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-success);
animation: fadeIn 0.5s ease-in-out;
}
.feedback-icon {
flex-shrink: 0;
}
.success-message {
display: flex;
text-align: justify;
}
.success-message {
display: flex;
text-align: justify;
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<template>
<div :class="['ui-switch', { 'ui-switch--disabled': disabled, 'ui-switch--on': modelValue }, $attrs.class]" :style="$attrs.style">
<div v-if="label || description || $slots.default" class="ui-switch__text">
<label :id="labelId" :for="controlId()" class="ui-switch__label">
<slot>{{ label }}</slot>
</label>
<span v-if="description" :id="descriptionId" class="ui-switch__description">{{ description }}</span>
</div>
<button
v-bind="controlAttrs()"
:id="controlId()"
type="button"
role="switch"
class="ui-switch__control"
:aria-checked="modelValue ? 'true' : 'false'"
:aria-labelledby="labelledBy()"
:aria-describedby="describedBy()"
:disabled="disabled"
@click="toggle"
>
<span class="ui-switch__thumb" aria-hidden="true"></span>
</button>
</div>
</template>
<script setup>
/**
* Interrupteur on/off (role="switch").
* v-model (Boolean), `label` (ou slot par défaut), `description`, `disabled`.
* Sans libellé visible, passer un aria-label : il est transmis au bouton.
* Un `id` ou un `aria-labelledby` passés par le parent sont prioritaires (ex. <label for="…"> externe).
*/
import { useAttrs, useId, useSlots } from 'vue';
defineOptions({ inheritAttrs: false });
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
label: {
type: String,
default: null
},
description: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'change']);
// class / style vont sur le conteneur, le reste (aria-label, data-*…) sur le bouton
const attrs = useAttrs();
const slots = useSlots();
function controlAttrs() {
const { class: _class, style: _style, id: _id, 'aria-labelledby': _labelledby, 'aria-describedby': _describedby, ...rest } = attrs;
return rest;
}
const uid = useId();
const generatedId = `switch-${uid}`;
// useAttrs() n'est pas réactif : relu à chaque rendu
function controlId() {
return attrs.id || generatedId;
}
// aria-describedby du parent conservé et complété par la description
function describedBy() {
const ids = [attrs['aria-describedby'], props.description ? descriptionId : null].filter(Boolean);
return ids.length ? ids.join(' ') : undefined;
}
function labelledBy() {
if (attrs['aria-labelledby']) return attrs['aria-labelledby'];
return (props.label || slots.default) ? labelId : undefined;
}
const labelId = `switch-label-${uid}`;
const descriptionId = `switch-desc-${uid}`;
function toggle() {
if (props.disabled) return;
const value = !props.modelValue;
emit('update:modelValue', value);
emit('change', value);
}
</script>
<style scoped>
.ui-switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.ui-switch__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.ui-switch__label {
font-weight: 500;
line-height: 1.3;
color: var(--text);
cursor: pointer;
}
.ui-switch__description {
font-size: 0.85em;
line-height: 1.35;
color: var(--text-secondary);
}
.ui-switch__control {
position: relative;
flex-shrink: 0;
width: 42px;
height: 24px;
padding: 0;
border: none;
border-radius: var(--radius-pill);
background-color: var(--quaternary);
cursor: pointer;
outline: 2px solid transparent;
outline-offset: 2px;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--dur) var(--ease), box-shadow var(--dur-fast) ease;
}
.ui-switch__control:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.ui-switch__thumb {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: #fff;
box-shadow: var(--shadow-sm);
transition: transform var(--dur) var(--ease);
}
.ui-switch--on .ui-switch__control {
background-color: var(--main);
}
.ui-switch--on .ui-switch__thumb {
transform: translateX(18px);
}
.ui-switch:not(.ui-switch--disabled) .ui-switch__control:hover {
background-color: color-mix(in srgb, var(--quaternary) 80%, var(--text));
}
.ui-switch--on:not(.ui-switch--disabled) .ui-switch__control:hover {
background-color: var(--main-hover);
}
.ui-switch--disabled {
opacity: 0.55;
}
.ui-switch--disabled .ui-switch__label,
.ui-switch--disabled .ui-switch__control {
cursor: not-allowed;
}
</style>
+56 -8
View File
@@ -1,11 +1,19 @@
<template>
<div class="tag" :style="{ borderColor: props.color }">
<Icon :color="props.color" :icon="props.icon"/>
<p><slot></slot></p>
<div :class="['tag', tone ? `tag--${tone}` : null, { 'tag--colored': color || tone }]" :style="tagStyle">
<Icon v-if="icon" class="tag-icon" :icon="icon" aria-hidden="true"/>
<span class="tag-text"><slot></slot></span>
</div>
</template>
<script setup>
const props = defineProps({
/**
* Étiquette en pilule.
* `tone` (default | main | success | warning | error | info) teinte le fond et la bordure ;
* `color` (historique) colore la bordure et l'icône — prioritaire sur le ton s'il est fourni.
* `icon` : icône décorative (pastille ronde par défaut). Slot par défaut : le texte (un <span>, utilisable dans un titre).
*/
import { computed } from 'vue';
const props = defineProps({
color: {
type: String,
default: ''
@@ -13,26 +21,66 @@
icon: {
type: String,
default: 'fa-circle fa-solid'
},
tone: {
type: String,
default: null,
validator: (value) => value === null || ['default', 'main', 'success', 'warning', 'error', 'info'].includes(value)
}
});
const tagStyle = computed(() => (props.color ? { '--tag-color': props.color } : null));
</script>
<style scoped>
.tag {
border: 1px solid #ccc;
border-radius: 20px;
padding: 5px 6px;
--tag-color: color-mix(in srgb, var(--text) 35%, transparent);
border: 1px solid var(--tag-color);
border-radius: var(--radius-pill);
padding: 4px 8px;
margin: 0;
display: flex;
align-items: center;
gap: 5px;
font-size: 0.7em;
font-weight: 500;
line-height: 1.2;
width: fit-content;
max-width: 100%;
box-sizing: border-box;
}
p {
.tag-icon {
flex-shrink: 0;
}
/* Sans couleur ni ton, l'icône garde la couleur du texte (comme avant) */
.tag--colored .tag-icon {
color: var(--tag-color);
}
/* Tons : fond teinté à 15 % + bordure de la couleur du ton */
.tag--default,
.tag--main,
.tag--success,
.tag--warning,
.tag--error,
.tag--info {
background-color: color-mix(in srgb, var(--tag-color) 15%, transparent);
}
.tag--default { --tag-color: var(--text-secondary); }
.tag--main { --tag-color: var(--main); }
.tag--success { --tag-color: var(--text-success); }
.tag--warning { --tag-color: var(--text-warning); }
.tag--error { --tag-color: var(--text-error); }
.tag--info { --tag-color: var(--text-info); }
.tag-text {
margin: 0;
display: flex;
align-items: center;
min-width: 0;
}
</style>
+4
View File
@@ -11,6 +11,10 @@
</div>
</template>
<script setup>
/**
* Carte utilisateur : avatar (pastille de rôle), nom d'affichage et identifiant Discord.
* Props : user ({ identity: { id, avatar, username, global_name, isMod, isOwner, isAdmin } }), mobile (disposition centrée).
*/
import Avatar from './Avatar.vue';
const props = defineProps({
user: {
+17 -5
View File
@@ -1,17 +1,17 @@
<template>
<div ref="videoContainer" :class="{'video': true, 'video-mobile': mobile}">
<div ref="thumbnailContainer" :class="{'thumbnail-container': true, 'thumbnail-mobile': mobile}">
<img v-if="video" class="thumbnail" :src="video.thumbnail" alt="Video Thumbnail" />
<img v-if="video" class="thumbnail" :src="video.thumbnail" alt="" loading="lazy" decoding="async" />
<div v-else class="thumbnail-placeholder"></div>
<p v-if="video && typeof video.duration === 'number' && video.duration != 0 && !mobile" class="duration">{{ getVideoDuration(video.duration) }}</p>
<p v-else-if="video && video.duration === 0 && !mobile" class="duration align"><Icon font-size="10px" color="red" icon="fa-solid fa-circle"></Icon> LIVE</p>
<p v-else-if="video && video.duration === 0 && !mobile" class="duration align"><Icon class="live-dot" icon="fa-solid fa-circle" aria-hidden="true"></Icon> LIVE</p>
<span v-else></span>
<slot></slot>
</div>
<div :class="{'info-mobile': mobile, 'info': !mobile}">
<Marquee v-if="video && mobile"><p>{{ video.title }}</p></Marquee>
<p v-else-if="video" class="title">{{ video.title }}</p>
<p v-else class="title">Aucun titre jouée</p>
<p v-else class="title">Aucun titre joué</p>
<p v-if="video" class="author">{{ video.author }}</p>
<p v-else class="author">Aucun auteur</p>
@@ -19,6 +19,12 @@
</div>
</template>
<script setup>
/**
* Carte d'un titre : miniature 16:9 (durée ou LIVE en surimpression), titre et auteur.
* Props : video ({ title, author, thumbnail, duration } ou null), mobile (disposition en ligne, titre défilant).
* Slot par défaut : superposé à la miniature (boutons d'action de VideoComposable).
* Exposé : getThumbnailContainer(), getVideoContainer() (éléments DOM).
*/
import { getVideoDuration } from '@/utils/TimeConverter';
import { ref } from 'vue';
import Marquee from './Marquee.vue';
@@ -59,6 +65,11 @@ defineExpose({
gap: 3px;
}
.live-dot {
font-size: 0.7em;
color: var(--text-error);
}
.thumbnail {
width: 100%;
height: auto;
@@ -98,8 +109,9 @@ defineExpose({
position: absolute;
bottom: 0px;
right: 0px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
background-color: rgba(0, 0, 0, 0.65);
color: var(--on-media);
font-variant-numeric: tabular-nums;
padding: 2px 5px;
border-radius: 15px;
font-size: 0.8em;
+9 -6
View File
@@ -1,24 +1,27 @@
<template>
<div :data-theme="mobile ? 'dark' : null" :class="'video'" >
<div :class="mobile ? 'mobile-thumbnail-container' : 'thumbnail-container'">
<img v-if="video && video.thumbnail && !playerOffline" :class="mobile ? 'mobile-thumbnail' : 'thumbnail'" :src="video.thumbnail" alt="Video Thumbnail" />
<img v-if="video && video.thumbnail && !playerOffline" :class="mobile ? 'mobile-thumbnail' : 'thumbnail'" :src="video.thumbnail" alt="" />
<div v-else :class="mobile ? 'mobile-thumbnail-placeholder' : 'thumbnail-placeholder'"></div>
</div>
<div class="info">
<p v-if="playerOffline" class="title secondary">Subsonics est hors-ligne</p>
<Marquee v-else-if="video" :class="{'title': true}">
<span :style="{ color: mobile ? 'white' : '' }">{{ video.title }}</span>
<span :style="{ color: mobile ? 'var(--text)' : '' }">{{ video.title }}</span>
</Marquee>
<p v-else class="title">Aucun titre jouée</p>
<p v-else class="title">Aucun titre joué</p>
<p v-if="playerOffline" class="author">Connectez vous d'abord à un salon audio pour lancer un titre</p>
<p v-if="playerOffline" class="author">Connectez-vous d'abord à un salon vocal pour lancer un titre</p>
<p v-else-if="video" class="author">{{ video.author }}</p>
<p v-else class="author">Aucun auteur</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from "vue";
/**
* Ligne « titre » compacte : miniature, titre (défilant s'il est long) et auteur.
* Props : video ({ title, author, thumbnail } ou null), playerOffline (message hors ligne), mobile (miniature carrée, thème sombre).
*/
import Marquee from "./Marquee.vue";
const props = defineProps({
@@ -94,7 +97,7 @@ const props = defineProps({
bottom: 0px;
right: 0px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
color: var(--on-media);
padding: 2px 5px;
border-radius: 15px;
font-size: 0.8em;
+9 -6
View File
@@ -2,24 +2,27 @@
<div :data-theme="mobile ? 'dark' : null" :class="'video'" >
<div :class="mobile ? 'mobile-thumbnail-container' : 'thumbnail-container'">
<div v-if="playerOffline" :class="mobile ? 'mobile-thumbnail-placeholder' : 'thumbnail-placeholder'"></div>
<img v-else-if="video && video.thumbnail" :class="mobile ? 'mobile-thumbnail' : 'thumbnail'" :src="video.thumbnail" alt="Video Thumbnail" />
<img v-else-if="video && video.thumbnail" :class="mobile ? 'mobile-thumbnail' : 'thumbnail'" :src="video.thumbnail" alt="" />
<div v-else :class="mobile ? 'mobile-thumbnail-placeholder' : 'thumbnail-placeholder'"></div>
</div>
<div class="info">
<p v-if="playerOffline" class="title secondary">Subsonics est hors-ligne</p>
<Marquee v-else-if="video" :class="{'title': true}">
<span :style="{ color: mobile ? 'white' : '' }">{{ video.title }}</span>
<span :style="{ color: mobile ? 'var(--text)' : '' }">{{ video.title }}</span>
</Marquee>
<p v-else class="title">Aucun titre jouée</p>
<p v-else class="title">Aucun titre joué</p>
<p v-if="playerOffline" class="author">Connectez vous d'abord à un salon audio pour lancer un titre</p>
<p v-if="playerOffline" class="author">Connectez-vous d'abord à un salon vocal pour lancer un titre</p>
<p v-else-if="video" class="author">{{ video.author }}</p>
<p v-else class="author">Aucun auteur</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from "vue";
/**
* Ligne « titre » compacte : miniature, titre (défilant s'il est long) et auteur.
* Props : video ({ title, author, thumbnail } ou null), playerOffline (message hors ligne), mobile (miniature carrée, thème sombre).
*/
import Marquee from "./Marquee.vue";
const props = defineProps({
@@ -95,7 +98,7 @@ const props = defineProps({
bottom: 0px;
right: 0px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
color: var(--on-media);
padding: 2px 5px;
border-radius: 15px;
font-size: 0.8em;
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<Box class="box" padding="closed">
<Carousel only-mobile :auto-play="false" disable-arrow class="carousel">
<Carousel only-mobile :auto-play="false" disable-arrow class="carousel" :page-labels="['Playlists', 'File de lecture', 'Compte']">
<Playlist/>
<Queue class="dispatch-mobile"/>
<AccountMobile mobile class="account"/>
@@ -1,44 +1,53 @@
<template>
<div class="container" v-for="(member, index) in server.members" :key="index">
<li class="member" v-for="member in server.members" :key="member.id">
<User :user="{identity: member}" />
<IconAction :title="`Action sur ${member.username}`" @click="setTargetUser(member)" v-if="(userStore.userInfo.labels.includes('ADMIN') || (userStore.userInfo.labels.includes('MOD_' + server.id) && !member.isMod) || server.owner == userStore.userInfo.identity.id) && (member.id != server.owner && !member.isAdmin) "
<IconAction
v-if="canActOn(member)"
icon="fa-solid fa-ellipsis-vertical"
:label="`Actions sur ${member.global_name || member.username}`"
aria-haspopup="menu"
@click="setTargetUser(member, $event)"
/>
</div>
<ContextMenu ref="menuRef" v-if="userStore.userInfo.labels.includes('ADMIN') || userStore.userInfo.labels.includes('MOD_' + server.id) || server.owner == userStore.userInfo.identity.id">
<div v-if="!targetUser?.isMod" @click="banUser()">
<Icon icon="fa-solid fa-hammer"/>
<p>Bannir</p>
</div>
<div @click="toogleMod()" v-if="userStore.userInfo.labels.includes('ADMIN') || userStore.userInfo.identity.id == server.owner">
<Icon icon="fa-solid fa-user-shield"/>
<p v-if="!targetUser?.isMod">Nommer modérateur</p>
<p v-else>Retirer les droits de modérateur</p>
</div>
</li>
<ContextMenu
v-if="isStaff"
ref="menuRef"
:label="targetUser ? `Actions sur ${targetUser.global_name || targetUser.username}` : 'Actions'"
>
<button v-if="canSwitchMod" type="button" @click="toogleMod()">
<Icon icon="fa-solid fa-user-shield" aria-hidden="true"/>
<span>{{ targetUser?.isMod ? 'Retirer les droits de modérateur' : 'Nommer modérateur' }}</span>
</button>
<button v-if="!targetUser?.isMod" type="button" class="ui-menu__danger" @click="askBan()">
<Icon icon="fa-solid fa-hammer" aria-hidden="true"/>
<span>Bannir du serveur</span>
</button>
</ContextMenu>
<Modal v-if="isStaff" ref="banModal" size="sm" icon="fa-solid fa-hammer" title="Bannir cet utilisateur ?">
<p class="ban-text">
<strong>{{ banTarget?.global_name || banTarget?.username }}</strong> ne pourra plus utiliser Subsonics sur ce serveur.
Un modérateur pourra lever le bannissement depuis la gestion du serveur.
</p>
<template #footer>
<Button variant="secondary" @click="banModal.close()">Annuler</Button>
<Button variant="danger" icon="fa-solid fa-hammer" @click="banUser()">Bannir</Button>
</template>
</Modal>
</template>
<script setup>
/**
* Lignes (<li>) des utilisateurs connectés d'un serveur, avec menu de modération
* (nommer / retirer modérateur, bannir après confirmation). À placer dans une <ul>.
* Requêtes : /OWNER/USERS/SWITCH_MOD id, /MOD/USERS/BAN id.
*/
import { computed, ref } from 'vue';
import User from '@/components/UI/User.vue';
import { ref } from 'vue';
import IconAction from '@/components/UI/IconAction.vue';
import ContextMenu from '@/components/UI/ContextMenu.vue';
import Modal from '@/components/UI/Modal.vue';
import Button from '@/components/UI/Button.vue';
import { useUserStore } from '@/stores/userStore';
import { IORequest } from '@/utils/IORequest';
import Events from '@/utils/Events';
const userStore = useUserStore();
const targetUser = ref(null);
const menuRef = ref(null);
function setTargetUser(user) {
targetUser.value = user;
if (menuRef.value && targetUser.value) {
menuRef.value.show();
}
}
const props = defineProps({
server: {
@@ -47,27 +56,68 @@ const props = defineProps({
}
});
const userStore = useUserStore();
const targetUser = ref(null);
const banTarget = ref(null);
const menuRef = ref(null);
const banModal = ref(null);
const labels = computed(() => userStore.userInfo?.labels || []);
const myId = computed(() => userStore.userInfo?.identity?.id);
const isAdmin = computed(() => labels.value.includes('ADMIN'));
const isOwner = computed(() => props.server?.owner == myId.value);
const isMod = computed(() => labels.value.includes('MOD_' + props.server?.id));
const isStaff = computed(() => isAdmin.value || isMod.value || isOwner.value);
const canSwitchMod = computed(() => isAdmin.value || isOwner.value);
// Mêmes règles qu'avant : jamais sur le propriétaire ni un administrateur ; un modérateur n'agit pas sur un autre
function canActOn(member) {
if (member.id == props.server.owner || member.isAdmin) return false;
return isAdmin.value || isOwner.value || (isMod.value && !member.isMod);
}
function setTargetUser(user, event) {
targetUser.value = user;
if (menuRef.value && targetUser.value) {
menuRef.value.show(event);
}
}
function toogleMod() {
if (!targetUser.value) return;
IORequest("/OWNER/USERS/SWITCH_MOD", () => {
console.log("Mod switched for user", targetUser.value.id);
}, targetUser.value.id)
IORequest("/OWNER/USERS/SWITCH_MOD", null, targetUser.value.id);
}
function askBan() {
if (!targetUser.value) return;
banTarget.value = targetUser.value;
banModal.value?.open();
}
function banUser() {
// : /MOD/USERS/BAN
if (!targetUser.value) return;
IORequest("/MOD/USERS/BAN", () => {
console.log("User banned:", targetUser.value.id);
}, targetUser.value.id);
if (!banTarget.value) return;
IORequest("/MOD/USERS/BAN", null, banTarget.value.id);
banModal.value?.close();
}
</script>
<style scoped>
.container {
.member {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 6px;
border-radius: var(--radius-sm);
}
.member:hover,
.member:focus-within {
background-color: var(--surface-hover);
}
.ban-text {
margin: 0;
line-height: 1.5;
}
</style>
+14 -2
View File
@@ -3,9 +3,12 @@
<ModalTree title="Utilisateurs" icon="fa-solid fa-users">
<GuildUsers :server="server"/>
</ModalTree>
<ModalTree v-if="userStore.userInfo.labels.includes('ADMIN') || userStore.userInfo.identity.id == server.owner" title="Sécurité" icon="fa-solid fa-shield-halved">
<ModalTree v-if="canManage" title="Sécurité" icon="fa-solid fa-shield-halved">
<GuildSecurity :server="server"/>
</ModalTree>
<ModalTree v-if="canManage" title="Playlists partagées" icon="fa-solid fa-list-ul">
<GuildPlaylists :server="server"/>
</ModalTree>
<ModalTree title="Statistiques" icon="fa-solid fa-chart-simple">
<GuildStats :server="server"/>
</ModalTree>
@@ -14,10 +17,11 @@
<script setup>
import Modal from '@/components/UI/Modal.vue';
import ModalTree from '@/components/UI/ModalTree.vue';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import GuildUsers from './Settings/GuildUsers.vue';
import GuildStats from './Settings/GuildStats.vue';
import GuildSecurity from './Settings/GuildSecurity.vue';
import GuildPlaylists from './Settings/GuildPlaylists.vue';
import Events from '@/utils/Events';
import { useUserStore } from '@/stores/userStore';
const userStore = useUserStore();
@@ -34,6 +38,14 @@ const props = defineProps({
}
});
// Propriétaire du serveur ou administrateur du bot (même règle que le backend : isGuildOwnerOrAdmin)
const canManage = computed(() => {
const info = userStore.userInfo;
if (!info || !props.server) return false;
const labels = Array.isArray(info.labels) ? info.labels : [];
return labels.includes('ADMIN') || (!!info.identity?.id && info.identity.id == props.server.owner);
});
defineExpose({
open() {
if (modal.value) {
@@ -0,0 +1,190 @@
<template>
<div class="guild-pl">
<p class="guild-pl__intro">
Une liste de playlists commune à tout le serveur, affichée à côté des playlists personnelles de chaque membre.
</p>
<Switch
:model-value="settings.enabled"
label="Activer les playlists partagées"
:description="settings.enabled ? 'Les membres voient l\'onglet « Serveur » dans leurs playlists.' : 'Désactivées : les playlists existantes sont conservées et réapparaîtront à la réactivation.'"
:disabled="saving !== null || !loaded"
@update:model-value="update({ enabled: $event })"
/>
<div :class="['guild-pl__editors', { 'guild-pl__editors--off': !settings.enabled }]">
<p :id="editorsLabelId" class="guild-pl__label">Qui peut les modifier ?</p>
<SegmentedControl
:model-value="settings.editors"
:options="EDITOR_OPTIONS"
:aria-labelledby="editorsLabelId"
:aria-describedby="editorsHintId"
@update:model-value="update({ editors: $event })"
/>
<p :id="editorsHintId" class="guild-pl__hint">
{{ settings.editors === 'mods'
? 'Les membres peuvent écouter et copier les playlists ; seuls les modérateurs et le propriétaire créent, renomment et modifient.'
: 'Tous les membres peuvent créer des playlists et ajouter, retirer ou réordonner des titres.' }}
Seuls leur créateur et les modérateurs peuvent supprimer une playlist.
</p>
</div>
<p v-if="settings.enabled" class="guild-pl__count">
<Icon icon="fa-solid fa-list-ul" aria-hidden="true" />
{{ countLabel }}
</p>
<p class="guild-pl__status" aria-live="polite">
<template v-if="saving !== null"><Spinner size="sm" label="Enregistrement…" visible-label /></template>
<template v-else-if="feedback">
<Icon :class="feedback.ok ? 'guild-pl__ok' : 'guild-pl__ko'" :icon="feedback.ok ? 'fa-solid fa-circle-check' : 'fa-solid fa-circle-exclamation'" aria-hidden="true" />
{{ feedback.text }}
</template>
</p>
</div>
</template>
<script setup>
/**
* Réglage « Playlists partagées » du serveur actif (propriétaire du serveur ou administrateur du bot,
* même règle que le backend : /OWNER/PLAYLISTS/SETTINGS).
*/
import { computed, onBeforeUnmount, ref, useId } from 'vue';
import Switch from '@/components/UI/Switch.vue';
import SegmentedControl from '@/components/UI/SegmentedControl.vue';
import Spinner from '@/components/UI/Spinner.vue';
import { usePlaylistStore } from '@/stores/playlistStore';
import { onModalOpen } from '@/utils/ModalContext';
const props = defineProps({
server: {
type: Object,
required: true
}
});
const EDITOR_OPTIONS = [
{ value: 'members', label: 'Tous les membres', icon: 'fa-solid fa-users' },
{ value: 'mods', label: 'Modérateurs seulement', icon: 'fa-solid fa-user-shield' }
];
const playlistStore = usePlaylistStore();
const uid = useId();
const editorsLabelId = `guild-pl-editors-${uid}`;
const editorsHintId = `guild-pl-editors-hint-${uid}`;
const saving = ref(null);
const feedback = ref(null);
let feedbackTimer = null;
// Réglage connu par le store (chargé avec la liste), sinon celui reçu avec /GUILD/INFO
const loaded = computed(() => playlistStore.guildLoaded || !!props.server?.sharedPlaylists);
const settings = computed(() => {
if (playlistStore.guildLoaded && playlistStore.guildId === props.server?.id) return playlistStore.guildSettings;
const info = props.server?.sharedPlaylists;
return { enabled: !!info?.enabled, editors: info?.editors === 'mods' ? 'mods' : 'members' };
});
const countLabel = computed(() => {
const n = playlistStore.counts.guild;
if (n === 0) return 'Aucune playlist partagée pour le moment.';
return `${n} playlist${n > 1 ? 's' : ''} partagée${n > 1 ? 's' : ''}`;
});
onModalOpen(() => {
feedback.value = null;
if (!playlistStore.guildLoading) playlistStore.fetchGuild();
});
function showFeedback(ok, text) {
feedback.value = { ok, text };
clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
feedback.value = null;
}, ok ? 4000 : 8000);
}
async function update(patch) {
if (saving.value !== null) return;
saving.value = Object.keys(patch)[0];
feedback.value = null;
const result = await playlistStore.updateGuildSettings(patch);
saving.value = null;
if (!result.ok) {
showFeedback(false, result.message);
return;
}
if (patch.enabled !== undefined) {
showFeedback(true, patch.enabled ? 'Playlists partagées activées.' : 'Playlists partagées désactivées (rien n\'a été supprimé).');
} else {
showFeedback(true, patch.editors === 'mods' ? 'Seuls les modérateurs peuvent désormais les modifier.' : 'Tous les membres peuvent désormais les modifier.');
}
}
onBeforeUnmount(() => clearTimeout(feedbackTimer));
</script>
<style scoped>
.guild-pl {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 8px;
}
.guild-pl__intro {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: var(--text-secondary);
}
.guild-pl__editors {
display: flex;
flex-direction: column;
gap: 8px;
transition: opacity var(--dur-fast) ease;
}
/* Réglable même désactivé (préparé avant l'activation), mais visuellement secondaire */
.guild-pl__editors--off {
opacity: 0.6;
}
.guild-pl__label {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
}
.guild-pl__hint,
.guild-pl__count {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 0;
font-size: 0.8rem;
line-height: 1.45;
color: var(--text-secondary);
}
.guild-pl__count svg {
margin-top: 0.2em;
}
.guild-pl__status {
display: flex;
align-items: center;
gap: 6px;
min-height: 1.4em;
margin: 0;
font-size: 0.85rem;
}
.guild-pl__ok {
color: var(--text-success);
}
.guild-pl__ko {
color: var(--text-error);
}
</style>
@@ -1,7 +1,7 @@
<template>
<div class="setting">
<p class="text-secondary"><Icon icon="fa-solid fa-user-shield" /> Autoriser les utilisateurs avec un rôle spécifique</p>
<Selector v-model="roleSelected" ref="roleSelector" v-if="roles.length > 0 && modalOpen">
<Selector v-model="roleSelected" ref="roleSelector" label="Rôle autorisé" v-if="roles.length > 0 && modalOpen">
<Tag v-for="role in roles" :key="role.id" :value="role.id" :color="decimalToHex(role.color)">{{role.name.replace("@everyone", "Tout le monde (@everyone)")}}</Tag>
</Selector>
<Button @click="updateRole" icon="fa-solid fa-rotate">Mettre à jour</Button>
@@ -13,7 +13,7 @@ import { onMounted, ref, watch } from 'vue';
import Selector from '@/components/UI/Selector.vue';
import { IORequest } from '@/utils/IORequest';
import Tag from '@/components/UI/Tag.vue';
import Events from '@/utils/Events';
import { onModalClose, onModalOpen, useModalContext } from '@/utils/ModalContext';
import Button from '@/components/UI/Button.vue';
import Success from '@/components/UI/Success.vue';
@@ -21,7 +21,9 @@ const roles = ref([])
const roleSelector = ref(null);
const roleUpdated = ref(false);
const roleSelected = ref(null);
const modalOpen = ref(false);
// État de la modale « Gestion » qui contient ce réglage (et non plus de n'importe quelle modale)
const modalContext = useModalContext();
const modalOpen = ref(modalContext ? modalContext.isOpen.value : true);
const props = defineProps({
server: {
@@ -34,13 +36,13 @@ onMounted(() => {
actualizeRoles();
})
Events.on("modal:open", () => {
onModalOpen(() => {
actualizeRoles();
modalOpen.value = true;
roleUpdated.value = false;
})
Events.on("modal:close", () => {
onModalClose(() => {
modalOpen.value = false;
roleSelected.value = null;
roleUpdated.value = false;
@@ -0,0 +1,376 @@
<template>
<div class="action-menu">
<button
ref="trigger"
type="button"
:class="['action-menu__trigger', text ? 'action-menu__trigger--text' : 'action-menu__trigger--icon', { 'action-menu__trigger--open': isOpen }]"
aria-haspopup="menu"
:aria-expanded="isOpen ? 'true' : 'false'"
:aria-controls="isOpen ? menuId : undefined"
:aria-label="text ? undefined : label"
:title="text ? undefined : label"
:disabled="disabled"
@click="toggle()"
@keydown="onTriggerKeydown"
>
<Icon :icon="icon" aria-hidden="true" />
<span v-if="text">{{ text }}</span>
<Icon v-if="text" class="action-menu__chevron" icon="fa-solid fa-chevron-down" aria-hidden="true" />
</button>
<!-- Hors du flux : les conteneurs défilants coupent ce qui dépasse -->
<Teleport to="body">
<Transition name="action-pop">
<div
v-if="isOpen"
:id="menuId"
ref="menu"
class="action-menu__panel"
role="menu"
:aria-label="label"
@keydown="onMenuKeydown"
>
<template v-for="item in items" :key="item.key">
<div v-if="item.separatorBefore" class="action-menu__separator" role="separator"></div>
<button
type="button"
role="menuitem"
tabindex="-1"
:class="['action-menu__item', { 'action-menu__item--danger': item.danger }]"
:aria-disabled="item.disabled ? 'true' : undefined"
@click="choose(item)"
>
<Icon class="action-menu__icon" :icon="item.icon || 'fa-solid fa-circle'" aria-hidden="true" />
<span class="action-menu__label">
{{ item.label }}
<span v-if="item.hint" class="action-menu__hint">{{ item.hint }}</span>
</span>
</button>
</template>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup>
/**
* Bouton « Plus d'actions » + menu déroulant accessible (role="menu").
* items : [{ key, label, icon?, hint?, danger?, disabled?, separatorBefore? }] ; émet select(key).
* Clavier : Entrée / Espace / flèche bas ouvrent ; flèches, Début / Fin parcourent ; Échap ferme.
*/
import { nextTick, onBeforeUnmount, ref, useId } from 'vue';
const props = defineProps({
items: {
type: Array,
default: () => []
},
label: {
type: String,
default: "Plus d'actions"
},
icon: {
type: String,
default: 'fa-solid fa-ellipsis'
},
// Texte visible du bouton (sinon bouton icône seul)
text: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['select']);
const menuId = `action-menu-${useId()}`;
const trigger = ref(null);
const menu = ref(null);
const isOpen = ref(false);
const MARGIN = 8;
function enabledItems() {
if (!menu.value) return [];
return Array.from(menu.value.querySelectorAll('[role="menuitem"]'))
.filter((item) => item.getAttribute('aria-disabled') !== 'true');
}
function focusItem(index) {
const list = enabledItems();
if (list.length === 0) return;
list[(index + list.length) % list.length].focus();
}
// Position fixe sous le bouton (au-dessus s'il manque de place), gardée dans la fenêtre
function placePanel() {
if (!trigger.value || !menu.value) return;
const rect = trigger.value.getBoundingClientRect();
const width = menu.value.offsetWidth;
const height = menu.value.offsetHeight;
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = window.innerHeight;
const left = Math.min(Math.max(rect.right - width, MARGIN), Math.max(MARGIN, viewportWidth - width - MARGIN));
let top = rect.bottom + 6;
if (top + height > viewportHeight - MARGIN && rect.top - height - 6 >= MARGIN) top = rect.top - height - 6;
menu.value.style.top = `${Math.max(MARGIN, top)}px`;
menu.value.style.left = `${left}px`;
}
function onViewportChange(event) {
if (event?.type === 'scroll' && menu.value && menu.value.contains(event.target)) return;
close(false);
}
function onOutsidePointer(event) {
const inside = trigger.value?.contains(event.target) || menu.value?.contains(event.target);
if (!inside) close(false);
}
function addGlobalListeners() {
document.addEventListener('pointerdown', onOutsidePointer, true);
window.addEventListener('resize', onViewportChange);
window.addEventListener('scroll', onViewportChange, true);
}
function removeGlobalListeners() {
document.removeEventListener('pointerdown', onOutsidePointer, true);
window.removeEventListener('resize', onViewportChange);
window.removeEventListener('scroll', onViewportChange, true);
}
function open(focus = 'first') {
if (isOpen.value || props.disabled) return;
isOpen.value = true;
addGlobalListeners();
nextTick(() => {
placePanel();
focusItem(focus === 'last' ? -1 : 0);
});
}
function close(restoreFocus = true) {
if (!isOpen.value) return;
isOpen.value = false;
removeGlobalListeners();
if (restoreFocus) nextTick(() => trigger.value?.focus());
}
function toggle() {
if (isOpen.value) close();
else open();
}
function onTriggerKeydown(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
open();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
open('last');
}
}
function onMenuKeydown(event) {
const list = enabledItems();
const index = list.indexOf(document.activeElement);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
focusItem(index + 1);
break;
case 'ArrowUp':
event.preventDefault();
focusItem(index - 1);
break;
case 'Home':
event.preventDefault();
focusItem(0);
break;
case 'End':
event.preventDefault();
focusItem(list.length - 1);
break;
case 'Escape':
event.preventDefault();
// Ne ferme pas la modale qui contiendrait le menu
event.stopPropagation();
close();
break;
case 'Tab':
close(false);
break;
default:
break;
}
}
function choose(item) {
if (item.disabled) return;
// Focus rendu au bouton d'abord : une modale ouverte par l'action le restituera ensuite au bon endroit
close();
nextTick(() => emit('select', item.key));
}
onBeforeUnmount(removeGlobalListeners);
defineExpose({ open, close });
</script>
<style scoped>
.action-menu {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.action-menu__trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid transparent;
background-color: transparent;
color: var(--text-secondary);
font: inherit;
cursor: pointer;
outline: 2px solid transparent;
transition: background-color var(--dur-fast) ease, color var(--dur-fast) ease, box-shadow var(--dur-fast) ease;
}
.action-menu__trigger--icon {
width: 36px;
height: 36px;
border-radius: 50%;
}
.action-menu__trigger--text {
min-height: 38px;
padding: 0 14px;
border-color: var(--border);
border-radius: var(--radius-pill);
color: var(--text);
font-size: 0.875rem;
font-weight: 600;
}
.action-menu__trigger:hover:not(:disabled),
.action-menu__trigger--open {
background-color: color-mix(in srgb, var(--text) 9%, transparent);
color: var(--text);
}
.action-menu__trigger:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.action-menu__trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-menu__chevron {
font-size: 0.75em;
transition: transform var(--dur-fast) ease;
}
.action-menu__trigger--open .action-menu__chevron {
transform: rotate(180deg);
}
.action-menu__panel {
position: fixed;
top: 0;
left: 0;
z-index: 3900;
display: flex;
flex-direction: column;
width: max-content;
min-width: 220px;
max-width: min(320px, calc(100vw - 30px));
padding: 6px;
box-sizing: border-box;
background-color: var(--primary);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.action-menu__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 36px;
padding: 6px 10px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font: inherit;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
outline: none;
}
.action-menu__item:hover,
.action-menu__item:focus {
background-color: color-mix(in srgb, var(--text) 9%, transparent);
}
.action-menu__item:focus-visible {
box-shadow: inset 0 0 0 2px var(--main);
}
.action-menu__item[aria-disabled='true'] {
color: var(--text-tertiary);
cursor: not-allowed;
background: transparent;
}
.action-menu__item--danger {
color: var(--text-error);
}
.action-menu__item--danger .action-menu__icon {
color: var(--text-error);
}
.action-menu__icon {
width: 1em;
flex-shrink: 0;
color: var(--text-secondary);
}
.action-menu__label {
display: flex;
flex-direction: column;
gap: 1px;
flex: 1;
min-width: 0;
}
.action-menu__hint {
font-size: 0.75rem;
color: var(--text-secondary);
}
.action-menu__separator {
height: 1px;
margin: 6px 4px;
background-color: var(--border);
}
.action-pop-enter-active,
.action-pop-leave-active {
transition: opacity var(--dur-fast) ease, transform var(--dur-fast) var(--ease);
}
.action-pop-enter-from,
.action-pop-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
@@ -0,0 +1,707 @@
<template>
<Modal ref="modal" :title="modalTitle" icon="fa-solid fa-square-plus" size="md">
<form :id="formId" class="create" novalidate @submit.prevent="submit()">
<!-- Type de playlist : deux grandes cartes (vrais boutons radio, masqués visuellement) -->
<fieldset class="create__fieldset" :disabled="busy">
<legend class="create__legend">Que voulez-vous faire ?</legend>
<div class="create__modes">
<label
v-for="option in MODES"
:key="option.value"
:class="['create__mode', { 'create__mode--selected': mode === option.value }]"
>
<input
v-model="mode"
class="sr-only"
type="radio"
:name="`${formId}-mode`"
:value="option.value"
:aria-describedby="`${formId}-mode-${option.value}`"
/>
<span class="create__mode-icon" aria-hidden="true"><Icon :icon="option.icon" /></span>
<span class="create__mode-text">
<span class="create__mode-title">{{ option.label }}</span>
<span :id="`${formId}-mode-${option.value}`" class="create__mode-desc">{{ option.description }}</span>
</span>
<Icon v-if="mode === option.value" class="create__mode-check" icon="fa-solid fa-circle-check" aria-hidden="true" />
</label>
</div>
</fieldset>
<!-- Import : lien avec détection en direct de la plateforme -->
<div v-if="mode === 'import'" class="create__group">
<Field label="Lien de la playlist" :for="urlId" required>
<div :class="['create__url', `create__url--${detection?.platform || 'none'}`, { 'create__url--invalid': showUrlProblem }]">
<span class="create__url-icon" aria-hidden="true">
<Icon :icon="detection?.icon || 'fa-solid fa-link'" />
</span>
<input
:id="urlId"
ref="urlInput"
v-model="url"
class="create__url-input"
type="url"
inputmode="url"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
maxlength="2000"
placeholder="https://www.youtube.com/playlist?list=…"
:disabled="busy"
:aria-invalid="showUrlProblem ? 'true' : 'false'"
:aria-describedby="`${urlId}-status ${urlId}-sources`"
data-autofocus
@blur="urlTouched = true"
@paste="urlTouched = true"
@keydown.enter.prevent="submit()"
/>
<button
v-if="canPaste && !url"
type="button"
class="create__paste"
:disabled="busy"
@click="pasteFromClipboard()"
>
<Icon icon="fa-solid fa-paste" aria-hidden="true" />
Coller
</button>
<button
v-else-if="url"
type="button"
class="create__paste create__paste--icon"
:disabled="busy"
aria-label="Effacer le lien"
title="Effacer le lien"
@click="clearUrl()"
>
<Icon icon="fa-solid fa-xmark" aria-hidden="true" />
</button>
</div>
</Field>
<p
:id="`${urlId}-status`"
:class="['create__status', `create__status--${urlStatus.tone}`]"
aria-live="polite"
>
<Icon v-if="urlStatus.icon" :icon="urlStatus.icon" aria-hidden="true" />
<span>{{ urlStatus.text }}</span>
</p>
<ul :id="`${urlId}-sources`" class="create__sources" aria-label="Sources acceptées">
<li v-for="source in SOURCES" :key="source.key" :class="['create__source', `create__source--${source.key}`, { 'create__source--active': isActiveSource(source.key) }]">
<Icon :icon="source.icon" aria-hidden="true" />
<span>{{ source.label }}</span>
</li>
</ul>
</div>
<!-- Nom : obligatoire pour une playlist vide, facultatif pour un import -->
<Field
:label="mode === 'import' ? 'Nom personnalisé (facultatif)' : 'Nom de la playlist'"
:for="nameId"
:required="mode === 'empty'"
:hint="mode === 'import' ? 'Laissez vide pour garder le titre d\'origine.' : null"
:error="nameError"
>
<template #aside>
<span :class="['create__counter', { 'create__counter--full': name.length >= MAX_NAME }]" aria-hidden="true">{{ name.length }}/{{ MAX_NAME }}</span>
</template>
<template #default="{ id, describedby, invalid }">
<input
:id="id"
ref="nameInput"
v-model="name"
class="create__input"
type="text"
autocomplete="off"
:maxlength="MAX_NAME"
:placeholder="mode === 'import' ? 'Titre de la playlist d\'origine' : 'Ex. : Soirée du vendredi'"
:disabled="busy"
:aria-describedby="describedby"
:aria-invalid="invalid ? 'true' : 'false'"
:data-autofocus="mode === 'empty' ? '' : null"
@keydown.enter.prevent="submit()"
/>
</template>
</Field>
<!-- Destination : seulement si l'option du serveur est active et que l'on peut y écrire -->
<div v-if="canChooseScope" class="create__group">
<p :id="`${formId}-scope`" class="create__label">Enregistrer dans</p>
<SegmentedControl
v-model="scope"
block
:options="scopeOptions"
:aria-labelledby="`${formId}-scope`"
:aria-describedby="`${formId}-scope-hint`"
/>
<p :id="`${formId}-scope-hint`" class="create__hint">
<Icon :icon="scope === 'guild' ? 'fa-solid fa-users' : 'fa-solid fa-lock'" aria-hidden="true" />
{{ scopeHint }}
</p>
</div>
<div v-if="busy" class="create__progress">
<Spinner :label="mode === 'import' ? 'Import en cours…' : 'Création en cours…'" visible-label />
<p v-if="mode === 'import'" class="create__hint">
Les titres sont récupérés un par un : comptez jusqu'à une minute pour une grande playlist Spotify.
Vous pouvez fermer cette fenêtre, une notification vous préviendra.
</p>
</div>
<p v-if="submitError" class="create__error" role="alert">
<Icon icon="fa-solid fa-circle-exclamation" aria-hidden="true" />
<span>{{ submitError }}</span>
</p>
</form>
<template #footer>
<Button variant="ghost" @click="modal?.close()">{{ busy ? 'Fermer' : 'Annuler' }}</Button>
<Button
type="submit"
:form="formId"
:icon="mode === 'import' ? 'fa-solid fa-file-import' : 'fa-solid fa-plus'"
:loading="busy"
>{{ submitLabel }}</Button>
</template>
</Modal>
</template>
<script setup>
/**
* Création d'une playlist : vide (nom) ou importée depuis un lien YouTube / YouTube Music / Spotify / SoundCloud.
* Ouverte par playlistStore.requestCreate(scope) (événement du bus PLAYLIST_CREATE_OPEN {scope?}).
* Une seule instance (Widget/Playlist/PlaylistHost.vue). La playlist créée est ouverte dans la vue.
*/
import { computed, nextTick, ref, useId, watch } from 'vue';
import Modal from '@/components/UI/Modal.vue';
import Button from '@/components/UI/Button.vue';
import Field from '@/components/UI/Field.vue';
import SegmentedControl from '@/components/UI/SegmentedControl.vue';
import Spinner from '@/components/UI/Spinner.vue';
import { useGlobalStore } from '@/stores/globalStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { detectPlaylistLink, formatSongCount } from './playlistMeta';
const MAX_NAME = 100;
const MODES = [
{ value: 'empty', label: 'Playlist vide', icon: 'fa-solid fa-pen-to-square', description: 'Donnez-lui un nom, puis ajoutez des titres depuis vos recherches.' },
{ value: 'import', label: 'Importer depuis un lien', icon: 'fa-solid fa-link', description: 'Copiez une playlist publique existante.' }
];
const SOURCES = [
{ key: 'youtube', label: 'YouTube', icon: 'fa-brands fa-youtube' },
{ key: 'youtube-music', label: 'YouTube Music', icon: 'fa-solid fa-circle-play' },
{ key: 'spotify', label: 'Spotify', icon: 'fa-brands fa-spotify' },
{ key: 'soundcloud', label: 'SoundCloud', icon: 'fa-brands fa-soundcloud' }
];
const playlistStore = usePlaylistStore();
const globalStore = useGlobalStore();
const uid = useId();
const formId = `playlist-create-${uid}`;
const urlId = `${formId}-url`;
const nameId = `${formId}-name`;
const modal = ref(null);
const urlInput = ref(null);
const nameInput = ref(null);
const mode = ref('empty');
const url = ref('');
const name = ref('');
const scope = ref('personal');
const urlTouched = ref(false);
const attempted = ref(false);
const busy = ref(false);
const submitError = ref(null);
const canPaste = typeof navigator !== 'undefined' && !!navigator.clipboard?.readText;
const detection = computed(() => detectPlaylistLink(url.value));
const canChooseScope = computed(() => playlistStore.canEditScope('guild'));
const serverName = computed(() => globalStore.actualServer?.name || null);
const scopeOptions = computed(() => [
{ value: 'personal', label: 'Mes playlists', icon: 'fa-solid fa-user' },
{ value: 'guild', label: 'Playlists du serveur', icon: 'fa-solid fa-users' }
]);
const scopeHint = computed(() => {
if (scope.value !== 'guild') return 'Visible par vous seul.';
const where = serverName.value ? ` de ${serverName.value}` : ' du serveur';
return playlistStore.guildSettings.editors === 'mods'
? `Visible par tous les membres${where} ; seuls les modérateurs pourront la modifier.`
: `Visible et modifiable par tous les membres${where}.`;
});
const modalTitle = computed(() => (scope.value === 'guild' && canChooseScope.value ? 'Nouvelle playlist partagée' : 'Nouvelle playlist'));
const submitLabel = computed(() => {
if (busy.value) return mode.value === 'import' ? 'Import…' : 'Création…';
return mode.value === 'import' ? 'Importer' : 'Créer la playlist';
});
// Problème de lien affiché seulement quand l'utilisateur a fini de saisir (sortie du champ, collage, envoi)
const showUrlProblem = computed(() => mode.value === 'import' && (urlTouched.value || attempted.value) && !detection.value?.valid);
const urlStatus = computed(() => {
const found = detection.value;
if (!found) {
return attempted.value
? { tone: 'error', icon: 'fa-solid fa-circle-exclamation', text: "Collez le lien d'une playlist." }
: { tone: 'muted', icon: null, text: 'Collez le lien de la playlist à copier (playlist publique ou non répertoriée).' };
}
if (found.valid) {
return { tone: 'success', icon: 'fa-solid fa-circle-check', text: found.message };
}
if (!showUrlProblem.value) {
return { tone: 'muted', icon: null, text: found.label ? `Lien ${found.label}…` : 'Analyse du lien…' };
}
return { tone: 'error', icon: 'fa-solid fa-circle-exclamation', text: found.message };
});
const nameError = computed(() => {
if (!attempted.value || mode.value !== 'empty') return null;
return name.value.trim() === '' ? 'Donnez un nom à la playlist.' : null;
});
function isActiveSource(key) {
return detection.value?.valid === true && detection.value.platform === key;
}
function reset(requestedScope) {
mode.value = 'empty';
url.value = '';
name.value = '';
urlTouched.value = false;
attempted.value = false;
submitError.value = null;
scope.value = requestedScope === 'guild' && canChooseScope.value ? 'guild' : 'personal';
}
// Ouverture demandée par le store (barre latérale, accueil, page de recherche…)
watch(() => playlistStore.createRequest, (request) => {
if (!request) return;
// Un import en cours garde son état : on rouvre simplement la fenêtre
if (!busy.value) reset(request.scope);
modal.value?.open();
});
// Changement de mode : les erreurs de l'autre formulaire disparaissent (le focus reste sur le bouton radio)
watch(mode, () => {
submitError.value = null;
attempted.value = false;
});
// L'option du serveur a été désactivée pendant la saisie
watch(canChooseScope, (allowed) => {
if (!allowed && scope.value === 'guild') scope.value = 'personal';
});
async function pasteFromClipboard() {
try {
const text = await navigator.clipboard.readText();
if (typeof text === 'string' && text.trim() !== '') {
url.value = text.trim();
urlTouched.value = true;
}
} catch {
submitError.value = "Le presse-papiers n'est pas accessible : collez le lien avec Ctrl+V.";
}
nextTick(() => urlInput.value?.focus());
}
function clearUrl() {
url.value = '';
urlTouched.value = false;
nextTick(() => urlInput.value?.focus());
}
function focusFirstProblem() {
nextTick(() => {
if (mode.value === 'import' && !detection.value?.valid) urlInput.value?.focus();
else nameInput.value?.focus();
});
}
async function submit() {
if (busy.value) return;
attempted.value = true;
submitError.value = null;
const isImport = mode.value === 'import';
const trimmed = name.value.trim();
if ((isImport && !detection.value?.valid) || (!isImport && trimmed === '')) {
focusFirstProblem();
return;
}
const target = scope.value === 'guild' && canChooseScope.value ? 'guild' : 'personal';
busy.value = true;
const result = await playlistStore.createPlaylist({
scope: target,
name: trimmed,
url: isImport ? detection.value.normalized : null
});
busy.value = false;
if (!result.ok) {
submitError.value = result.message;
// Fenêtre fermée pendant l'import : l'erreur est signalée par une notification
if (!modal.value?.isOpen) playlistStore.notify(result.message, { tone: 'error' });
return;
}
const playlist = result.playlist;
const message = successMessage(playlist, isImport, target);
const show = () => {
playlistStore.setScope(target);
playlistStore.openPlaylist(target, playlist.playlistId);
};
if (modal.value?.isOpen) {
modal.value.close();
show();
playlistStore.notify(message, { tone: 'success' });
} else {
playlistStore.notify(message, { tone: 'success', action: { label: 'Ouvrir', run: show } });
}
reset(target);
}
function successMessage(playlist, isImport, target) {
const title = playlist?.title || 'Playlist';
const where = target === 'guild' ? ' sur le serveur' : '';
if (!isImport) return `Playlist « ${title} » créée${where}.`;
const count = Array.isArray(playlist?.songs) ? playlist.songs.length : 0;
const cut = playlist?.truncated ? ' (limitée aux 5000 premiers titres)' : '';
return `« ${title} » importée${where} : ${formatSongCount(count)}${cut}.`;
}
</script>
<style scoped>
.create {
display: flex;
flex-direction: column;
gap: 18px;
}
.create__fieldset {
margin: 0;
padding: 0;
border: none;
min-width: 0;
}
.create__legend,
.create__label {
margin: 0 0 8px 0;
padding: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
}
.create__modes {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.create__mode {
position: relative;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background-color: var(--primary);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: border-color var(--dur-fast) ease, background-color var(--dur-fast) ease, box-shadow var(--dur-fast) ease;
}
.create__mode:hover {
border-color: color-mix(in srgb, var(--main) 50%, transparent);
}
.create__mode--selected {
border-color: var(--main);
background-color: color-mix(in srgb, var(--main) 9%, var(--primary));
}
/* Le vrai bouton radio est masqué : l'anneau de focus clavier va sur la carte */
.create__mode:has(input:focus-visible) {
box-shadow: var(--focus-ring);
}
.create__fieldset:disabled .create__mode {
cursor: not-allowed;
opacity: 0.6;
}
.create__mode-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
background-color: var(--tertiary);
color: var(--text-secondary);
transition: background-color var(--dur-fast) ease, color var(--dur-fast) ease;
}
.create__mode--selected .create__mode-icon {
background-color: var(--main);
color: var(--main-contrast);
}
.create__mode-text {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding-right: 18px;
}
.create__mode-title {
font-weight: 600;
line-height: 1.3;
}
.create__mode-desc {
font-size: 0.8rem;
line-height: 1.4;
color: var(--text-secondary);
}
.create__mode-check {
position: absolute;
top: 10px;
right: 10px;
color: var(--main);
}
.create__group {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Champ de lien : icône de la plateforme détectée + bouton Coller / Effacer */
.create__url {
--platform-color: var(--text-secondary);
display: flex;
align-items: center;
gap: 4px;
padding: 0 4px 0 0;
border-radius: var(--radius-sm);
background-color: var(--tertiary);
transition: box-shadow var(--dur-fast) ease;
}
.create__url:focus-within {
box-shadow: var(--focus-ring);
}
.create__url--invalid {
box-shadow: 0 0 0 1px var(--text-error);
}
.create__url--youtube { --platform-color: #ff3b3b; }
.create__url--youtube-music { --platform-color: #ff3b3b; }
.create__url--spotify { --platform-color: #1ed760; }
.create__url--soundcloud { --platform-color: #ff7700; }
.create__url-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 38px;
color: var(--platform-color);
font-size: 1.1em;
transition: color var(--dur-fast) ease;
}
.create__url-input {
flex: 1;
min-width: 0;
padding-left: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.create__paste {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
min-height: 30px;
padding: 0 10px;
border: none;
border-radius: var(--radius-pill);
background-color: color-mix(in srgb, var(--text) 10%, transparent);
color: var(--text);
font: inherit;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background-color var(--dur-fast) ease;
}
.create__paste--icon {
width: 30px;
padding: 0;
justify-content: center;
background-color: transparent;
color: var(--text-secondary);
}
.create__paste:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--text) 16%, transparent);
color: var(--text);
}
.create__paste:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--main);
}
.create__paste:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.create__status {
display: flex;
align-items: flex-start;
gap: 6px;
min-height: 1.4em;
margin: 0;
font-size: 0.8rem;
line-height: 1.4;
}
.create__status svg {
margin-top: 0.2em;
flex-shrink: 0;
}
.create__status--muted { color: var(--text-secondary); }
.create__status--success { color: var(--text-success); }
.create__status--error { color: var(--text-error); }
.create__sources {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.create__source {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
font-size: 0.75rem;
color: var(--text-secondary);
transition: border-color var(--dur-fast) ease, color var(--dur-fast) ease, background-color var(--dur-fast) ease;
}
.create__source--youtube svg,
.create__source--youtube-music svg { color: #ff3b3b; }
.create__source--spotify svg { color: #1ed760; }
.create__source--soundcloud svg { color: #ff7700; }
.create__source--active {
border-color: currentColor;
color: var(--text);
background-color: color-mix(in srgb, var(--text) 7%, transparent);
font-weight: 600;
}
.create__input {
width: 100%;
box-sizing: border-box;
}
.create__counter {
font-size: 0.75rem;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
.create__counter--full {
color: var(--text-warning);
}
.create__hint {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 0;
font-size: 0.8rem;
line-height: 1.45;
color: var(--text-secondary);
}
.create__hint svg {
margin-top: 0.2em;
flex-shrink: 0;
}
.create__progress {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border-radius: var(--radius);
background-color: var(--primary);
border: 1px solid var(--border);
}
.create__error {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0;
padding: 10px 12px;
border-radius: var(--radius);
background-color: color-mix(in srgb, var(--text-error) 12%, transparent);
color: var(--text);
font-size: 0.875rem;
line-height: 1.45;
}
.create__error svg {
margin-top: 0.2em;
flex-shrink: 0;
color: var(--text-error);
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.create {
gap: 14px;
}
.create__modes {
grid-template-columns: minmax(0, 1fr);
}
.create__mode {
padding: 12px;
}
}
</style>
@@ -0,0 +1,25 @@
<template>
<PlaylistCreateModal />
<SaveToPlaylistModal />
<PlaylistToasts />
</template>
<script setup>
/**
* Hôte unique des playlists (monté une fois dans App.vue) :
* - branche les poussées serveur et les événements du bus du store (réenregistrés après chaque navigation,
* routes.js vidant le bus et les écouteurs socket) ;
* - porte les modales globales (création / import, enregistrement d'un titre) et les notifications.
* La barre latérale peut être démontée (carrousel mobile) : rien de tout cela ne doit dépendre d'elle.
*/
import { onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';
import { usePlaylistStore } from '@/stores/playlistStore';
import PlaylistCreateModal from './PlaylistCreateModal.vue';
import SaveToPlaylistModal from './SaveToPlaylistModal.vue';
import PlaylistToasts from './PlaylistToasts.vue';
const playlistStore = usePlaylistStore();
const stop = playlistStore.listen(useRouter());
onBeforeUnmount(stop);
</script>
@@ -0,0 +1,451 @@
<template>
<section class="songs" :aria-labelledby="headingId">
<div class="songs__toolbar">
<h3 :id="headingId" class="songs__heading">
Titres
<span class="songs__count">{{ songs.length.toLocaleString('fr-FR') }}</span>
</h3>
<div v-if="songs.length > FILTER_THRESHOLD" class="songs__filter">
<Icon class="songs__filter-icon" icon="fa-solid fa-magnifying-glass" aria-hidden="true" />
<label :for="filterId" class="sr-only">Rechercher dans la playlist</label>
<input
:id="filterId"
v-model="filter"
type="search"
class="songs__filter-input"
placeholder="Rechercher dans la playlist…"
autocomplete="off"
@keydown.esc.stop="filter = ''"
/>
</div>
</div>
<p v-if="canReorder && songs.length > 1" :id="helpId" class="songs__help">
<Icon icon="fa-solid fa-grip-vertical" aria-hidden="true" />
Glissez la poignée pour réordonner, ou utilisez les flèches haut / bas quand elle a le focus.
</p>
<p v-else-if="filtering && editable" class="songs__help">
<Icon icon="fa-solid fa-circle-info" aria-hidden="true" />
Effacez la recherche pour réordonner les titres.
</p>
<EmptyState v-if="songs.length === 0" icon="fa-solid fa-music" title="Aucun titre pour l'instant" class="songs__empty">
<template v-if="editable">Recherchez un titre, puis utilisez « Enregistrer dans une playlist » pour l'ajouter ici.</template>
<template v-else>Cette playlist est vide.</template>
<template v-if="editable" #actions>
<Button size="sm" icon="fa-solid fa-magnifying-glass" @click="Events.emit('SEARCH_FOCUS')">Rechercher un titre</Button>
</template>
</EmptyState>
<EmptyState v-else-if="visible.length === 0" icon="fa-solid fa-magnifying-glass" title="Aucun résultat" compact>
Aucun titre ne correspond à « {{ filter.trim() }} ».
<template #actions>
<Button size="sm" variant="ghost" icon="fa-solid fa-xmark" @click="clearFilter()">Effacer la recherche</Button>
</template>
</EmptyState>
<draggable
v-else-if="canReorder"
:model-value="visible"
item-key="key"
tag="ol"
class="songs__list"
:aria-labelledby="headingId"
handle=".songs__handle"
:animation="160"
ghost-class="songs__row--ghost"
chosen-class="songs__row--chosen"
@change="onDragChange"
>
<template #item="{ element }">
<li class="songs__row songs__row--sortable">
<button
type="button"
class="songs__handle"
:data-song-handle="element.key"
:aria-label="`Déplacer « ${element.song.title} », position ${element.index + 1} sur ${songs.length}`"
:aria-describedby="helpId"
@keydown="onHandleKeydown($event, element)"
>
<Icon icon="fa-solid fa-grip-vertical" aria-hidden="true" />
</button>
<PlaylistSongRow
:song="element.song"
:index="element.index"
:removable="editable"
:can-play="canPlay"
:busy="busyKey === element.key"
@play="play(element, $event)"
@save="playlistStore.requestSave(element.song)"
@remove="remove(element)"
/>
</li>
</template>
</draggable>
<ol v-else class="songs__list" :aria-labelledby="headingId">
<li v-for="element in visible" :key="element.key" class="songs__row">
<PlaylistSongRow
:song="element.song"
:index="element.index"
:removable="editable"
:can-play="canPlay"
:busy="busyKey === element.key"
@play="play(element, $event)"
@save="playlistStore.requestSave(element.song)"
@remove="remove(element)"
/>
</li>
</ol>
<div v-if="hiddenCount > 0" class="songs__more">
<Button variant="secondary" size="sm" icon="fa-solid fa-angles-down" @click="limit += PAGE">
Afficher {{ Math.min(PAGE, hiddenCount) }} titres de plus
</Button>
<span class="songs__more-count">{{ hiddenCount.toLocaleString('fr-FR') }} restant{{ hiddenCount > 1 ? 's' : '' }}</span>
</div>
<p class="sr-only" aria-live="polite">{{ announcement }}</p>
</section>
</template>
<script setup>
/**
* Titres d'une playlist enregistrée : lecture / ajout à la file / enregistrement ailleurs,
* retrait et réordonnancement (glisser-déposer ou flèches sur la poignée) si l'on a le droit de la modifier.
* Affichage progressif (les playlists importées peuvent compter 5000 titres).
*/
import { computed, nextTick, ref, useId, watch } from 'vue';
import draggable from 'vuedraggable';
import Button from '@/components/UI/Button.vue';
import EmptyState from '@/components/UI/EmptyState.vue';
import PlaylistSongRow from './PlaylistSongRow.vue';
import Events from '@/utils/Events';
import { IORequestAsync } from '@/utils/IORequest';
import { useGlobalStore } from '@/stores/globalStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { isSyncedPlaylist, normalizeScope } from './playlistMeta';
const PAGE = 150;
const FILTER_THRESHOLD = 8;
const props = defineProps({
playlist: {
type: Object,
required: true
},
scope: {
type: String,
default: 'personal'
}
});
const globalStore = useGlobalStore();
const playlistStore = usePlaylistStore();
const uid = useId();
const headingId = `songs-heading-${uid}`;
const filterId = `songs-filter-${uid}`;
const helpId = `songs-help-${uid}`;
const filter = ref('');
const limit = ref(PAGE);
const announcement = ref('');
const busyKey = ref(null);
const where = computed(() => normalizeScope(props.scope));
const songs = computed(() => (Array.isArray(props.playlist?.songs) ? props.playlist.songs : []));
const canPlay = computed(() => !!globalStore.currentChannel);
// Une playlist synchronisée avec YouTube est remplacée à chaque synchronisation : modifications locales inutiles
const editable = computed(() => playlistStore.canEditScope(where.value) && !isSyncedPlaylist(props.playlist));
// Clé stable par occurrence (un même titre peut figurer plusieurs fois)
const entries = computed(() => {
const seen = new Map();
return songs.value.map((song, index) => {
const base = String(song?.id ?? song?.url ?? 'titre');
const occurrence = (seen.get(base) || 0) + 1;
seen.set(base, occurrence);
return { key: `${base}#${occurrence}`, song, index };
});
});
function fold(text) {
return String(text || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
}
const filtering = computed(() => filter.value.trim() !== '');
const matching = computed(() => {
const query = fold(filter.value.trim());
if (!query) return entries.value;
return entries.value.filter(({ song }) => fold(`${song?.title} ${song?.author}`).includes(query));
});
const visible = computed(() => matching.value.slice(0, limit.value));
const hiddenCount = computed(() => Math.max(0, matching.value.length - visible.value.length));
// Réordonnancement : droit d'écriture, pas de recherche (les positions affichées sont alors les vraies)
const canReorder = computed(() => editable.value && !filtering.value && songs.value.length > 1);
// Autre playlist ouverte dans la même vue : on repart du début
watch(() => props.playlist?.playlistId, () => {
filter.value = '';
limit.value = PAGE;
});
watch(filter, () => {
limit.value = PAGE;
});
function announce(message) {
announcement.value = '';
nextTick(() => {
announcement.value = message;
});
}
function clearFilter() {
filter.value = '';
nextTick(() => document.getElementById(filterId)?.focus());
}
async function move(from, to, title) {
const result = await playlistStore.moveSong(where.value, props.playlist.playlistId, from, to);
if (!result.ok) {
announce("Le nouvel ordre n'a pas pu être enregistré.");
playlistStore.notify(result.message, { tone: 'error' });
return false;
}
if (title) announce(`« ${title} » déplacé en position ${to + 1} sur ${songs.value.length}.`);
return true;
}
function onDragChange(event) {
const moved = event?.moved;
if (!moved || moved.oldIndex === moved.newIndex) return;
// Liste affichée = début de la playlist (aucune recherche) : indices identiques
move(moved.oldIndex, moved.newIndex, moved.element?.song?.title);
}
async function onHandleKeydown(event, element) {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
event.preventDefault();
const to = element.index + (event.key === 'ArrowUp' ? -1 : 1);
if (to < 0 || to >= songs.value.length) {
announce(to < 0 ? 'Déjà en première position.' : 'Déjà en dernière position.');
return;
}
// Déplacement au-delà de la partie affichée : on l'agrandit pour garder le focus visible
if (to >= limit.value) limit.value += PAGE;
const pending = move(element.index, to, element.song?.title);
await nextTick();
document.querySelector(`[data-song-handle="${CSS.escape(element.key)}"]`)?.focus();
await pending;
}
async function play(element, now) {
if (!canPlay.value || busyKey.value) return;
busyKey.value = element.key;
const response = await IORequestAsync('/SEARCH/PLAY', { song: JSON.parse(JSON.stringify(element.song)), now: !!now });
busyKey.value = null;
const title = element.song?.title || 'Titre';
if (response === true) {
playlistStore.notify(now ? `Lecture de « ${title} »` : `« ${title} » ajouté à la file`, { tone: 'success' });
} else {
playlistStore.notify("Lecture impossible : rejoignez d'abord un salon vocal de ce serveur.", { tone: 'error' });
}
}
async function remove(element) {
if (busyKey.value) return;
const playlistId = props.playlist.playlistId;
const target = where.value;
const song = element.song;
const position = element.index;
busyKey.value = element.key;
const result = await playlistStore.removeSong(target, playlistId, song?.id, position);
busyKey.value = null;
if (!result.ok) {
playlistStore.notify(result.message, { tone: 'error' });
return;
}
announce(`« ${song?.title} » retiré de la playlist.`);
playlistStore.notify(`« ${song?.title || 'Titre'} » retiré`, {
tone: 'info',
action: { label: 'Annuler', run: () => restore(target, playlistId, song, position) }
});
}
// Annulation d'un retrait : ajout en fin de playlist puis retour à la position d'origine
async function restore(target, playlistId, song, position) {
const added = await playlistStore.addSong(target, playlistId, song);
if (!added.ok) {
playlistStore.notify(added.message, { tone: 'error' });
return;
}
const last = (added.playlist?.songs?.length || 1) - 1;
const to = Math.min(position, last);
if (to !== last) await playlistStore.moveSong(target, playlistId, last, to);
}
</script>
<style scoped>
.songs {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.songs__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.songs__heading {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 1.05em;
font-weight: 600;
}
.songs__count {
padding: 1px 8px;
border-radius: var(--radius-pill);
background-color: color-mix(in srgb, var(--text) 10%, transparent);
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.songs__filter {
position: relative;
display: flex;
align-items: center;
flex: 0 1 280px;
min-width: 0;
}
.songs__filter-icon {
position: absolute;
left: 10px;
font-size: 0.8em;
color: var(--text-secondary);
pointer-events: none;
}
.songs__filter-input {
width: 100%;
box-sizing: border-box;
padding-left: 30px !important;
font-size: 0.875rem;
}
.songs__help {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: 0.78rem;
color: var(--text-secondary);
}
.songs__empty {
padding: 30px 10px;
}
.songs__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.songs__row {
display: flex;
align-items: center;
min-width: 0;
border-radius: var(--radius);
}
.songs__row :deep(.song-row) {
flex: 1;
}
.songs__handle {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 44px;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-tertiary);
font-size: 0.85em;
cursor: grab;
opacity: 0.35;
touch-action: none;
outline: 2px solid transparent;
transition: opacity var(--dur-fast) ease, color var(--dur-fast) ease, background-color var(--dur-fast) ease;
}
.songs__row:hover .songs__handle,
.songs__row:focus-within .songs__handle {
opacity: 1;
}
.songs__handle:hover {
color: var(--text);
background-color: color-mix(in srgb, var(--text) 8%, transparent);
}
.songs__handle:focus-visible {
outline: none;
opacity: 1;
color: var(--text);
box-shadow: inset 0 0 0 2px var(--main);
}
.songs__handle:active {
cursor: grabbing;
}
@media (hover: none) {
.songs__handle {
opacity: 1;
}
}
.songs__row--ghost {
opacity: 0.45;
background-color: var(--main-soft);
}
.songs__row--chosen :deep(.song-row) {
background-color: color-mix(in srgb, var(--text) 9%, transparent);
}
.songs__more {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 6px 0 10px 0;
}
.songs__more-count {
font-size: 0.8rem;
color: var(--text-secondary);
}
</style>
@@ -0,0 +1,267 @@
<template>
<div :class="['song-row', { 'song-row--busy': busy }]">
<span class="song-row__index" aria-hidden="true">{{ index + 1 }}</span>
<span class="song-row__thumb-wrap">
<img
v-if="thumbnail && !thumbFailed"
class="song-row__thumb"
:src="thumbnail"
alt=""
loading="lazy"
decoding="async"
@error="thumbFailed = true"
/>
<span v-else class="song-row__thumb song-row__thumb--empty" aria-hidden="true">
<Icon :icon="typeIcon" />
</span>
</span>
<span class="song-row__text">
<span class="song-row__title u-ellipsis" :title="song.title">{{ song.title || 'Titre sans nom' }}</span>
<span class="song-row__author u-ellipsis">
<Icon v-if="song.type && song.type !== 'youtube'" class="song-row__type" :icon="typeIcon" aria-hidden="true" />
{{ song.author || 'Auteur inconnu' }}
</span>
</span>
<span v-if="duration" class="song-row__duration">
<span class="sr-only">Durée : </span>{{ duration }}
</span>
<span class="song-row__actions">
<Spinner v-if="busy" size="sm" label="En cours…" />
<IconAction
v-else
class="song-row__play"
icon="fa-solid fa-play"
:label="`Lire maintenant « ${song.title} »`"
:disabled="!canPlay"
@click="emit('play', true)"
/>
<ActionMenu :label="`Actions pour « ${song.title} »`" :items="menuItems" @select="onSelect" />
</span>
</div>
</template>
<script setup>
/**
* Ligne d'un titre dans une playlist enregistrée (PlaylistSongList).
* Émet play(now), save, remove ; l'ordre (poignée) est géré par la liste.
*/
import { computed, ref, watch } from 'vue';
import IconAction from '@/components/UI/IconAction.vue';
import Spinner from '@/components/UI/Spinner.vue';
import ActionMenu from './ActionMenu.vue';
const props = defineProps({
song: {
type: Object,
required: true
},
index: {
type: Number,
default: 0
},
removable: {
type: Boolean,
default: false
},
canPlay: {
type: Boolean,
default: false
},
busy: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['play', 'save', 'remove']);
const thumbFailed = ref(false);
// Image par défaut du backend : n'apporte rien
const thumbnail = computed(() => {
const value = props.song?.thumbnail;
return typeof value === 'string' && /^https?:\/\//i.test(value) && !/radomisol/i.test(value) ? value : null;
});
watch(thumbnail, () => {
thumbFailed.value = false;
});
const typeIcon = computed(() => {
switch (props.song?.type) {
case 'youtube': return 'fa-brands fa-youtube';
case 'spotify': return 'fa-brands fa-spotify';
case 'soundcloud': return 'fa-brands fa-soundcloud';
case 'attachment': return 'fa-solid fa-file-audio';
default: return 'fa-solid fa-music';
}
});
const duration = computed(() => {
const seconds = Number(props.song?.duration);
if (Number.isFinite(seconds) && seconds > 0) {
const total = Math.floor(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = String(total % 60).padStart(2, '0');
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
}
return typeof props.song?.readduration === 'string' && props.song.readduration !== '0:00' ? props.song.readduration : null;
});
const menuItems = computed(() => {
const items = [
{ key: 'queue', label: 'Ajouter à la file', icon: 'fa-solid fa-list-ul', disabled: !props.canPlay, hint: props.canPlay ? null : 'Rejoignez un salon vocal' },
{ key: 'now', label: 'Lire maintenant', icon: 'fa-solid fa-play', disabled: !props.canPlay }
];
if (props.song?.type !== 'attachment') {
items.push({ key: 'save', label: 'Enregistrer dans une playlist', icon: 'fa-regular fa-bookmark' });
}
if (props.song?.url && /^https?:\/\//i.test(props.song.url) && props.song.type !== 'attachment') {
items.push({ key: 'open', label: 'Ouvrir la source', icon: 'fa-solid fa-arrow-up-right-from-square' });
}
if (props.removable) {
items.push({ key: 'remove', label: 'Retirer de la playlist', icon: 'fa-solid fa-trash', danger: true, separatorBefore: true });
}
return items;
});
function onSelect(key) {
switch (key) {
case 'queue':
emit('play', false);
break;
case 'now':
emit('play', true);
break;
case 'save':
emit('save');
break;
case 'open':
window.open(props.song.url, '_blank', 'noopener');
break;
case 'remove':
emit('remove');
break;
default:
break;
}
}
</script>
<style scoped>
.song-row {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
padding: 6px 6px 6px 4px;
border-radius: var(--radius);
transition: background-color var(--dur-fast) ease;
}
.song-row:hover,
.song-row:focus-within {
background-color: color-mix(in srgb, var(--text) 6%, transparent);
}
.song-row--busy {
opacity: 0.7;
}
.song-row__index {
flex-shrink: 0;
width: 2.2em;
text-align: right;
font-size: 0.8rem;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
.song-row__thumb-wrap {
flex-shrink: 0;
display: inline-flex;
}
.song-row__thumb {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
aspect-ratio: 16 / 9;
border-radius: var(--radius-sm);
object-fit: cover;
background-color: var(--tertiary);
color: var(--text-secondary);
}
.song-row__text {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.song-row__title {
font-size: 0.92em;
font-weight: 600;
line-height: 1.3;
}
.song-row__author {
font-size: 0.8em;
line-height: 1.3;
color: var(--text-secondary);
}
.song-row__type {
margin-right: 3px;
}
.song-row__duration {
flex-shrink: 0;
font-size: 0.8rem;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.song-row__actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
flex-shrink: 0;
min-width: 72px;
}
/* Lecture rapide visible au survol / au focus (toujours sur écran tactile) */
.song-row__play {
opacity: 0;
transition: opacity var(--dur-fast) ease;
}
.song-row:hover .song-row__play,
.song-row:focus-within .song-row__play {
opacity: 1;
}
@media (hover: none) {
.song-row__play {
opacity: 1;
}
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.song-row {
gap: 10px;
}
.song-row__index,
.song-row__duration {
display: none;
}
.song-row__thumb {
width: 52px;
}
}
</style>
@@ -0,0 +1,437 @@
<template>
<div ref="root" class="sort-menu">
<button
ref="trigger"
type="button"
:class="['sort-menu__trigger', { 'sort-menu__trigger--open': isOpen }]"
aria-haspopup="menu"
:aria-expanded="isOpen ? 'true' : 'false'"
:aria-controls="isOpen ? menuId : undefined"
:aria-label="`Trier les playlists (actuellement : ${summary})`"
:title="`Trier : ${summary}`"
@click="toggle()"
@keydown="onTriggerKeydown"
>
<Icon :icon="triggerIcon" aria-hidden="true" />
<span class="sort-menu__trigger-label">{{ shortLabel }}</span>
<Icon class="sort-menu__chevron" icon="fa-solid fa-chevron-down" aria-hidden="true" />
</button>
<!-- Hors du flux (body) : la barre latérale coupe ce qui dépasse -->
<Teleport to="body">
<Transition name="sort-pop">
<div
v-if="isOpen"
:id="menuId"
ref="menu"
class="sort-menu__panel"
:style="panelStyle"
role="menu"
aria-label="Trier les playlists"
@keydown="onMenuKeydown"
>
<div role="group" :aria-labelledby="`${menuId}-modes`">
<p :id="`${menuId}-modes`" class="sort-menu__group">Trier par</p>
<button
v-for="mode in SORT_MODES"
:key="mode"
type="button"
role="menuitemradio"
tabindex="-1"
:aria-checked="modelValue.mode === mode ? 'true' : 'false'"
class="sort-menu__item"
@click="chooseMode(mode)"
>
<Icon class="sort-menu__icon" :icon="MODE_ICONS[mode]" aria-hidden="true" />
<span class="sort-menu__label">{{ SORT_LABELS[mode] }}</span>
<Icon v-if="modelValue.mode === mode" class="sort-menu__check" icon="fa-solid fa-check" aria-hidden="true" />
</button>
</div>
<div class="sort-menu__separator" role="separator"></div>
<div role="group" :aria-labelledby="`${menuId}-dir`">
<p :id="`${menuId}-dir`" class="sort-menu__group">Ordre</p>
<button
v-for="dir in ['asc', 'desc']"
:key="dir"
type="button"
role="menuitemradio"
tabindex="-1"
:aria-checked="!isManual && modelValue.dir === dir ? 'true' : 'false'"
:aria-disabled="isManual ? 'true' : undefined"
class="sort-menu__item"
@click="chooseDir(dir)"
>
<Icon class="sort-menu__icon" :icon="dir === 'asc' ? 'fa-solid fa-arrow-up-short-wide' : 'fa-solid fa-arrow-down-wide-short'" aria-hidden="true" />
<span class="sort-menu__label">{{ dirLabel(dir) }}</span>
<Icon v-if="!isManual && modelValue.dir === dir" class="sort-menu__check" icon="fa-solid fa-check" aria-hidden="true" />
</button>
<p v-if="isManual" class="sort-menu__hint">Glissez les playlists (ou utilisez les flèches sur la poignée) pour choisir leur ordre.</p>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup>
/**
* Menu de tri des playlists (bouton + menu déroulant accessible).
* v-model : { mode: 'manual'|'name'|'created'|'updated'|'songs'|'duration', dir: 'asc'|'desc' }.
* Clavier : Entrée / Espace / flèche bas ouvrent ; flèches, Début / Fin parcourent ; Échap ferme.
*/
import { computed, nextTick, onBeforeUnmount, ref, useId } from 'vue';
import { SORT_MODES, SORT_LABELS, SORT_DEFAULT_DIR } from '@/stores/playlistStore';
const props = defineProps({
modelValue: {
type: Object,
required: true
}
});
const emit = defineEmits(['update:modelValue']);
const MODE_ICONS = {
manual: 'fa-solid fa-grip-lines',
name: 'fa-solid fa-font',
created: 'fa-solid fa-calendar-plus',
updated: 'fa-solid fa-clock-rotate-left',
songs: 'fa-solid fa-list-ol',
duration: 'fa-solid fa-hourglass-half'
};
const SHORT_LABELS = {
manual: 'Perso',
name: 'Nom',
created: 'Création',
updated: 'Modifiée',
songs: 'Titres',
duration: 'Durée'
};
const menuId = `sort-menu-${useId()}`;
const root = ref(null);
const trigger = ref(null);
const menu = ref(null);
const isOpen = ref(false);
const isManual = computed(() => props.modelValue.mode === 'manual');
const shortLabel = computed(() => SHORT_LABELS[props.modelValue.mode] || 'Trier');
const triggerIcon = computed(() => {
if (isManual.value) return 'fa-solid fa-sort';
return props.modelValue.dir === 'desc' ? 'fa-solid fa-arrow-down-wide-short' : 'fa-solid fa-arrow-up-short-wide';
});
function dirLabel(dir, mode = props.modelValue.mode) {
switch (mode) {
case 'name':
return dir === 'asc' ? 'De A à Z' : 'De Z à A';
case 'created':
case 'updated':
return dir === 'asc' ? "Plus anciennes d'abord" : "Plus récentes d'abord";
case 'songs':
case 'duration':
return dir === 'asc' ? "Plus petites d'abord" : "Plus grandes d'abord";
default:
return dir === 'asc' ? 'Croissant' : 'Décroissant';
}
}
const summary = computed(() => {
const label = SORT_LABELS[props.modelValue.mode] || SORT_LABELS.manual;
return isManual.value ? label : `${label}, ${dirLabel(props.modelValue.dir).toLowerCase()}`;
});
function items() {
if (!menu.value) return [];
return Array.from(menu.value.querySelectorAll('[role="menuitemradio"]'))
.filter((item) => item.getAttribute('aria-disabled') !== 'true');
}
function focusItem(index) {
const list = items();
if (list.length === 0) return;
const target = list[(index + list.length) % list.length];
target.focus();
}
// Position fixe sous le bouton, aligné à droite, gardée dans la fenêtre (retourné vers le haut si besoin)
const panelStyle = ref({ top: '0px', left: '0px' });
const MARGIN = 8;
function placePanel() {
if (!trigger.value || !menu.value) return;
const rect = trigger.value.getBoundingClientRect();
const width = menu.value.offsetWidth;
const height = menu.value.offsetHeight;
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = window.innerHeight;
const left = Math.min(Math.max(rect.right - width, MARGIN), Math.max(MARGIN, viewportWidth - width - MARGIN));
let top = rect.bottom + 6;
if (top + height > viewportHeight - MARGIN && rect.top - height - 6 >= MARGIN) {
top = rect.top - height - 6;
}
// Appliqué aussi directement : le focus est posé juste après, avant le prochain rendu
panelStyle.value = { top: `${Math.max(MARGIN, top)}px`, left: `${left}px` };
Object.assign(menu.value.style, panelStyle.value);
}
// La page bouge (redimensionnement, défilement d'un conteneur) : le menu se ferme plutôt que de flotter
function onViewportChange(event) {
if (event?.type === 'scroll' && menu.value && menu.value.contains(event.target)) return;
close(false);
}
function open(focus = 'checked') {
if (isOpen.value) return;
isOpen.value = true;
document.addEventListener('pointerdown', onOutsidePointer, true);
window.addEventListener('resize', onViewportChange);
window.addEventListener('scroll', onViewportChange, true);
nextTick(() => {
placePanel();
const list = items();
if (focus === 'last') {
focusItem(list.length - 1);
return;
}
const checked = list.findIndex((item) => item.getAttribute('aria-checked') === 'true');
focusItem(checked === -1 ? 0 : checked);
});
}
function close(restoreFocus = true) {
if (!isOpen.value) return;
isOpen.value = false;
removeGlobalListeners();
if (restoreFocus) nextTick(() => trigger.value?.focus());
}
function removeGlobalListeners() {
document.removeEventListener('pointerdown', onOutsidePointer, true);
window.removeEventListener('resize', onViewportChange);
window.removeEventListener('scroll', onViewportChange, true);
}
function toggle() {
if (isOpen.value) close();
else open();
}
function onOutsidePointer(event) {
const inside = root.value?.contains(event.target) || menu.value?.contains(event.target);
if (!inside) close(false);
}
function onTriggerKeydown(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
open();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
open('last');
}
}
function onMenuKeydown(event) {
const list = items();
const index = list.indexOf(document.activeElement);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
focusItem(index + 1);
break;
case 'ArrowUp':
event.preventDefault();
focusItem(index - 1);
break;
case 'Home':
event.preventDefault();
focusItem(0);
break;
case 'End':
event.preventDefault();
focusItem(list.length - 1);
break;
case 'Escape':
event.preventDefault();
// Ne ferme pas une éventuelle modale parente
event.stopPropagation();
close();
break;
case 'Tab':
close(false);
break;
default:
break;
}
}
function chooseMode(mode) {
if (mode !== props.modelValue.mode) {
emit('update:modelValue', { mode, dir: SORT_DEFAULT_DIR[mode] });
}
close();
}
function chooseDir(dir) {
if (isManual.value) return;
if (dir !== props.modelValue.dir) {
emit('update:modelValue', { mode: props.modelValue.mode, dir });
}
close();
}
onBeforeUnmount(removeGlobalListeners);
</script>
<style scoped>
.sort-menu {
position: relative;
flex-shrink: 0;
}
.sort-menu__trigger {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
background-color: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
outline: 2px solid transparent;
outline-offset: 2px;
transition: background-color var(--dur-fast) ease, color var(--dur-fast) ease, box-shadow var(--dur-fast) ease;
}
.sort-menu__trigger:hover,
.sort-menu__trigger--open {
background-color: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--text);
}
.sort-menu__trigger:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.sort-menu__chevron {
font-size: 0.75em;
transition: transform var(--dur-fast) ease;
}
.sort-menu__trigger--open .sort-menu__chevron {
transform: rotate(180deg);
}
.sort-menu__panel {
position: fixed;
z-index: 3000;
display: flex;
flex-direction: column;
width: max-content;
min-width: 220px;
max-width: min(300px, calc(100vw - 30px));
padding: 6px;
box-sizing: border-box;
background-color: var(--primary);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.sort-menu__group {
margin: 4px 8px 4px 8px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
}
.sort-menu__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 34px;
padding: 0 10px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font: inherit;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
outline: none;
}
.sort-menu__item:hover,
.sort-menu__item:focus-visible,
.sort-menu__item:focus {
background-color: color-mix(in srgb, var(--text) 9%, transparent);
}
.sort-menu__item:focus-visible {
box-shadow: inset 0 0 0 2px var(--main);
}
.sort-menu__item[aria-checked='true'] {
color: var(--main);
font-weight: 600;
}
.sort-menu__item[aria-disabled='true'] {
color: var(--text-tertiary);
cursor: not-allowed;
background: transparent;
}
.sort-menu__icon {
width: 1em;
flex-shrink: 0;
color: var(--text-secondary);
}
.sort-menu__item[aria-checked='true'] .sort-menu__icon {
color: var(--main);
}
.sort-menu__label {
flex: 1;
}
.sort-menu__check {
flex-shrink: 0;
}
.sort-menu__separator {
height: 1px;
margin: 6px 4px;
background-color: var(--border);
}
.sort-menu__hint {
margin: 4px 8px 6px 8px;
max-width: 260px;
font-size: 0.75rem;
line-height: 1.4;
color: var(--text-secondary);
}
.sort-pop-enter-active,
.sort-pop-leave-active {
transition: opacity var(--dur-fast) ease, transform var(--dur-fast) var(--ease);
}
.sort-pop-enter-from,
.sort-pop-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
@@ -0,0 +1,203 @@
<template>
<Teleport to="body">
<!-- Région toujours présente : les lecteurs d'écran n'annoncent que ce qui change dans une région déjà connue -->
<section class="toasts" aria-label="Notifications des playlists">
<div class="sr-only" aria-live="polite" aria-atomic="true">{{ politeText }}</div>
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ alertText }}</div>
<TransitionGroup name="toast" tag="ul" class="toasts__list">
<li
v-for="toast in playlistStore.toasts"
:key="toast.id"
:class="['toast', `toast--${toast.tone || 'info'}`]"
>
<Icon class="toast__icon" :icon="ICONS[toast.tone] || ICONS.info" aria-hidden="true" />
<p class="toast__message">{{ toast.message }}</p>
<button
v-if="toast.action && typeof toast.action.run === 'function'"
type="button"
class="toast__action"
@click="runAction(toast)"
>{{ toast.action.label }}</button>
<IconAction
class="toast__close"
icon="fa-solid fa-xmark"
label="Fermer la notification"
@click="playlistStore.dismissToast(toast.id)"
/>
</li>
</TransitionGroup>
</section>
</Teleport>
</template>
<script setup>
/**
* Notifications courtes du store des playlists (playlistStore.notify).
* Annonce vocale séparée de l'affichage : « polite » pour les succès / infos, « assertive » pour les erreurs.
*/
import { nextTick, ref, watch } from 'vue';
import IconAction from '@/components/UI/IconAction.vue';
import { usePlaylistStore } from '@/stores/playlistStore';
const playlistStore = usePlaylistStore();
const ICONS = {
success: 'fa-solid fa-circle-check',
error: 'fa-solid fa-circle-exclamation',
info: 'fa-solid fa-circle-info'
};
const politeText = ref('');
const alertText = ref('');
let lastAnnounced = 0;
// Seules les nouvelles notifications sont lues (pas celles qui restent après une fermeture)
watch(() => playlistStore.toasts, (list) => {
let polite = null;
let alert = null;
for (const toast of list) {
if (toast.id <= lastAnnounced) continue;
lastAnnounced = toast.id;
if (toast.tone === 'error') alert = toast.message;
else polite = toast.message;
}
// Vidé puis rempli : un message identique au précédent est relu
if (polite !== null) {
politeText.value = '';
nextTick(() => { politeText.value = polite; });
}
if (alert !== null) {
alertText.value = '';
nextTick(() => { alertText.value = alert; });
}
});
function runAction(toast) {
playlistStore.dismissToast(toast.id);
try {
toast.action.run();
} catch (error) {
console.error('[PlaylistToasts] Action en échec', error);
}
}
</script>
<style scoped>
.toasts {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 4000;
pointer-events: none;
}
.toasts__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
width: max-content;
max-width: min(420px, calc(100vw - 30px));
box-sizing: border-box;
padding: 10px 8px 10px 14px;
border: 1px solid var(--border);
border-left: 4px solid var(--toast-color);
border-radius: var(--radius);
background-color: var(--primary);
color: var(--text);
box-shadow: var(--shadow-lg);
pointer-events: auto;
font-size: 0.9rem;
line-height: 1.4;
}
.toast--success { --toast-color: var(--text-success); }
.toast--error { --toast-color: var(--text-error); }
.toast--info { --toast-color: var(--text-info); }
.toast__icon {
flex-shrink: 0;
color: var(--toast-color);
}
.toast__message {
flex: 1;
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.toast__action {
flex-shrink: 0;
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
background-color: transparent;
color: var(--text);
font: inherit;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background-color var(--dur-fast) ease;
}
.toast__action:hover {
background-color: color-mix(in srgb, var(--text) 9%, transparent);
}
.toast__action:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.toast__close {
flex-shrink: 0;
font-size: 0.85em;
}
.toast-enter-active,
.toast-leave-active {
transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(8px);
}
.toast-leave-active {
position: absolute;
right: 0;
}
.toast-move {
transition: transform var(--dur) var(--ease);
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.toasts {
left: 15px;
right: 15px;
bottom: 15px;
}
.toasts__list {
align-items: stretch;
}
.toast {
width: 100%;
max-width: none;
}
}
</style>
@@ -0,0 +1,452 @@
<template>
<Modal ref="modal" title="Enregistrer dans une playlist" icon="fa-solid fa-bookmark" size="md" @close="onClose()">
<div v-if="video" class="save">
<!-- Titre à enregistrer -->
<div class="save__song">
<img v-if="video.thumbnail" class="save__thumb" :src="video.thumbnail" alt="" loading="lazy" />
<span v-else class="save__thumb save__thumb--empty" aria-hidden="true"><Icon icon="fa-solid fa-music" /></span>
<span class="save__song-text">
<span class="save__song-title u-ellipsis">{{ video.title || 'Titre sans nom' }}</span>
<span class="save__song-author u-ellipsis">{{ video.author || 'Auteur inconnu' }}</span>
</span>
</div>
<div v-if="totalCount > FILTER_THRESHOLD" class="save__filter">
<Icon class="save__filter-icon" icon="fa-solid fa-magnifying-glass" aria-hidden="true" />
<label :for="filterId" class="sr-only">Filtrer les playlists</label>
<input
:id="filterId"
v-model="filter"
type="search"
class="save__filter-input"
placeholder="Filtrer les playlists…"
autocomplete="off"
@keydown.esc.stop="filter = ''"
/>
</div>
<section v-for="group in groups" :key="group.scope" class="save__group" :aria-labelledby="`${uid}-${group.scope}`">
<h3 :id="`${uid}-${group.scope}`" class="save__group-title">
<Icon :icon="group.icon" aria-hidden="true" />
{{ group.title }}
<span class="save__group-count">{{ group.items.length }}</span>
</h3>
<p v-if="!group.loaded" class="save__empty"><Spinner size="sm" label="Chargement…" visible-label /></p>
<p v-else-if="group.items.length === 0" class="save__empty">
{{ filter.trim() ? 'Aucune playlist ne correspond.' : group.emptyText }}
</p>
<ul v-else class="save__list">
<li v-for="playlist in group.items" :key="playlist.playlistId">
<PlaylistItem
:playlist="playlist"
:scope="group.scope"
compact
:disabled="isLocked(playlist) || busyKey !== null"
:aria-describedby="hintIdOf(group.scope, playlist)"
:class="{ 'save__item--locked': isLocked(playlist) }"
@click="save(group.scope, playlist)"
>
<template #end>
<Spinner v-if="busyKey === keyOf(group.scope, playlist)" size="sm" label="Ajout en cours…" />
<span v-else-if="isLocked(playlist)" class="save__badge" :id="hintIdOf(group.scope, playlist)">
<Icon icon="fa-solid fa-lock" aria-hidden="true" /> Synchronisée
</span>
<span v-else-if="contains(playlist)" class="save__badge save__badge--present" :id="hintIdOf(group.scope, playlist)">
<Icon icon="fa-solid fa-check" aria-hidden="true" /> Déjà présent
</span>
<Icon v-else class="save__plus" icon="fa-solid fa-plus" aria-hidden="true" />
</template>
</PlaylistItem>
</li>
</ul>
</section>
<!-- Création rapide : nouvelle playlist contenant ce titre -->
<form v-if="creating" class="save__create" @submit.prevent="createAndSave()">
<Field label="Nom de la nouvelle playlist" :for="nameId" :error="createError">
<template #default="{ id, describedby, invalid }">
<input
:id="id"
ref="nameInput"
v-model="newName"
type="text"
maxlength="100"
autocomplete="off"
placeholder="Ex. : Découvertes"
:disabled="busyKey !== null"
:aria-describedby="describedby"
:aria-invalid="invalid ? 'true' : 'false'"
/>
</template>
</Field>
<SegmentedControl
v-if="canUseGuild"
v-model="newScope"
size="sm"
label="Enregistrer dans"
:options="scopeOptions"
/>
<div class="save__create-actions">
<Button variant="ghost" size="sm" @click="creating = false">Annuler</Button>
<Button type="submit" size="sm" icon="fa-solid fa-plus" :loading="busyKey === 'create'">Créer et ajouter</Button>
</div>
</form>
</div>
<template #footer>
<Button v-if="!creating" variant="secondary" icon="fa-solid fa-plus" class="save__new" @click="startCreate()">Nouvelle playlist</Button>
<Button variant="ghost" @click="modal?.close()">Fermer</Button>
</template>
</Modal>
</template>
<script setup>
/**
* « Enregistrer dans une playlist » : mes playlists et, si l'option est active et que j'ai le droit d'y écrire,
* celles du serveur. Un clic ajoute le titre ; création rapide d'une playlist contenant le titre.
* Ouverte par playlistStore.requestSave(video) (événement du bus PLAYLIST_SAVE_OPEN {video}).
*/
import { computed, nextTick, ref, useId, watch } from 'vue';
import Modal from '@/components/UI/Modal.vue';
import Button from '@/components/UI/Button.vue';
import Field from '@/components/UI/Field.vue';
import Spinner from '@/components/UI/Spinner.vue';
import SegmentedControl from '@/components/UI/SegmentedControl.vue';
import PlaylistItem from '@/components/UI/PlaylistItem.vue';
import { useGlobalStore } from '@/stores/globalStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { isSyncedPlaylist } from './playlistMeta';
const FILTER_THRESHOLD = 6;
const playlistStore = usePlaylistStore();
const globalStore = useGlobalStore();
const uid = `save-${useId()}`;
const filterId = `${uid}-filter`;
const nameId = `${uid}-name`;
const modal = ref(null);
const nameInput = ref(null);
const video = ref(null);
const filter = ref('');
const busyKey = ref(null);
const creating = ref(false);
const newName = ref('');
const newScope = ref('personal');
const createError = ref(null);
const canUseGuild = computed(() => playlistStore.canEditScope('guild'));
const scopeOptions = [
{ value: 'personal', label: 'Mes playlists', icon: 'fa-solid fa-user' },
{ value: 'guild', label: 'Serveur', icon: 'fa-solid fa-users' }
];
function fold(text) {
return String(text || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
}
function filtered(list) {
const query = fold(filter.value.trim());
return query ? list.filter((playlist) => fold(playlist.title).includes(query)) : list;
}
const groups = computed(() => {
const result = [{
scope: 'personal',
title: 'Mes playlists',
icon: 'fa-solid fa-user',
loaded: playlistStore.personalLoaded,
emptyText: "Vous n'avez pas encore de playlist : créez-en une ci-dessous.",
items: filtered(playlistStore.sortedPersonal)
}];
if (canUseGuild.value) {
result.push({
scope: 'guild',
title: globalStore.actualServer?.name ? `Serveur ${globalStore.actualServer.name}` : 'Playlists du serveur',
icon: 'fa-solid fa-users',
loaded: playlistStore.guildLoaded,
emptyText: 'Aucune playlist partagée pour le moment.',
items: filtered(playlistStore.sortedGuild)
});
}
return result;
});
const totalCount = computed(() => playlistStore.counts.personal + (canUseGuild.value ? playlistStore.counts.guild : 0));
function keyOf(scope, playlist) {
return `${scope}:${playlist.playlistId}`;
}
function hintIdOf(scope, playlist) {
if (!isLocked(playlist) && !contains(playlist)) return undefined;
return `${uid}-hint-${scope}-${playlist.playlistId}`;
}
// Playlist synchronisée avec YouTube : remplacée à chaque synchronisation, un ajout local serait perdu
function isLocked(playlist) {
return isSyncedPlaylist(playlist);
}
function contains(playlist) {
const id = video.value?.id;
if (id === undefined || id === null) return false;
return Array.isArray(playlist.songs) && playlist.songs.some((song) => String(song?.id) === String(id));
}
watch(() => playlistStore.saveRequest, (request) => {
if (!request?.video) return;
video.value = request.video;
filter.value = '';
creating.value = false;
createError.value = null;
busyKey.value = null;
// Listes pas encore chargées (page ouverte depuis peu)
if (!playlistStore.personalLoaded && !playlistStore.personalLoading) playlistStore.fetchPersonal();
modal.value?.open();
});
function onClose() {
creating.value = false;
}
async function save(scope, playlist) {
if (busyKey.value !== null || isLocked(playlist) || !video.value) return;
busyKey.value = keyOf(scope, playlist);
const result = await playlistStore.addSong(scope, playlist.playlistId, video.value);
busyKey.value = null;
if (!result.ok) {
playlistStore.notify(result.message, { tone: 'error' });
return;
}
modal.value?.close();
const id = playlist.playlistId;
playlistStore.notify(`Ajouté à « ${result.playlist?.title || playlist.title} »`, {
tone: 'success',
action: { label: 'Ouvrir', run: () => playlistStore.openPlaylist(scope, id) }
});
}
function startCreate() {
creating.value = true;
newName.value = '';
createError.value = null;
newScope.value = playlistStore.defaultCreateScope();
nextTick(() => nameInput.value?.focus());
}
async function createAndSave() {
if (busyKey.value !== null || !video.value) return;
const name = newName.value.trim();
if (!name) {
createError.value = 'Donnez un nom à la playlist.';
nameInput.value?.focus();
return;
}
createError.value = null;
const scope = newScope.value === 'guild' && canUseGuild.value ? 'guild' : 'personal';
busyKey.value = 'create';
const created = await playlistStore.createPlaylist({ scope, name });
if (!created.ok) {
busyKey.value = null;
createError.value = created.message;
return;
}
const added = await playlistStore.addSong(scope, created.playlist.playlistId, video.value);
busyKey.value = null;
modal.value?.close();
const id = created.playlist.playlistId;
if (added.ok) {
playlistStore.notify(`Playlist « ${created.playlist.title} » créée avec ce titre`, {
tone: 'success',
action: { label: 'Ouvrir', run: () => playlistStore.openPlaylist(scope, id) }
});
} else {
playlistStore.notify(`Playlist « ${created.playlist.title} » créée, mais le titre n'a pas pu y être ajouté : ${added.message}`, { tone: 'error' });
}
}
</script>
<style scoped>
.save {
display: flex;
flex-direction: column;
gap: 16px;
}
.save__song {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: var(--radius);
background-color: var(--primary);
border: 1px solid var(--border);
min-width: 0;
}
.save__thumb {
flex-shrink: 0;
width: 64px;
aspect-ratio: 16 / 9;
border-radius: var(--radius-sm);
object-fit: cover;
background-color: var(--tertiary);
}
.save__thumb--empty {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.save__song-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.save__song-title {
font-weight: 600;
}
.save__song-author {
font-size: 0.8rem;
color: var(--text-secondary);
}
.save__filter {
position: relative;
display: flex;
align-items: center;
}
.save__filter-icon {
position: absolute;
left: 10px;
font-size: 0.8em;
color: var(--text-secondary);
pointer-events: none;
}
.save__filter-input {
width: 100%;
box-sizing: border-box;
padding-left: 30px !important;
}
.save__group {
display: flex;
flex-direction: column;
gap: 6px;
}
.save__group-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--text-secondary);
}
.save__group-count {
padding: 0 7px;
border-radius: var(--radius-pill);
background-color: color-mix(in srgb, var(--text) 10%, transparent);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
}
.save__empty {
margin: 0;
padding: 8px 2px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.save__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.save__list :deep(.playlist-item:disabled) {
cursor: default;
}
.save__item--locked {
opacity: 0.6;
}
.save__plus {
flex-shrink: 0;
margin-right: 6px;
color: var(--text-secondary);
opacity: 0;
transition: opacity var(--dur-fast) ease;
}
.save__list :deep(.playlist-item:hover) .save__plus,
.save__list :deep(.playlist-item:focus-visible) .save__plus {
opacity: 1;
color: var(--main);
}
@media (hover: none) {
.save__plus {
opacity: 1;
}
}
.save__badge {
display: inline-flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
padding: 2px 8px;
border-radius: var(--radius-pill);
background-color: color-mix(in srgb, var(--text) 9%, transparent);
color: var(--text-secondary);
font-size: 0.72rem;
font-weight: 600;
white-space: nowrap;
}
.save__badge--present {
color: var(--text-success);
}
.save__create {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border-radius: var(--radius);
border: 1px dashed color-mix(in srgb, var(--main) 55%, transparent);
background-color: color-mix(in srgb, var(--main) 5%, transparent);
}
.save__create input {
width: 100%;
box-sizing: border-box;
}
.save__create-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.save__new {
margin-right: auto;
}
</style>
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more