Version 2.1.0 - Update UI
This commit is contained in:
158 files changed
+23753
-7224
No files matched your search
+10
-2
@@ -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
@@ -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
@@ -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>
|
||||
+246
-45
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<div class="card-content">
|
||||
</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>
|
||||
@@ -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>
|
||||
|
||||
<!-- 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)"/>
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
<template v-else>Aucun compte lié</template>
|
||||
</p>
|
||||
<p v-if="!integ.google?.configured" class="hint">
|
||||
Il manque : {{ googleMissing.join(", ") }}.
|
||||
</p>
|
||||
</AdminBanner>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
</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 #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">
|
||||
<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 }}
|
||||
</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" }}
|
||||
</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>
|
||||
</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>
|
||||
</template>
|
||||
</AdminCard>
|
||||
</AdminGrid>
|
||||
|
||||
<!-- 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>
|
||||
</AdminCard>
|
||||
</div>
|
||||
<p class="hint">
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
<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"/> Administration</p>
|
||||
<nav class="admin-nav">
|
||||
<button v-for="item in sections" :key="item.id"
|
||||
:class="['nav-item', { active: adminStore.section === item.id }]"
|
||||
<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"/>
|
||||
<span>{{ item.label }}</span>
|
||||
<span v-if="badges[item.id]" :class="['nav-badge', badges[item.id].type]">{{ badges[item.id].text }}</span>
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+304
-296
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</AdminState>
|
||||
</Box>
|
||||
|
||||
<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()">
|
||||
<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 :disabled="!selected || downloading" icon="fa-solid fa-download" @click="download()">
|
||||
<Button variant="secondary" :disabled="!selected" :loading="downloading" icon="fa-solid fa-download" @click="download()">
|
||||
{{ downloading ? "Téléchargement…" : "Télécharger" }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AdminToolbar>
|
||||
|
||||
<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>
|
||||
<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()">
|
||||
<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>
|
||||
</AdminCard>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)"/>
|
||||
<AdminStat label="Valeurs invalides" :value="invalidCount" icon="fa-solid fa-triangle-exclamation"
|
||||
:color="invalidCount ? 'var(--text-error)' : 'var(--text-success)'"/>
|
||||
</div>
|
||||
|
||||
<Glider v-model="tab">
|
||||
<span>Par serveur</span>
|
||||
<span>Par utilisateur</span>
|
||||
<span>Brutes</span>
|
||||
</Glider>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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"
|
||||
:tone="invalidCount ? 'error' : 'success'" :sub="safeNum(data.raw?.length) + ' compteur(s) au total'"/>
|
||||
</AdminStatRow>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"> · {{ 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;
|
||||
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;
|
||||
width: 96px;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
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>
|
||||
@@ -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>
|
||||
<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>
|
||||
@@ -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">
|
||||
<!-- ----- 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 }"/>
|
||||
</div>
|
||||
<div class="text">
|
||||
<p class="title" :title="row.title">{{ row.title || "Sans titre" }}</p>
|
||||
<div class="tags">
|
||||
<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'" color="var(--text-error)" icon="fa-brands fa-google">
|
||||
Synchronisée<span v-if="row.syncedAt"> {{ formatRelative(row.syncedAt, nowDate) }}</span>
|
||||
<Tag v-if="row.source === 'google'" tone="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>
|
||||
</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"/>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
</div>
|
||||
</header>
|
||||
<div class="adm-section__body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</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>
|
||||
@@ -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="30px"/>
|
||||
<span v-else class="mono">{{ detail.owner?.id || "Inconnu" }}</span>
|
||||
<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="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 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>
|
||||
</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>
|
||||
</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>
|
||||
</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">
|
||||
<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>
|
||||
<Button icon="fa-solid fa-rotate" :disabled="savingRole || selectedRole === (detail.secureRole?.id ?? null)" @click="saveRole()">Mettre à jour</Button>
|
||||
</div>
|
||||
</ModalTree>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
<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-else color="var(--text-secondary)">Membre</Tag>
|
||||
<Tag v-if="member.isBanned" color="var(--text-error)">Banni</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>
|
||||
</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')"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalTree>
|
||||
</AdminState>
|
||||
</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>
|
||||
<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>
|
||||
</AdminCard>
|
||||
|
||||
<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>
|
||||
</ModalTree>
|
||||
</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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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" color="var(--text-error)" icon="fa-solid fa-triangle-exclamation">{{ guild.missingPermissions.length }} permission(s) manquante(s)</Tag>
|
||||
</div>
|
||||
</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>
|
||||
<Tag v-if="guild.missingPermissions?.length" tone="warning" icon="fa-solid fa-triangle-exclamation">
|
||||
{{ guild.missingPermissions.length }} permission(s) manquante(s)
|
||||
</Tag>
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
// 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';
|
||||
}
|
||||
|
||||
function prefixOf(type) {
|
||||
switch (typeOf(type)) {
|
||||
case 'error': return "Erreur : ";
|
||||
case 'success': return "Succès : ";
|
||||
case 'warning': return "Attention : ";
|
||||
default: return "Information : ";
|
||||
}
|
||||
}
|
||||
</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));
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 :color="detail.hasDiscordAuth ? 'var(--text-success)' : 'var(--text-warning)'">{{ detail.hasDiscordAuth ? "Active" : "Absente" }}</Tag>
|
||||
<Tag :tone="detail.hasDiscordAuth ? 'success' : '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 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>
|
||||
</div>
|
||||
</ModalTree>
|
||||
</AdminCard>
|
||||
|
||||
<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)">
|
||||
<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>
|
||||
</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)">
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</ModalTree>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</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')"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalTree>
|
||||
</AdminState>
|
||||
</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)"/>
|
||||
<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 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>
|
||||
</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="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"/>
|
||||
<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>
|
||||
<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>
|
||||
</AdminState>
|
||||
</AdminCard>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</ModalTree>
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
<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 class="tags">
|
||||
<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" 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.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"> ({{ 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"/>
|
||||
<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>
|
||||
+145
-173
@@ -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">
|
||||
<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">
|
||||
<!-- 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="mono">PID {{ proc.ytdlp.pid }}</span>
|
||||
<span class="since">{{ formatRelative(proc.ytdlp.startedAt, nowDate) }}</span>
|
||||
</AdminKv>
|
||||
<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
@@ -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 */ }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
<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>{{ server.members.length + 1}}</ListenBox>
|
||||
<IconAction ref="opener" @click="showMenu = !showMenu" :icon="showMenu ? 'fa-solid fa-angle-up' : 'fa-solid fa-angle-down'"/>
|
||||
<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>
|
||||
<Error v-else><ServerItem/></Error>
|
||||
</Box>
|
||||
<div :style="`width: ${serverBox.offsetWidth}px !important;`" class="menu" v-if="showMenu">
|
||||
<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">
|
||||
<div>
|
||||
<p><Icon icon="fa-solid fa-users"/> Utilisateurs en ligne</p>
|
||||
<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">
|
||||
<div v-if="server.members.length > 0" class="users-list">
|
||||
<ul v-if="server.members.length > 0" class="users-list" aria-label="Utilisateurs en ligne">
|
||||
<GuildHeaderUsers :server="server"/>
|
||||
</div>
|
||||
<Info secondary v-else>Aucun utilisateur en ligne</Info>
|
||||
</div>
|
||||
</ul>
|
||||
<Info secondary v-else>Personne d'autre n'est connecté pour le moment.</Info>
|
||||
</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>
|
||||
<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>
|
||||
</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;
|
||||
});
|
||||
|
||||
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 toggleMenu() {
|
||||
if (showMenu.value) closeMenu(false);
|
||||
else openMenu();
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', () => {
|
||||
function openMenu() {
|
||||
showMenu.value = true;
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true);
|
||||
}
|
||||
|
||||
function closeMenu(focusOpener) {
|
||||
if (!showMenu.value) return;
|
||||
showMenu.value = false;
|
||||
});
|
||||
userStore.userInfo.identity.isAdmin = null;
|
||||
userStore.userInfo.identity.isMod = null;
|
||||
userStore.userInfo.identity.isOwner = null;
|
||||
});
|
||||
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;
|
||||
@media screen and (max-width: 768px),
|
||||
screen and (max-height: 607px) {
|
||||
.server-actions {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -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 :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>
|
||||
<Forward title="Suivant" @click="forward" :class="{'playIcon': online && video, 'itm-sec': true, 'enabled': online}" class="itm-sec" icon="fa-solid fa-forward-step"/>
|
||||
</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 });
|
||||
|
||||
} else {
|
||||
// player est fermé → on ouvre normalement
|
||||
function openPlayer() {
|
||||
clearTimeout(closeTimer);
|
||||
playerClosing.value = false;
|
||||
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;
|
||||
|
||||
+530
-171
@@ -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"/>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
</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" :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>
|
||||
<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>
|
||||
</Modal>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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);
|
||||
|
||||
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]);
|
||||
|
||||
// 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";
|
||||
}
|
||||
|
||||
Events.on("playlist:init", (data) => {
|
||||
playlists.value = data;
|
||||
})
|
||||
|
||||
// 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());
|
||||
});
|
||||
|
||||
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.";
|
||||
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' });
|
||||
}
|
||||
newPlaylistTitle.value = '';
|
||||
isYoutube.value = false;
|
||||
urlLink.value = null;
|
||||
|
||||
}, { name: newPlaylistTitle.value, url: urlLink.value });
|
||||
}
|
||||
|
||||
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-items-list {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
.playlist__heading {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -1,80 +1,77 @@
|
||||
<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">
|
||||
<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>
|
||||
<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';
|
||||
/**
|
||||
* 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 { 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 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 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)
|
||||
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
|
||||
}
|
||||
direction.value = newTab > oldTab ? 'forward' : 'backward';
|
||||
});
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
<style scoped>
|
||||
|
||||
.list-wrapper {
|
||||
position: relative; /* conteneur pour les sections absolues */
|
||||
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; }
|
||||
@@ -92,27 +89,12 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -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();
|
||||
function optionId(index) {
|
||||
return `${listId}-opt-${index}`;
|
||||
}
|
||||
if (searchQuery.value.trim() === '') {
|
||||
|
||||
// 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;
|
||||
}
|
||||
if(queryByResult == "result") {
|
||||
Events.emit("SEARCH_RESULT_SENDED");
|
||||
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) => {
|
||||
Events.emit("SEARCH_RESULT", {data: data, query: searchQuery.value});
|
||||
if(queryByResult == "result") {
|
||||
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();
|
||||
}
|
||||
}, searchQuery.value);
|
||||
}, query);
|
||||
}
|
||||
|
||||
Events.on("VIEW_CLOSED", () => {
|
||||
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>
|
||||
@@ -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,11 +79,13 @@ function finishLoading() {
|
||||
}
|
||||
|
||||
// === EVENTS ===
|
||||
Events.on("SEARCH_STARTED", () => {
|
||||
// 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) => {
|
||||
async SEARCH_RESULT(data) {
|
||||
setLoading("Chargement des résultats de recherche...");
|
||||
|
||||
const isPlaylist = data?.data?.form === "PLAYLIST";
|
||||
@@ -94,9 +102,9 @@ Events.on("SEARCH_RESULT", async (data) => {
|
||||
};
|
||||
|
||||
finishLoading(); // on enlève le loader seulement une fois que le composant est prêt
|
||||
});
|
||||
},
|
||||
|
||||
Events.on("VIEW_INFO", () => {
|
||||
VIEW_INFO() {
|
||||
setLoading("Chargement des informations...");
|
||||
|
||||
if (actualComponent.value.onlyMobile) {
|
||||
@@ -112,9 +120,9 @@ Events.on("VIEW_INFO", () => {
|
||||
};
|
||||
|
||||
finishLoading();
|
||||
});
|
||||
},
|
||||
|
||||
Events.on("VIEW_FILES", () => {
|
||||
VIEW_FILES() {
|
||||
setLoading("Chargement des fichiers...");
|
||||
|
||||
actualComponent.value = {
|
||||
@@ -123,9 +131,9 @@ Events.on("VIEW_FILES", () => {
|
||||
};
|
||||
|
||||
finishLoading();
|
||||
});
|
||||
},
|
||||
|
||||
Events.on("VIEW_PLAYLIST", (data) => {
|
||||
VIEW_PLAYLIST(data) {
|
||||
setLoading("Chargement de la playlist...");
|
||||
|
||||
actualComponent.value = {
|
||||
@@ -133,18 +141,20 @@ Events.on("VIEW_PLAYLIST", (data) => {
|
||||
props: {
|
||||
playlist: data.playlist,
|
||||
hasBeenActualized: data.hasBeenActualized,
|
||||
// Portée explicite (personnel / serveur) quand l'émetteur la connaît
|
||||
scope: data.scope,
|
||||
},
|
||||
};
|
||||
|
||||
finishLoading();
|
||||
});
|
||||
},
|
||||
|
||||
Events.on("VIEW_RESET", () => {
|
||||
VIEW_RESET() {
|
||||
returnDefault();
|
||||
finishLoading();
|
||||
});
|
||||
},
|
||||
|
||||
Events.on("VIEW_HELP", () => {
|
||||
VIEW_HELP() {
|
||||
setLoading("Chargement de l'aide...");
|
||||
|
||||
actualComponent.value = {
|
||||
@@ -153,9 +163,9 @@ Events.on("VIEW_HELP", () => {
|
||||
};
|
||||
|
||||
finishLoading();
|
||||
});
|
||||
},
|
||||
|
||||
Events.on("VIEW_HISTORY", () => {
|
||||
VIEW_HISTORY() {
|
||||
setLoading("Chargement de l'historique...");
|
||||
|
||||
actualComponent.value = {
|
||||
@@ -164,6 +174,36 @@ Events.on("VIEW_HISTORY", () => {
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
playerStore.setLyricsOpen(false);
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
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 if (deltaX < -5) {
|
||||
} else {
|
||||
changeSlot((actualIndex.value + 1) % allSlots.value.length, "forward");
|
||||
|
||||
}
|
||||
|
||||
wrapper.value.removeEventListener('touchmove', handleTouchMove, false);
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -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>
|
||||
@@ -1,113 +1,365 @@
|
||||
<template>
|
||||
<div ref="contextMenu" class="contextmenu">
|
||||
<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';
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Actions'
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['open', 'close']);
|
||||
|
||||
const contextMenu = ref(null);
|
||||
const isOpen = ref(false);
|
||||
const mouse = useMouse();
|
||||
|
||||
onMounted(() => {
|
||||
let opener = null;
|
||||
let openedWithPointer = false;
|
||||
let focusHandled = false;
|
||||
|
||||
contextMenu.value.addEventListener('rightclick', (e) => {
|
||||
e.preventDefault(); // Prevent default context menu
|
||||
});
|
||||
const MARGIN = 8;
|
||||
|
||||
contextMenu.value.addEventListener('click', () => {
|
||||
contextMenu.value.style.display = 'none'; // Hide context menu on click
|
||||
});
|
||||
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'));
|
||||
}
|
||||
|
||||
contextMenu.value.addEventListener('mouseleave', () => {
|
||||
contextMenu.value.style.display = 'none'; // Hide context menu on mouse leave
|
||||
});
|
||||
// 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() {
|
||||
const menu = contextMenu.value
|
||||
const { x, y } = mouse.value
|
||||
function show(arg) {
|
||||
const menu = contextMenu.value;
|
||||
if (!menu) return;
|
||||
|
||||
// Affiche temporairement pour mesurer
|
||||
menu.style.display = 'flex'
|
||||
menu.style.left = '0px'
|
||||
menu.style.top = '0px'
|
||||
// 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 };
|
||||
|
||||
// Force le DOM à calculer les dimensions
|
||||
const menuRect = menu.getBoundingClientRect()
|
||||
const windowWidth = window.innerWidth
|
||||
const windowHeight = window.innerHeight
|
||||
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);
|
||||
|
||||
let left = x
|
||||
let top = y
|
||||
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;
|
||||
|
||||
// Ajuste si dépasse à droite
|
||||
if (x + menuRect.width > windowWidth) {
|
||||
left = windowWidth - menuRect.width
|
||||
let left;
|
||||
let top;
|
||||
if (openedWithPointer) {
|
||||
left = point.x - 5;
|
||||
top = point.y - 5;
|
||||
} else {
|
||||
left = x - 5
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
// Ajuste si dépasse en bas
|
||||
if (y + menuRect.height > windowHeight) {
|
||||
top = windowHeight - menuRect.height
|
||||
} else {
|
||||
top = y - 5
|
||||
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');
|
||||
}
|
||||
|
||||
// Applique la position corrigée
|
||||
menu.style.left = `${Math.max(left, 0)}px`
|
||||
menu.style.top = `${Math.max(top, 0)}px`
|
||||
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 {
|
||||
<!-- 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: 0px;
|
||||
z-index: 1000;
|
||||
border-radius: 10px;
|
||||
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;;
|
||||
.ui-menu:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ui-menu > * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
padding: 0 7px;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
:deep(div:first-child) {
|
||||
border-radius: 5px 5px 0px 0px;
|
||||
}
|
||||
|
||||
:deep(div:last-child) {
|
||||
border-radius: 0px 0px 5px 5px;
|
||||
}
|
||||
|
||||
:deep(div:only-child) {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
:deep(div:hover) {
|
||||
background-color: var(--primary-hover);
|
||||
color: var(--text-inverse);
|
||||
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;
|
||||
}
|
||||
|
||||
.ui-menu > * p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ui-menu > * svg {
|
||||
width: 1.1em;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
@@ -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>
|
||||
+14
-12
@@ -1,26 +1,28 @@
|
||||
<template>
|
||||
<div>
|
||||
<Icon icon="fa-solid fa-circle-exclamation"></Icon>
|
||||
<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; }
|
||||
}
|
||||
|
||||
div {
|
||||
/* 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: fadeIn 0.5s ease-in-out;
|
||||
animation: u-fade-in 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.feedback-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+14
-12
@@ -1,14 +1,17 @@
|
||||
<template>
|
||||
<div :class="{ second: secondary }">
|
||||
<Icon icon="fa-solid fa-circle-info"></Icon>
|
||||
<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,17 +20,16 @@ defineProps({
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
div {
|
||||
/* 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: fadeIn 0.5s ease-in-out;
|
||||
animation: u-fade-in 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.feedback-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-message {
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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,87 +13,98 @@
|
||||
</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() {
|
||||
function stop() {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
resetPosition();
|
||||
}
|
||||
|
||||
function cycle() {
|
||||
clearTimeout(timer);
|
||||
resetPosition();
|
||||
timer = setTimeout(() => {
|
||||
if (!shouldScroll.value) return;
|
||||
const duration = distance.value / props.speed * 1000; // ms
|
||||
|
||||
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);
|
||||
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();
|
||||
|
||||
// observer taille
|
||||
resizeObserver = new ResizeObserver(() => recalc());
|
||||
resizeObserver.observe(container.value);
|
||||
resizeObserver.observe(content.value);
|
||||
|
||||
// observer slot
|
||||
// Le contenu du slot change (nouveau titre)
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
nextTick(() => recalc());
|
||||
});
|
||||
@@ -101,12 +113,15 @@ onMounted(() => {
|
||||
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>
|
||||
@@ -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';
|
||||
|
||||
|
||||
+382
-92
@@ -1,147 +1,437 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div ref="modal" class="modal-overlay">
|
||||
<div ref="modalContent" class="modal">
|
||||
<div class="modal-header">
|
||||
<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">
|
||||
<Icon font-size="1.5em" v-if="icon" :icon="icon" />
|
||||
<p>{{ title }}</p>
|
||||
<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>
|
||||
<IconAction @click="close()" icon="fa-solid fa-xmark" />
|
||||
<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>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
<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;
|
||||
|
||||
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>
|
||||
|
||||
import IconAction from '../UI/IconAction.vue';
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
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-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modal-title-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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 {
|
||||
background-color: var(--secondary);
|
||||
border-radius: 10px;
|
||||
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;
|
||||
max-height: calc(100vh - 20px);
|
||||
max-height: calc(100dvh - 20px);
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3500;
|
||||
.modal-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 14px 16px 16px 16px;
|
||||
}
|
||||
|
||||
|
||||
.modal-footer {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
<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 {
|
||||
@@ -55,6 +67,7 @@ defineProps({
|
||||
|
||||
.settings-item {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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>
|
||||
+405
-149
@@ -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) {
|
||||
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;
|
||||
}
|
||||
|
||||
function readOptions() {
|
||||
const nodes = flatten(slots.default ? slots.default() : []);
|
||||
return nodes.map((vnode) => ({ vnode, value: vnode.props?.value }));
|
||||
}
|
||||
|
||||
function optionId(index) {
|
||||
return `${listId}-opt-${index}`;
|
||||
}
|
||||
|
||||
function optionKey(option, index) {
|
||||
return option.value !== undefined && option.value !== null ? `v-${String(option.value)}-${index}` : `i-${index}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function isCurrent(option) {
|
||||
return option.value === currentValue();
|
||||
}
|
||||
|
||||
function currentIndex() {
|
||||
return lastOptions.findIndex((option) => isCurrent(option));
|
||||
}
|
||||
|
||||
function hasSelection() {
|
||||
// Relit le slot à chaque rendu : les options suivent les données du parent
|
||||
lastOptions = readOptions();
|
||||
return currentIndex() !== -1;
|
||||
}
|
||||
|
||||
function renderedOptions() {
|
||||
return lastOptions;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
window.addEventListener('rightclick', (e) => {
|
||||
e.preventDefault(); // Prevent default context menu
|
||||
});
|
||||
|
||||
window.addEventListener('click', (e) => {
|
||||
hideMenu(e);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
showMenu.value = false;
|
||||
|
||||
});
|
||||
updateModelValue(firstSlot ? firstSlot.props ? firstSlot.props.value : '' : '');
|
||||
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('rightclick', (e) => {
|
||||
e.preventDefault(); // Prevent default context menu
|
||||
});
|
||||
window.removeEventListener('click', (e) => {
|
||||
hideMenu(e);
|
||||
});
|
||||
|
||||
window.removeEventListener('resize', () => {
|
||||
showMenu.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
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 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;
|
||||
.ui-select {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-select__trigger {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.container .option:last-child {
|
||||
border-radius: 0px 0px 5px 5px !important;
|
||||
}
|
||||
|
||||
.container .option:only-child {
|
||||
border-radius: 0px 0px 5px 5px !important;
|
||||
}
|
||||
|
||||
.container .option:first-child {
|
||||
border-top: 1px solid var(--secondary) !important;
|
||||
}
|
||||
|
||||
.option {
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
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;
|
||||
}
|
||||
|
||||
.ui-select__trigger:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, var(--tertiary) 80%, var(--text));
|
||||
}
|
||||
|
||||
.ui-select__trigger:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.ui-select--open .ui-select__trigger {
|
||||
box-shadow: 0 0 0 2px var(--main);
|
||||
}
|
||||
|
||||
.ui-select__trigger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-select__value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background-color: var(--primary-hover);
|
||||
color: var(--text-inverse);
|
||||
.ui-select__placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.showed {
|
||||
/* margin-bottom: 0 !important;
|
||||
padding-bottom: 0; */
|
||||
border-radius: 5px 5px 0 0 !important;
|
||||
.ui-select__chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
transition: transform var(--dur-fast) ease;
|
||||
}
|
||||
|
||||
.icon {
|
||||
.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>
|
||||
@@ -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>
|
||||
@@ -1,8 +1,24 @@
|
||||
<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>
|
||||
/**
|
||||
* 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,
|
||||
@@ -11,13 +27,41 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
<template>
|
||||
<div>
|
||||
<Icon icon="fa-solid fa-check-circle"></Icon>
|
||||
<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; }
|
||||
}
|
||||
|
||||
div {
|
||||
/* 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: fadeIn 0.5s ease-in-out;
|
||||
animation: u-fade-in 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.feedback-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
|
||||
@@ -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>
|
||||
@@ -1,10 +1,18 @@
|
||||
<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>
|
||||
/**
|
||||
* É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,
|
||||
@@ -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>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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
Reference in new issue
Block a user