Version 2.0.0 - update of everything
This commit is contained in:
57 files changed
+9407
-364
No files matched your search
@@ -0,0 +1,19 @@
|
||||
# Dépendances et build locaux : refaits dans l'image (binaires du poste de dev inutilisables sous Linux)
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log*
|
||||
*.log
|
||||
|
||||
# Fichiers d'environnement locaux (secrets éventuels) ; .env / .env.production restent disponibles pour le build Vite
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Dépôt git et fichiers sans intérêt pour l'image
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
README.md
|
||||
+13
-2
@@ -1,4 +1,5 @@
|
||||
FROM node:lts
|
||||
# 1. Construction du site (Vite)
|
||||
FROM node:lts AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -10,8 +11,18 @@ COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# 2. Image finale : uniquement le dist/ construit et le serveur statique
|
||||
FROM node:lts-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g http-server
|
||||
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
EXPOSE 4001
|
||||
|
||||
CMD ["http-server", "dist", "-p", "4001", "-P", "http://localhost:4001?/"]
|
||||
# -c-1 : pas de cache navigateur longue durée, sinon après un déploiement index.html (mis en cache 1 h)
|
||||
# peut référencer des assets hachés qui n'existent plus
|
||||
# -P : toute route inconnue renvoie index.html (routeur Vue en mode history)
|
||||
CMD ["http-server", "dist", "-p", "4001", "-c-1", "-P", "http://localhost:4001?/"]
|
||||
+3
-4
@@ -6,13 +6,12 @@ services:
|
||||
container_name: subsonics-frontend
|
||||
ports:
|
||||
- "4001:4001"
|
||||
volumes:
|
||||
- subsonics-frontend:/app/dist
|
||||
# Pas de volume sur /app/dist : un volume nommé n'est rempli qu'à sa création,
|
||||
# il masquait donc chaque nouveau build (le site servait toujours la première version déployée).
|
||||
# Ancien volume à supprimer une fois : docker volume rm <projet>_subsonics-frontend
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
volumes:
|
||||
subsonics-frontend:
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chopin-frontend",
|
||||
"version": "1.5.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --host --port 8080",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<router-view/>
|
||||
<Announcement/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { useGlobalStore } from '@/stores/globalStore';
|
||||
import Announcement from '@/components/UI/Announcement.vue';
|
||||
const globalStore = useGlobalStore();
|
||||
console.log("Subsonics Chopin - Welcome");
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
<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>
|
||||
</div>
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</Box>
|
||||
</template>
|
||||
<script setup>
|
||||
// Carte d'information (fond --tertiary, pastille d'icône en --secondary comme Metric.vue)
|
||||
import Box from '@/components/UI/Box.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "fa-solid fa-circle-info"
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "var(--text)"
|
||||
}
|
||||
});
|
||||
</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>
|
||||
@@ -0,0 +1,587 @@
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
<template v-else>Aucun compte lié</template>
|
||||
</p>
|
||||
<p v-if="!integ.google?.configured" class="hint">
|
||||
Il manque : {{ googleMissing.join(", ") }}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="integration">
|
||||
<div class="integration-head">
|
||||
<span class="integration-name"><Icon icon="fa-brands fa-spotify"/> Spotify</span>
|
||||
<Tag :color="spotifyState.color" :icon="spotifyState.icon">{{ spotifyState.label }}</Tag>
|
||||
</div>
|
||||
<p v-if="integ.spotify?.lastTest" class="hint">
|
||||
Dernier test {{ formatRelative(integ.spotify.lastTest.at, nowDate) }} : {{ integ.spotify.lastTest.message }}
|
||||
</p>
|
||||
<p v-else class="hint">Liens publics uniquement (albums, playlists, titres), convertis vers YouTube.</p>
|
||||
<div class="actions">
|
||||
<Button icon="fa-solid fa-vial" :disabled="spotifyTesting || !integ.spotify?.configured" @click="testSpotify()">
|
||||
{{ spotifyTesting ? "Test…" : "Tester" }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="integration">
|
||||
<div class="integration-head">
|
||||
<span class="integration-name"><Icon icon="fa-solid fa-cloud"/> Proxy WARP</span>
|
||||
<Tag :color="warpState.color" :icon="warpState.icon">{{ warpState.label }}</Tag>
|
||||
</div>
|
||||
<p class="hint">{{ integ.warp?.configured ? "Routes : " + (integ.warp.routes || []).map(r => r === 'warp' ? 'WARP' : 'directe').join(" puis ") : "Connexion directe uniquement" }}</p>
|
||||
<div class="actions">
|
||||
<Button class="secondary" icon="fa-solid fa-arrow-right" @click="adminStore.setSection('warp')">Section WARP</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="integration">
|
||||
<div class="integration-head">
|
||||
<span class="integration-name"><Icon icon="fa-brands fa-youtube"/> Compte YouTube (lecture)</span>
|
||||
<Tag :color="cookiesState.color" :icon="cookiesState.icon">{{ cookiesState.label }}</Tag>
|
||||
</div>
|
||||
<p class="hint">
|
||||
<template v-if="integ.youtube?.cookies?.present">Cookies importés {{ formatRelative(integ.youtube.cookies.importedAt, nowDate) }}<template v-if="integ.youtube.cookies.invalidReason"> · {{ integ.youtube.cookies.invalidReason }}</template></template>
|
||||
<template v-else>Lecture anonyme (aucun cookie)</template>
|
||||
</p>
|
||||
<div class="actions">
|
||||
<Button class="secondary" icon="fa-solid fa-arrow-right" @click="adminStore.setSection('youtube')">Section YouTube</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="integration">
|
||||
<div class="integration-head">
|
||||
<span class="integration-name"><Icon icon="fa-solid fa-photo-film"/> Base média</span>
|
||||
<Tag :color="!integ.media?.configured ? 'var(--text-secondary)' : integ.media.connected ? 'var(--text-success)' : 'var(--text-error)'"
|
||||
:icon="integ.media?.connected ? 'fa-solid fa-circle-check' : 'fa-solid fa-circle-xmark'">
|
||||
{{ !integ.media?.configured ? "Non configurée" : integ.media.connected ? "Salon accessible" : "Salon introuvable" }}
|
||||
</Tag>
|
||||
</div>
|
||||
<p class="hint">Salon Discord où sont stockés les fichiers envoyés par les utilisateurs.</p>
|
||||
</div>
|
||||
</div>
|
||||
</AdminCard>
|
||||
|
||||
<!-- Paramètres -->
|
||||
<div class="cards">
|
||||
<AdminCard v-for="group in groups" :key="group.id" :title="group.title" :icon="group.icon" :color="group.color">
|
||||
<p v-if="group.hint" class="hint">{{ group.hint }}</p>
|
||||
|
||||
<div v-if="group.id === 'google'" class="uri-block">
|
||||
<p class="hint">URI de redirection autorisée à déclarer dans la console Google Cloud (identifiants OAuth « Application Web ») :</p>
|
||||
<div class="uri-row">
|
||||
<code class="uri">{{ integ.google?.redirectUri || "Définissez d'abord l'adresse du site" }}</code>
|
||||
<IconAction v-if="integ.google?.redirectUri" title="Copier" icon="fa-solid fa-copy" @click="copy(integ.google.redirectUri)"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="field in group.fields" :key="field.key" class="field">
|
||||
<div class="field-head">
|
||||
<label class="field-label" :for="'cfg-' + field.key">{{ field.label }}</label>
|
||||
<Tag v-if="entry(field.key).pendingRestart" color="var(--text-warning)" icon="fa-solid fa-power-off">Redémarrage requis</Tag>
|
||||
<Tag v-else-if="entry(field.key).requiresRestart" color="var(--text-secondary)" icon="fa-solid fa-power-off">Au redémarrage</Tag>
|
||||
</div>
|
||||
<p v-if="entry(field.key).secret" class="secret-state">
|
||||
<Icon :icon="entry(field.key).set ? 'fa-solid fa-lock' : 'fa-solid fa-lock-open'"/>
|
||||
{{ entry(field.key).set ? "Défini (" + safeNum(entry(field.key).length) + " caractères)" : "Non défini" }}
|
||||
</p>
|
||||
<div class="field-row">
|
||||
<input v-if="entry(field.key).secret" :id="'cfg-' + field.key" type="password" v-model="drafts[field.key]"
|
||||
class="input mono" :maxlength="field.max || 300" autocomplete="new-password" spellcheck="false"
|
||||
:placeholder="entry(field.key).set ? 'Nouvelle valeur (laisser vide pour conserver)' : 'Valeur'"
|
||||
@keyup.enter="save(field)"/>
|
||||
<input v-else :id="'cfg-' + field.key" type="text" v-model="drafts[field.key]"
|
||||
:class="['input', { mono: field.mono }]" :maxlength="field.max || 300" spellcheck="false" autocomplete="off"
|
||||
:inputmode="field.numeric ? 'numeric' : undefined" :placeholder="field.placeholder || ''"
|
||||
@keyup.enter="save(field)"/>
|
||||
<Button icon="fa-solid fa-floppy-disk" :disabled="!isDirty(field) || saving[field.key]" @click="save(field)">
|
||||
{{ saving[field.key] ? "…" : "Enregistrer" }}
|
||||
</Button>
|
||||
<IconAction v-if="!entry(field.key).secret && isDirty(field)" title="Annuler" icon="fa-solid fa-xmark" @click="resetDraft(field.key)"/>
|
||||
<IconAction v-if="entry(field.key).secret && entry(field.key).set && entry(field.key).optional" title="Effacer"
|
||||
icon="fa-solid fa-trash" color="var(--text-error)" @click="clearSecret(field)"/>
|
||||
</div>
|
||||
<p v-if="field.hint" class="hint">{{ field.hint }}</p>
|
||||
</div>
|
||||
</AdminCard>
|
||||
</div>
|
||||
<p class="hint">
|
||||
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>
|
||||
</AdminState>
|
||||
|
||||
<AdminConfirm ref="confirmRef"/>
|
||||
</AdminSection>
|
||||
</template>
|
||||
<script setup>
|
||||
/**
|
||||
* Section « Configuration » : édition champ par champ de la liste blanche de config.json
|
||||
* (backend : /ADMIN/CONFIG/GET, /ADMIN/CONFIG/SET {key, value}) et état des intégrations.
|
||||
* Les secrets sont en écriture seule : le serveur ne renvoie que {set, length}.
|
||||
*/
|
||||
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 AdminCard from './AdminCard.vue';
|
||||
import AdminConfirm from './AdminConfirm.vue';
|
||||
import { safeNum, formatRelative } from './adminFormat';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const confirmRef = ref(null);
|
||||
|
||||
const groups = [
|
||||
{
|
||||
id: 'discord', title: "Discord", icon: "fa-brands fa-discord", color: "var(--admin-color)",
|
||||
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." }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'website', title: "Site web", icon: "fa-solid fa-globe", color: "var(--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)." }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'google', title: "Google / YouTube Data API", icon: "fa-brands fa-google", color: "var(--text-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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'report', title: "Signalements", icon: "fa-solid fa-flag", color: "var(--text-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 }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const allFields = groups.flatMap(g => g.fields);
|
||||
const labelOf = Object.fromEntries(allFields.map(f => [f.key, f.label]));
|
||||
|
||||
const data = computed(() => adminStore.config);
|
||||
const integ = computed(() => data.value?.integrations || {});
|
||||
|
||||
function entry(key) {
|
||||
return data.value?.config?.[key] || {};
|
||||
}
|
||||
|
||||
function serverText(key) {
|
||||
const value = entry(key).value;
|
||||
return value === null || value === undefined ? "" : String(value);
|
||||
}
|
||||
|
||||
// Brouillons : texte en clair pour les champs normaux, toujours vide au départ pour les secrets
|
||||
const drafts = reactive({});
|
||||
const baseline = {};
|
||||
const saving = reactive({});
|
||||
|
||||
// Nouvelle configuration (requête, poussée d'un autre admin) : les champs non modifiés suivent le serveur
|
||||
function syncDrafts() {
|
||||
for (const field of allFields) {
|
||||
if (entry(field.key).secret) {
|
||||
if (drafts[field.key] === undefined) drafts[field.key] = "";
|
||||
continue;
|
||||
}
|
||||
const next = serverText(field.key);
|
||||
if (drafts[field.key] === undefined || drafts[field.key] === baseline[field.key]) drafts[field.key] = next;
|
||||
baseline[field.key] = next;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => data.value?.config, syncDrafts, { immediate: true });
|
||||
|
||||
function isDirty(field) {
|
||||
const draft = drafts[field.key] ?? "";
|
||||
if (entry(field.key).secret) return draft.trim() !== "";
|
||||
return draft.trim() !== serverText(field.key);
|
||||
}
|
||||
|
||||
function resetDraft(key) {
|
||||
drafts[key] = serverText(key);
|
||||
}
|
||||
|
||||
const pendingLabels = computed(() => Object.entries(data.value?.config || {})
|
||||
.filter(([, value]) => value.pendingRestart)
|
||||
.map(([key]) => labelOf[key] || key));
|
||||
|
||||
const googleMissing = computed(() => {
|
||||
const missing = [];
|
||||
if (!integ.value.google?.clientIdSet) missing.push("l'ID client");
|
||||
if (!integ.value.google?.clientSecretSet) missing.push("le secret client");
|
||||
if (!integ.value.website) missing.push("l'adresse du site");
|
||||
return missing;
|
||||
});
|
||||
|
||||
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" };
|
||||
});
|
||||
|
||||
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" };
|
||||
});
|
||||
|
||||
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" };
|
||||
});
|
||||
|
||||
async function send(field, value) {
|
||||
saving[field.key] = true;
|
||||
try {
|
||||
const result = await adminRequest("/ADMIN/CONFIG/SET", { key: field.key, value }, 20000);
|
||||
adminStore.setConfig(result);
|
||||
const info = result?.result || {};
|
||||
if (entry(field.key).secret) drafts[field.key] = "";
|
||||
else {
|
||||
// Valeur normalisée par le serveur (espaces, "/" final...)
|
||||
drafts[field.key] = serverText(field.key);
|
||||
baseline[field.key] = drafts[field.key];
|
||||
}
|
||||
if (!info.changed) adminStore.notify('info', `« ${field.label} » : aucune modification`);
|
||||
else adminStore.notify('success', `« ${field.label} » enregistré` + (info.requiresRestart ? " (pris en compte au redémarrage)" : ""));
|
||||
return true;
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, field.label);
|
||||
return false;
|
||||
} finally {
|
||||
saving[field.key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(field) {
|
||||
if (!isDirty(field) || saving[field.key]) return;
|
||||
const value = drafts[field.key].trim();
|
||||
if (field.key === 'token') {
|
||||
const ok = await confirmRef.value.ask({
|
||||
title: "Remplacer le token du bot",
|
||||
icon: "fa-solid fa-key",
|
||||
message: "Le nouveau token sera utilisé au prochain redémarrage.\nS'il est invalide, Subsonics ne pourra plus se connecter à Discord et il faudra corriger config.json à la main.",
|
||||
confirmLabel: "Remplacer",
|
||||
confirmIcon: "fa-solid fa-key"
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
await send(field, value);
|
||||
}
|
||||
|
||||
async function clearSecret(field) {
|
||||
const ok = await confirmRef.value.ask({
|
||||
title: "Effacer « " + field.label + " »",
|
||||
icon: "fa-solid fa-trash",
|
||||
message: "La valeur enregistrée sera supprimée de config.json. L'intégration correspondante cessera de fonctionner.",
|
||||
confirmLabel: "Effacer",
|
||||
confirmIcon: "fa-solid fa-trash"
|
||||
});
|
||||
if (!ok) return;
|
||||
await send(field, "");
|
||||
}
|
||||
|
||||
// ----- Test Spotify -----
|
||||
const spotifyTesting = ref(false);
|
||||
|
||||
async function testSpotify() {
|
||||
if (spotifyTesting.value) return;
|
||||
spotifyTesting.value = true;
|
||||
try {
|
||||
const result = await adminRequest("/ADMIN/CONFIG/SPOTIFY/TEST", {}, 25000);
|
||||
if (data.value && result?.integrations) adminStore.setConfig({ ...data.value, integrations: result.integrations });
|
||||
adminStore.notify(result?.result?.ok ? 'success' : 'error', "Spotify : " + (result?.result?.message || "résultat inconnu"));
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, "Test Spotify");
|
||||
} finally {
|
||||
spotifyTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
adminStore.notify('success', "Copié dans le presse-papiers");
|
||||
} catch (e) {
|
||||
adminStore.notify('error', "Copie impossible : sélectionnez le texte à la main");
|
||||
}
|
||||
}
|
||||
|
||||
const nowDate = ref(Date.now());
|
||||
let timer = null;
|
||||
|
||||
onMounted(() => {
|
||||
adminStore.fetchConfig();
|
||||
timer = setInterval(() => { nowDate.value = Date.now(); }, 5000);
|
||||
});
|
||||
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
background-color: var(--secondary);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.75em;
|
||||
background-color: var(--secondary);
|
||||
border-radius: 5px;
|
||||
padding: 7px;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.integration .uri {
|
||||
background-color: var(--tertiary);
|
||||
}
|
||||
|
||||
/* Champs */
|
||||
.field {
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<Modal ref="modal" :title="options.title" :icon="options.icon">
|
||||
<p ref="messageRef" 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()"/>
|
||||
</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>
|
||||
</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>
|
||||
<Button :disabled="!canConfirm" :icon="options.confirmIcon" @click="confirm()">{{ options.confirmLabel }}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
<script setup>
|
||||
/**
|
||||
* Modale de confirmation réutilisable de l'administration.
|
||||
* 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 Modal from '@/components/UI/Modal.vue';
|
||||
import Button from '@/components/UI/Button.vue';
|
||||
import Events from '@/utils/Events';
|
||||
|
||||
const defaults = {
|
||||
title: "Confirmation",
|
||||
icon: "fa-solid fa-triangle-exclamation",
|
||||
message: "Es-tu sûr de vouloir continuer ?",
|
||||
confirmLabel: "Confirmer",
|
||||
confirmIcon: "fa-solid fa-check",
|
||||
requireText: null,
|
||||
askReason: false,
|
||||
reasonLabel: "Raison",
|
||||
reasonPlaceholder: "Explique la raison de cette action",
|
||||
reasonMin: 0,
|
||||
reasonMax: 200,
|
||||
checkbox: null,
|
||||
checkboxDefault: false
|
||||
};
|
||||
|
||||
const modal = ref(null);
|
||||
const options = ref({ ...defaults });
|
||||
const typed = ref("");
|
||||
const reason = ref("");
|
||||
const checked = ref(false);
|
||||
let pending = null;
|
||||
|
||||
const canConfirm = computed(() => {
|
||||
if (options.value.requireText && typed.value.trim() !== options.value.requireText.trim()) return false;
|
||||
if (options.value.askReason) {
|
||||
const length = reason.value.trim().length;
|
||||
if (length < options.value.reasonMin || length > options.value.reasonMax) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function settle(value) {
|
||||
const resolve = pending;
|
||||
pending = null;
|
||||
if (resolve) resolve(value);
|
||||
}
|
||||
|
||||
function ask(opts = {}) {
|
||||
settle(null);
|
||||
options.value = { ...defaults, ...opts };
|
||||
typed.value = "";
|
||||
reason.value = "";
|
||||
checked.value = !!options.value.checkboxDefault;
|
||||
modal.value?.open();
|
||||
return new Promise((resolve) => { pending = resolve; });
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (!canConfirm.value) return;
|
||||
const result = { reason: reason.value.trim(), checked: checked.value };
|
||||
settle(result);
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
settle(null);
|
||||
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).
|
||||
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);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.confirm-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.confirm-field textarea {
|
||||
min-height: 8vh;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-size: 1em !important;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.confirm-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
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>
|
||||
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<DefaultSplash v-if="!isAdmin">
|
||||
<h1 class="denied-title"><Icon icon="fa-solid fa-lock"/> 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>
|
||||
</DefaultSplash>
|
||||
<div v-else class="admin-container">
|
||||
<div class="left-side">
|
||||
<Box padding="closed" class="nav-box">
|
||||
<SubsonicsLogo class="logo"/>
|
||||
<p class="admin-label"><Icon icon="fa-solid fa-star"/> Administration</p>
|
||||
<nav class="admin-nav">
|
||||
<button v-for="item in sections" :key="item.id"
|
||||
:class="['nav-item', { active: adminStore.section === item.id }]"
|
||||
@click="adminStore.setSection(item.id)">
|
||||
<Icon class="nav-icon" :icon="item.icon"/>
|
||||
<span>{{ item.label }}</span>
|
||||
<span v-if="badges[item.id]" :class="['nav-badge', badges[item.id].type]">{{ badges[item.id].text }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
<Button class="back" icon="fa-solid fa-arrow-left" @click="router.push('/servers')">Retour aux serveurs</Button>
|
||||
</Box>
|
||||
<Account class="account"/>
|
||||
</div>
|
||||
<Box class="main" padding="closed">
|
||||
<component :is="current.component" :key="current.id"/>
|
||||
</Box>
|
||||
<AdminToasts/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
/**
|
||||
* Tableau de bord d'administration : navigation par sections + 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.
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, 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 Error from '@/components/UI/Error.vue';
|
||||
import DefaultSplash from '@/components/UI/DefaultSplash.vue';
|
||||
import SubsonicsLogo from '@/components/UI/SubsonicsLogo.vue';
|
||||
import Account from '@/components/Layout/Account.vue';
|
||||
import AdminToasts from './AdminToasts.vue';
|
||||
import AdminOverview from './AdminOverview.vue';
|
||||
import AdminPlayers from './AdminPlayers.vue';
|
||||
import AdminServers from './AdminServers.vue';
|
||||
import AdminUsers from './AdminUsers.vue';
|
||||
import AdminLogs from './AdminLogs.vue';
|
||||
import AdminWarp from './AdminWarp.vue';
|
||||
import AdminYoutube from './AdminYoutube.vue';
|
||||
import AdminMetrics from './AdminMetrics.vue';
|
||||
import AdminMaintenance from './AdminMaintenance.vue';
|
||||
import AdminPlaylists from './AdminPlaylists.vue';
|
||||
import AdminConfig from './AdminConfig.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
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 current = computed(() => sections.find(s => s.id === adminStore.section) || sections[0]);
|
||||
|
||||
// Petites pastilles d'état dans la navigation
|
||||
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 warp = adminStore.warp || adminStore.overview?.warp;
|
||||
if (warp?.configured) result.warp = warp.healthy ? { text: "OK", type: 'good' } : { text: "KO", type: 'bad' };
|
||||
// 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;
|
||||
const failureRate = yt ? yt.diagnostics?.recentFailureRate : adminStore.overview?.ytdlp?.recentFailureRate;
|
||||
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' };
|
||||
const online = adminStore.presence?.counts?.online ?? adminStore.overview?.web?.usersOnline;
|
||||
if (online) result.users = { text: online, type: 'neutral' };
|
||||
// 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' };
|
||||
return result;
|
||||
});
|
||||
|
||||
function start() {
|
||||
adminStore.attach();
|
||||
adminStore.fetchOverview();
|
||||
adminStore.fetchPlayers();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.title = "Administration - Subsonics";
|
||||
if (isAdmin.value) start();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
adminStore.detach();
|
||||
});
|
||||
|
||||
// Droit retiré ou accordé en direct (/USER/READY recharge userInfo)
|
||||
watch(isAdmin, (value) => {
|
||||
if (value) start();
|
||||
else adminStore.detach();
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.admin-container {
|
||||
padding: 15px;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 0.7fr) 3fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.left-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nav-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.logo :deep(h1) {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.logo :deep(.img) {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.admin-label {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: var(--admin-color);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background-color: transparent;
|
||||
color: var(--text);
|
||||
font-size: 0.95em;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: var(--tertiary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background-color: var(--main);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.7em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 20px;
|
||||
background-color: var(--quaternary);
|
||||
color: #FFFFFF;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.nav-badge.good {
|
||||
background-color: color-mix(in srgb, var(--mod-color) 70%, black);
|
||||
}
|
||||
|
||||
.nav-badge.warn {
|
||||
background-color: var(--text-warning);
|
||||
}
|
||||
|
||||
.nav-badge.bad {
|
||||
background-color: var(--text-error);
|
||||
}
|
||||
|
||||
.back {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 15px !important;
|
||||
}
|
||||
|
||||
.denied-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px), screen and (max-height: 607px) {
|
||||
.admin-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.left-side {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.nav-box {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
border-radius: 20px;
|
||||
background-color: var(--tertiary);
|
||||
}
|
||||
|
||||
.account {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="kv">
|
||||
<span class="kv-label">{{ label }}</span>
|
||||
<span :class="{ 'kv-value': true, mono }"><slot>{{ value }}</slot></span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
// Ligne « libellé : valeur » des cartes d'administration
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
value: {
|
||||
default: null
|
||||
},
|
||||
mono: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
</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,698 @@
|
||||
<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>
|
||||
</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>
|
||||
</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()">
|
||||
{{ live ? "Arrêter le direct" : "Temps réel" }}
|
||||
</Button>
|
||||
<Button :disabled="!selected || downloading" icon="fa-solid fa-download" @click="download()">
|
||||
{{ downloading ? "Téléchargement…" : "Télécharger" }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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()">
|
||||
{{ 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)]">
|
||||
<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>
|
||||
</template>
|
||||
<span v-else class="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>
|
||||
</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>
|
||||
|
||||
<AdminConfirm ref="confirmRef"/>
|
||||
</AdminSection>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, 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 AdminPills from './AdminPills.vue';
|
||||
import AdminConfirm from './AdminConfirm.vue';
|
||||
import { formatBytes, formatRelative } from './adminFormat';
|
||||
|
||||
const CHUNK = 262144; // 256 Kio par lecture
|
||||
const MAX_LINES = 5000; // Lignes gardées en mémoire
|
||||
const ALL_LEVELS = ["INFO", "WARN", "ERROR", "FATAL"];
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const confirmRef = ref(null);
|
||||
const consoleRef = ref(null);
|
||||
|
||||
// ----- Fichiers -----
|
||||
const files = ref([]);
|
||||
const filesLoading = ref(false);
|
||||
const filesError = ref(null);
|
||||
const selected = ref(null);
|
||||
|
||||
const isCurrent = computed(() => !!files.value.find(f => f.name === selected.value)?.current);
|
||||
|
||||
async function loadFiles() {
|
||||
if (filesLoading.value) return;
|
||||
filesLoading.value = true;
|
||||
try {
|
||||
const list = await adminRequest("/ADMIN/LOGS/LIST");
|
||||
files.value = Array.isArray(list) ? list : [];
|
||||
filesError.value = null;
|
||||
if (!selected.value || !files.value.find(f => f.name === selected.value)) {
|
||||
const first = files.value.find(f => f.current) || files.value[0];
|
||||
if (first) select(first.name);
|
||||
}
|
||||
} catch (e) {
|
||||
filesError.value = describeError(e);
|
||||
} finally {
|
||||
filesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFile(file) {
|
||||
const ok = await confirmRef.value.ask({
|
||||
title: "Supprimer un fichier de logs",
|
||||
icon: "fa-solid fa-trash-can",
|
||||
message: `Le fichier « ${file.name} » (${formatBytes(file.size)}) sera définitivement supprimé.`,
|
||||
confirmLabel: "Supprimer",
|
||||
confirmIcon: "fa-solid fa-trash-can"
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await adminRequest("/ADMIN/LOGS/DELETE", { name: file.name });
|
||||
adminStore.notify('success', `Fichier ${file.name} supprimé`);
|
||||
if (selected.value === file.name) selected.value = null;
|
||||
await loadFiles();
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, "Suppression du fichier");
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Lecture -----
|
||||
const lines = ref([]);
|
||||
const linesLoading = ref(false);
|
||||
const linesError = ref(null);
|
||||
const olderLoading = ref(false);
|
||||
const startOffset = ref(0);
|
||||
const truncated = ref(false);
|
||||
const downloading = ref(false);
|
||||
|
||||
const levels = ref([...ALL_LEVELS]);
|
||||
const typeFilter = ref("");
|
||||
const searchFilter = ref("");
|
||||
|
||||
const levelOptions = [
|
||||
{ value: "INFO", label: "INFO", color: "var(--admin-color)" },
|
||||
{ value: "WARN", label: "WARN", color: "var(--text-warning)" },
|
||||
{ value: "ERROR", label: "ERROR", color: "var(--text-error)" },
|
||||
{ value: "FATAL", label: "FATAL", color: "var(--main-active)" }
|
||||
];
|
||||
|
||||
let keySeed = 0;
|
||||
function withKeys(list) {
|
||||
return (Array.isArray(list) ? list : []).map(line => ({ ...line, key: ++keySeed }));
|
||||
}
|
||||
|
||||
// Filtres envoyés au serveur (omis s'ils ne restreignent rien)
|
||||
function filterParams() {
|
||||
const params = {};
|
||||
if (levels.value.length && levels.value.length < ALL_LEVELS.length) params.levels = [...levels.value];
|
||||
if (typeFilter.value.trim()) params.type = typeFilter.value.trim();
|
||||
if (searchFilter.value.trim()) params.search = searchFilter.value.trim();
|
||||
return params;
|
||||
}
|
||||
|
||||
// Mêmes filtres que le serveur (LogReader.filterLines) pour les lignes reçues en direct
|
||||
function matches(line) {
|
||||
// Aucune case cochée = aucun filtre de niveau (même règle que filterParams)
|
||||
if (levels.value.length && levels.value.length < ALL_LEVELS.length && !(line.level && levels.value.includes(line.level))) return false;
|
||||
// Type : égalité exacte, sans tenir compte de la casse (comme le serveur)
|
||||
const type = typeFilter.value.trim().toLowerCase();
|
||||
if (type && (line.type || "").toLowerCase() !== type) return false;
|
||||
const search = searchFilter.value.trim().toLowerCase();
|
||||
if (search && !(line.raw || line.message || "").toLowerCase().includes(search)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const visibleLines = computed(() => lines.value.filter(matches));
|
||||
|
||||
const interrupted = computed(() => {
|
||||
if (isCurrent.value && adminStore.overview?.logs?.interrupted) return true;
|
||||
const last = lines.value[lines.value.length - 1];
|
||||
return !!(isCurrent.value && last && (last.raw || "").includes("END OF LOGS"));
|
||||
});
|
||||
|
||||
function levelClass(level) {
|
||||
return level ? "level-" + level.toLowerCase() : "level-none";
|
||||
}
|
||||
|
||||
function trim() {
|
||||
if (lines.value.length > MAX_LINES) {
|
||||
lines.value = lines.value.slice(lines.value.length - MAX_LINES);
|
||||
truncated.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Début réel (première ligne complète) d'une lecture : le serveur renvoie offset = octet demandé
|
||||
function lineStartOf(result) {
|
||||
const first = Number(result?.firstLineOffset);
|
||||
if (Number.isFinite(first)) return first;
|
||||
return Number(result?.offset) || 0;
|
||||
}
|
||||
|
||||
// Ajoute des lignes reçues autour d'une relecture en écartant celles déjà présentes (même texte brut)
|
||||
function appendUnique(incoming) {
|
||||
if (!Array.isArray(incoming) || !incoming.length) return;
|
||||
const recent = new Set(lines.value.slice(-1000).map(line => line.raw));
|
||||
const fresh = incoming.filter(line => !recent.has(line.raw));
|
||||
if (!fresh.length) return;
|
||||
lines.value = lines.value.concat(withKeys(fresh));
|
||||
trim();
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
// Lignes en direct arrivées pendant une relecture : mises de côté puis fusionnées (sinon écrasées)
|
||||
let pendingLive = null;
|
||||
|
||||
let readToken = 0;
|
||||
async function reload() {
|
||||
if (!selected.value) return;
|
||||
const token = ++readToken;
|
||||
linesLoading.value = true;
|
||||
linesError.value = null;
|
||||
pendingLive = [];
|
||||
try {
|
||||
const result = await adminRequest("/ADMIN/LOGS/READ", { name: selected.value, limit: CHUNK, ...filterParams() });
|
||||
if (token !== readToken) return;
|
||||
lines.value = withKeys(result?.lines);
|
||||
startOffset.value = lineStartOf(result);
|
||||
truncated.value = false;
|
||||
follow.value = true;
|
||||
scrollToBottom(true);
|
||||
} catch (e) {
|
||||
if (token !== readToken) return;
|
||||
lines.value = [];
|
||||
linesError.value = describeError(e);
|
||||
} finally {
|
||||
if (token === readToken) {
|
||||
linesLoading.value = false;
|
||||
const waiting = pendingLive;
|
||||
pendingLive = null;
|
||||
appendUnique(waiting);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remonte d'un bloc avant le début actuel, en conservant la position de lecture
|
||||
async function loadOlder() {
|
||||
if (olderLoading.value || startOffset.value <= 0) return;
|
||||
olderLoading.value = true;
|
||||
const token = readToken;
|
||||
const from = Math.max(0, startOffset.value - CHUNK);
|
||||
try {
|
||||
const result = await adminRequest("/ADMIN/LOGS/READ", { name: selected.value, offset: from, limit: startOffset.value - from, ...filterParams() });
|
||||
if (token !== readToken) return;
|
||||
const element = consoleRef.value;
|
||||
const previousHeight = element?.scrollHeight || 0;
|
||||
lines.value = withKeys(result?.lines).concat(lines.value);
|
||||
// Début de la première ligne complète lue (firstLineOffset) : la ligne à cheval sur la limite
|
||||
// du bloc sera relue entière au prochain appel. Sans progression (ligne > bloc), on repart de
|
||||
// l'offset demandé pour ne jamais boucler
|
||||
const effective = lineStartOf(result);
|
||||
startOffset.value = effective < startOffset.value ? effective : from;
|
||||
await nextTick();
|
||||
if (element) element.scrollTop += element.scrollHeight - previousHeight;
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, "Lecture des logs");
|
||||
} finally {
|
||||
olderLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function select(name) {
|
||||
if (selected.value === name) return;
|
||||
stopLive();
|
||||
selected.value = name;
|
||||
lines.value = [];
|
||||
startOffset.value = 0;
|
||||
reload().then(() => {
|
||||
// Le fichier courant passe directement en direct
|
||||
if (isCurrent.value) startLive();
|
||||
});
|
||||
}
|
||||
|
||||
// Filtres modifiés : relecture depuis la fin (avec un délai pour la saisie)
|
||||
let filterTimer = null;
|
||||
watch([levels, typeFilter, searchFilter], () => {
|
||||
clearTimeout(filterTimer);
|
||||
filterTimer = setTimeout(() => reload(), 400);
|
||||
});
|
||||
|
||||
// ----- Téléchargement complet du fichier -----
|
||||
async function download() {
|
||||
if (!selected.value || downloading.value) return;
|
||||
downloading.value = true;
|
||||
try {
|
||||
const name = selected.value;
|
||||
const chunks = [];
|
||||
let offset = 0;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const result = await adminRequest("/ADMIN/LOGS/READ", { name, offset, limit: 1048576 }, 30000);
|
||||
chunks.push(...(result?.lines || []).map(l => l.raw ?? l.message ?? ""));
|
||||
const next = Number(result?.nextOffset);
|
||||
if (result?.eof || !Number.isFinite(next) || next <= offset || next >= Number(result?.size)) break;
|
||||
offset = next;
|
||||
}
|
||||
const blob = new Blob([chunks.join("\n")], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, "Téléchargement");
|
||||
} finally {
|
||||
downloading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Défilement / suivi automatique -----
|
||||
const follow = ref(true);
|
||||
|
||||
function onScroll() {
|
||||
const element = consoleRef.value;
|
||||
if (!element) return;
|
||||
follow.value = element.scrollHeight - element.scrollTop - element.clientHeight < 30;
|
||||
}
|
||||
|
||||
async function scrollToBottom(force = false) {
|
||||
if (!force && !follow.value) return;
|
||||
follow.value = true;
|
||||
await nextTick();
|
||||
const element = consoleRef.value;
|
||||
if (element) element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
|
||||
// ----- Temps réel -----
|
||||
const live = ref(false);
|
||||
let liveFile = null;
|
||||
|
||||
async function startLive() {
|
||||
if (live.value || !selected.value) return;
|
||||
try {
|
||||
const result = await adminRequest("/ADMIN/LOGS/SUBSCRIBE");
|
||||
liveFile = result?.name || selected.value;
|
||||
live.value = true;
|
||||
// Tampon du serveur (dernières lignes) : couvre celles écrites entre la lecture et l'abonnement
|
||||
if (liveFile === selected.value && Array.isArray(result?.lines)) {
|
||||
if (pendingLive) pendingLive.push(...result.lines);
|
||||
else appendUnique(result.lines);
|
||||
}
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, "Logs en direct");
|
||||
}
|
||||
}
|
||||
|
||||
function stopLive() {
|
||||
if (!live.value) return;
|
||||
live.value = false;
|
||||
liveFile = null;
|
||||
// Inutile de prévenir le serveur si le socket est tombé : les rooms sont déjà perdues
|
||||
if (socket.connected) adminRequest("/ADMIN/LOGS/UNSUBSCRIBE").catch(() => {});
|
||||
}
|
||||
|
||||
function toggleLive() {
|
||||
if (live.value) stopLive();
|
||||
else {
|
||||
// Abonnement pendant la relecture : les lignes reçues entre-temps sont fusionnées sans doublon
|
||||
reload();
|
||||
startLive();
|
||||
}
|
||||
}
|
||||
|
||||
function onAppend(payload) {
|
||||
if (!live.value || !payload || !Array.isArray(payload.lines)) return;
|
||||
if (payload.name && payload.name !== selected.value && payload.name !== liveFile) return;
|
||||
// Relecture en cours : fusion à son retour
|
||||
if (pendingLive) {
|
||||
pendingLive.push(...payload.lines);
|
||||
return;
|
||||
}
|
||||
lines.value = lines.value.concat(withKeys(payload.lines));
|
||||
trim();
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
let offAppend = null;
|
||||
|
||||
onMounted(() => {
|
||||
offAppend = adminListen("/ADMIN/LOGS/APPEND", onAppend);
|
||||
loadFiles();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
offAppend?.();
|
||||
clearTimeout(filterTimer);
|
||||
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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.files-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
max-height: 65vh;
|
||||
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 {
|
||||
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;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.follow:hover {
|
||||
background-color: var(--main-hover);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--text-success);
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
font-size: 0.7em;
|
||||
animation: blink 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px), screen and (max-height: 607px) {
|
||||
.logs-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.files-list {
|
||||
max-height: 25vh;
|
||||
}
|
||||
|
||||
.toolbar-inputs {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.console {
|
||||
height: 55vh;
|
||||
}
|
||||
|
||||
.line {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,232 @@
|
||||
<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()">
|
||||
{{ announcing ? "Envoi…" : "Diffuser" }}
|
||||
</Button>
|
||||
</div>
|
||||
</AdminCard>
|
||||
|
||||
<AdminCard title="Redémarrage" icon="fa-solid fa-power-off" color="var(--text-error)">
|
||||
<p class="hint">
|
||||
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()">
|
||||
{{ restarting ? "Redémarrage…" : "Redémarrer Subsonics" }}
|
||||
</Button>
|
||||
</div>
|
||||
<Success v-if="restartAt">Redémarrage demandé {{ formatRelative(restartAt, nowDate) }}, reconnexion en attente…</Success>
|
||||
</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()"/>
|
||||
</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>
|
||||
</AdminState>
|
||||
</AdminCard>
|
||||
</div>
|
||||
|
||||
<AdminConfirm ref="confirmRef"/>
|
||||
</AdminSection>
|
||||
</template>
|
||||
<script setup>
|
||||
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 AdminCard from './AdminCard.vue';
|
||||
import AdminPills from './AdminPills.vue';
|
||||
import AdminState from './AdminState.vue';
|
||||
import AdminConfirm from './AdminConfirm.vue';
|
||||
import { safeNum, formatBytes, formatRelative } from './adminFormat';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const confirmRef = ref(null);
|
||||
|
||||
// ----- Annonce -----
|
||||
const message = ref("");
|
||||
const level = ref("info");
|
||||
const announcing = ref(false);
|
||||
const levels = [
|
||||
{ value: "info", label: "Information", icon: "fa-solid fa-circle-info", color: "var(--admin-color)" },
|
||||
{ value: "warn", label: "Avertissement", icon: "fa-solid fa-triangle-exclamation", color: "var(--text-warning)" }
|
||||
];
|
||||
|
||||
const canAnnounce = computed(() => {
|
||||
const length = message.value.trim().length;
|
||||
return length >= 1 && length <= 500;
|
||||
});
|
||||
|
||||
async function announce() {
|
||||
if (!canAnnounce.value) return;
|
||||
announcing.value = true;
|
||||
try {
|
||||
await adminRequest("/ADMIN/MAINTENANCE/ANNOUNCE", { message: message.value.trim(), level: level.value });
|
||||
adminStore.notify('success', "Annonce diffusée à tous les utilisateurs");
|
||||
message.value = "";
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, "Annonce");
|
||||
} finally {
|
||||
announcing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Redémarrage -----
|
||||
const reason = ref("");
|
||||
const restarting = ref(false);
|
||||
const restartAt = ref(null);
|
||||
|
||||
const canRestart = computed(() => {
|
||||
const length = reason.value.trim().length;
|
||||
return length >= 3 && length <= 200;
|
||||
});
|
||||
|
||||
async function restart() {
|
||||
if (!canRestart.value) return;
|
||||
const ok = await confirmRef.value.ask({
|
||||
title: "Redémarrer Subsonics",
|
||||
icon: "fa-solid fa-power-off",
|
||||
message: `Tous les players vont quitter leur salon et la musique s'arrêtera sur tous les serveurs.\nRaison : ${reason.value.trim()}`,
|
||||
requireText: "REDEMARRER",
|
||||
confirmLabel: "Redémarrer",
|
||||
confirmIcon: "fa-solid fa-power-off"
|
||||
});
|
||||
if (!ok) return;
|
||||
restarting.value = true;
|
||||
try {
|
||||
const result = await adminRequest("/ADMIN/MAINTENANCE/RESTART", { reason: reason.value.trim() });
|
||||
restartAt.value = result?.at || Date.now();
|
||||
adminStore.notify('info', "Redémarrage en cours, l'interface va se reconnecter automatiquement", 8000);
|
||||
reason.value = "";
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, "Redémarrage");
|
||||
} finally {
|
||||
restarting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Bases de données -----
|
||||
const databases = ref(null);
|
||||
const dbLoading = ref(false);
|
||||
const dbError = ref(null);
|
||||
|
||||
async function loadDatabases() {
|
||||
if (dbLoading.value) return;
|
||||
dbLoading.value = true;
|
||||
try {
|
||||
const list = await adminRequest("/ADMIN/DATABASES");
|
||||
databases.value = Array.isArray(list) ? list : [];
|
||||
dbError.value = null;
|
||||
} catch (e) {
|
||||
dbError.value = describeError(e);
|
||||
} finally {
|
||||
dbLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const nowDate = ref(Date.now());
|
||||
let timer = null;
|
||||
|
||||
onMounted(() => {
|
||||
loadDatabases();
|
||||
timer = setInterval(() => { nowDate.value = Date.now(); }, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => clearInterval(timer));
|
||||
</script>
|
||||
<style scoped>
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
</AdminState>
|
||||
|
||||
<AdminConfirm ref="confirmRef"/>
|
||||
</AdminSection>
|
||||
</template>
|
||||
<script setup>
|
||||
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 AdminState from './AdminState.vue';
|
||||
import AdminStat from './AdminStat.vue';
|
||||
import AdminConfirm from './AdminConfirm.vue';
|
||||
import { safeNum, formatUptime } from './adminFormat';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const confirmRef = ref(null);
|
||||
|
||||
const data = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const repairing = ref(false);
|
||||
const tab = ref(0);
|
||||
const rawSearch = ref("");
|
||||
|
||||
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 invalidCount = computed(() => (data.value?.raw || []).filter(m => m.invalid).length);
|
||||
|
||||
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));
|
||||
if (!query) return list;
|
||||
return list.filter(m => m.name.toLowerCase().includes(query) || (m.description || "").toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
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";
|
||||
return "fa-solid fa-chart-bar";
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await adminRequest("/ADMIN/METRICS");
|
||||
error.value = null;
|
||||
} catch (e) {
|
||||
error.value = describeError(e);
|
||||
if (data.value) adminStore.notifyError(e, "Métriques");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function repair() {
|
||||
repairing.value = true;
|
||||
try {
|
||||
const result = await adminRequest("/ADMIN/METRICS/REPAIR");
|
||||
adminStore.notify('success', `${safeNum(result?.repaired)} valeur(s) réparée(s)`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, "Réparation des métriques");
|
||||
} finally {
|
||||
repairing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMetric(metric) {
|
||||
const ok = await confirmRef.value.ask({
|
||||
title: "Supprimer une métrique",
|
||||
icon: "fa-solid fa-trash-can",
|
||||
message: `La métrique « ${metric.name} » sera définitivement supprimée.`,
|
||||
confirmLabel: "Supprimer",
|
||||
confirmIcon: "fa-solid fa-trash-can"
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await adminRequest("/ADMIN/METRICS/DELETE", { name: metric.name });
|
||||
adminStore.notify('success', `Métrique ${metric.name} supprimée`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, "Suppression de la métrique");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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 {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.7em;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
background-color: var(--secondary);
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-values {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px), screen and (max-height: 607px) {
|
||||
.metric {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-values {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<div class="stats">
|
||||
<AdminStat label="Disponibilité" :value="formatUptime(uptime)" icon="fa-solid fa-clock" color="var(--text-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)"
|
||||
: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)"
|
||||
:sub="safeNum(data.discord?.voiceConnections) + ' connexion(s) vocale(s)'"/>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<AdminCard title="Application" icon="fa-solid fa-cube" color="var(--main)">
|
||||
<AdminKv label="Nom" :value="data.app?.name"/>
|
||||
<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>
|
||||
</AdminKv>
|
||||
</AdminCard>
|
||||
|
||||
<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)">
|
||||
<AdminKv label="Inscrits" mono :value="safeNum(data.users?.registered)"/>
|
||||
<AdminKv label="Administrateurs" mono :value="safeNum(data.users?.admins)"/>
|
||||
<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>
|
||||
</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)">
|
||||
<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>
|
||||
<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>
|
||||
</AdminKv>
|
||||
</AdminCard>
|
||||
</div>
|
||||
</AdminState>
|
||||
</AdminSection>
|
||||
</template>
|
||||
<script setup>
|
||||
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 AdminCard from './AdminCard.vue';
|
||||
import AdminKv from './AdminKv.vue';
|
||||
import AdminStat from './AdminStat.vue';
|
||||
import { safeNum, formatBytes, formatUptime, formatDate, formatRelative, youtubeAccount, youtubeClass } from './adminFormat';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const data = computed(() => adminStore.overview);
|
||||
const ytAccount = computed(() => youtubeAccount(data.value?.ytdlp?.cookies));
|
||||
|
||||
// Horloge locale : la disponibilité avance entre deux poussées (sans dérive : recalculée depuis la réception)
|
||||
const nowDate = ref(Date.now());
|
||||
const receivedAt = ref(Date.now());
|
||||
let timer = null;
|
||||
|
||||
// Recalage à chaque poussée (l'uptime serveur évite tout décalage d'horloge client/serveur)
|
||||
watch(() => data.value?.app?.uptime, () => { receivedAt.value = Date.now(); });
|
||||
|
||||
const uptime = computed(() => safeNum(data.value?.app?.uptime) + Math.max(0, nowDate.value - receivedAt.value) / 1000);
|
||||
|
||||
const usedMem = computed(() => safeNum(data.value?.system?.totalmem) - safeNum(data.value?.system?.freemem));
|
||||
|
||||
function percent(value, total) {
|
||||
const t = safeNum(total);
|
||||
if (t <= 0) return 0;
|
||||
return Math.min(100, Math.max(0, safeNum(value) / t * 100));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
adminStore.fetchOverview();
|
||||
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(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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bot-line p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bot-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 100%;
|
||||
background-color: var(--main);
|
||||
}
|
||||
|
||||
.bot-tag {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bot-id {
|
||||
font-size: 0.7em;
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<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 } : {}"
|
||||
@click="toggle(option.value)">
|
||||
<Icon v-if="option.icon" :icon="option.icon"/>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
// Filtres en pilule (choix unique, ou multiple avec la prop `multiple`)
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
default: null
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
function isSelected(value) {
|
||||
return props.multiple ? (props.modelValue || []).includes(value) : props.modelValue === value;
|
||||
}
|
||||
|
||||
function toggle(value) {
|
||||
if (!props.multiple) return emit('update:modelValue', value);
|
||||
const current = [...(props.modelValue || [])];
|
||||
const index = current.indexOf(value);
|
||||
if (index === -1) current.push(value); else current.splice(index, 1);
|
||||
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>
|
||||
@@ -0,0 +1,410 @@
|
||||
<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>
|
||||
<Tag :color="status.color" :icon="status.icon">{{ status.label }}</Tag>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="title secondary">Aucun titre en cours</p>
|
||||
<p class="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">
|
||||
<span>{{ formatClock(positionMs) }}</span>
|
||||
<span v-if="isLive"><Tag color="var(--text-error)">LIVE</Tag></span>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<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) }}
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</Box>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, ref } 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 { safeNum, formatClock, formatRelative, playerStatus } from './adminFormat';
|
||||
|
||||
const props = defineProps({
|
||||
player: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
// performance.now() partagé, rafraîchi par AdminPlayers
|
||||
now: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['leave']);
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const busy = ref(false);
|
||||
const showQueue = ref(false);
|
||||
|
||||
const status = computed(() => playerStatus(props.player.status || (props.player.playing ? 'playing' : props.player.paused ? 'paused' : 'idle')));
|
||||
|
||||
const durationMs = computed(() => {
|
||||
if (props.player.durationMs !== undefined && props.player.durationMs !== null) return safeNum(props.player.durationMs);
|
||||
const seconds = safeNum(props.player.current?.duration);
|
||||
return seconds > 0 ? seconds * 1000 : null;
|
||||
});
|
||||
|
||||
const isLive = computed(() => !durationMs.value);
|
||||
|
||||
// Position extrapolée : ancre {ms, rate, t0} posée à la réception, bornée à la durée
|
||||
const positionMs = computed(() => {
|
||||
const anchor = props.player.anchor;
|
||||
if (!anchor) return 0;
|
||||
const value = anchor.ms + anchor.rate * (props.now - anchor.t0);
|
||||
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 route = computed(() => props.player.ytdlp?.route || null);
|
||||
const nextCount = computed(() => safeNum(props.player.nextCount, props.player.next?.length || 0));
|
||||
|
||||
// value : état voulu pour loop / shuffle (envoyé explicitement, sans bascule côté serveur)
|
||||
async function act(action, value) {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
await adminStore.playerAction(props.player.guildId, action, value);
|
||||
busy.value = false;
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.player-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.guild {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.guild-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.guild-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.guild-name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.guild-id {
|
||||
font-size: 0.7em;
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background-color: var(--secondary);
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 30%;
|
||||
min-width: 90px;
|
||||
aspect-ratio: 16/9;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
background-color: var(--quaternary);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.current-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.title.secondary {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.author {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: monospace;
|
||||
font-size: 0.8em;
|
||||
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);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.last-error {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.last-error.stale {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
display: 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;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.queue-toggle:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-top: 5px;
|
||||
max-height: 25vh;
|
||||
overflow-y: auto;
|
||||
animation: unfold 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background-color: var(--secondary);
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.queue-index {
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.queue-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<AdminSection title="🎵 Players" subtitle="Tous les players connectés, en temps réel">
|
||||
<template #actions>
|
||||
<AdminPills v-model="filter" :options="filters"/>
|
||||
<Button icon="fa-solid fa-rotate" :disabled="adminStore.playersLoading" @click="adminStore.fetchPlayers()">Actualiser</Button>
|
||||
</template>
|
||||
|
||||
<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 !'"
|
||||
@retry="adminStore.fetchPlayers()">
|
||||
<div class="players-grid">
|
||||
<AdminPlayerCard v-for="player in visible" :key="player.guildId" :player="player" :now="now" @leave="leave"/>
|
||||
</div>
|
||||
</AdminState>
|
||||
|
||||
<AdminConfirm ref="confirmRef"/>
|
||||
</AdminSection>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useAdminStore } from '@/stores/adminStore';
|
||||
import Button from '@/components/UI/Button.vue';
|
||||
import AdminSection from './AdminSection.vue';
|
||||
import AdminState from './AdminState.vue';
|
||||
import AdminPills from './AdminPills.vue';
|
||||
import AdminConfirm from './AdminConfirm.vue';
|
||||
import AdminPlayerCard from './AdminPlayerCard.vue';
|
||||
|
||||
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 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;
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
async function leave(player) {
|
||||
const result = await confirmRef.value.ask({
|
||||
title: "Faire quitter le salon",
|
||||
icon: "fa-solid fa-right-from-bracket",
|
||||
message: `Subsonics va quitter le salon vocal de « ${player.guildName || player.guildId} ». La file d'attente sera perdue.`,
|
||||
confirmLabel: "Quitter le salon",
|
||||
confirmIcon: "fa-solid fa-right-from-bracket"
|
||||
});
|
||||
if (!result) return;
|
||||
if (await adminStore.playerAction(player.guildId, 'leave')) {
|
||||
adminStore.notify('success', `Subsonics a quitté le salon de ${player.guildName || player.guildId}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
adminStore.fetchPlayers();
|
||||
timer = setInterval(() => { now.value = performance.now(); }, 500);
|
||||
});
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<AdminSection title="📚 Playlists" :subtitle="subtitle">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<AdminPills v-model="filter" :options="filterOptions"/>
|
||||
|
||||
<AdminState :loading="!owners && !error" :error="!owners ? error : null"
|
||||
:empty="!!owners && filtered.length === 0"
|
||||
:empty-text="search || filter !== 'all' ? 'Aucune playlist ne correspond à ces critères' : 'Aucune playlist enregistrée'"
|
||||
@retry="load()">
|
||||
<div class="list">
|
||||
<div v-for="row in shown" :key="row.key" class="row">
|
||||
<div class="main-info">
|
||||
<div class="cover">
|
||||
<img v-if="row.thumbnail && !brokenCovers.has(row.key)" :src="row.thumbnail" alt="" loading="lazy" referrerpolicy="no-referrer" @error="markBroken(row.key)"/>
|
||||
<Icon v-else :icon="typeInfo(row).icon" :style="{ color: typeInfo(row).color }"/>
|
||||
</div>
|
||||
<div class="text">
|
||||
<p class="title" :title="row.title">{{ row.title || "Sans titre" }}</p>
|
||||
<div class="tags">
|
||||
<Tag :color="typeInfo(row).color" :icon="typeInfo(row).icon">{{ typeInfo(row).label }}</Tag>
|
||||
<Tag v-if="row.source === 'google'" color="var(--text-error)" icon="fa-brands fa-google">
|
||||
Synchronisée<span v-if="row.syncedAt"> {{ formatRelative(row.syncedAt, nowDate) }}</span>
|
||||
</Tag>
|
||||
<span class="meta"><Icon icon="fa-solid fa-music"/> {{ safeNum(row.songCount) }} titre(s)</span>
|
||||
<span v-if="safeNum(row.duration)" class="meta"><Icon icon="fa-solid fa-clock"/> {{ formatUptime(row.duration) }}</span>
|
||||
<span class="meta mono">#{{ row.playlistId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side">
|
||||
<AdminUserLine :user="row.owner" :online="adminStore.onlineIds.has(row.userId)" size="32px"/>
|
||||
<a v-if="safeUrl(row.url)" class="link" :href="row.url" target="_blank" rel="noopener noreferrer" title="Ouvrir la source">
|
||||
<Icon icon="fa-solid fa-arrow-up-right-from-square"/>
|
||||
</a>
|
||||
<IconAction title="Supprimer" icon="fa-solid fa-trash" color="var(--text-error)" @click="remove(row)"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filtered.length > shown.length" class="more">
|
||||
<Button color-lower icon="fa-solid fa-chevron-down" @click="limit += PAGE_SIZE">
|
||||
Afficher plus ({{ filtered.length - shown.length }} restante(s))
|
||||
</Button>
|
||||
</div>
|
||||
</AdminState>
|
||||
|
||||
<AdminConfirm ref="confirmRef"/>
|
||||
</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}.
|
||||
*/
|
||||
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 AdminPills from './AdminPills.vue';
|
||||
import AdminUserLine from './AdminUserLine.vue';
|
||||
import AdminConfirm from './AdminConfirm.vue';
|
||||
import { safeNum, formatUptime, formatRelative } from './adminFormat';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const confirmRef = ref(null);
|
||||
|
||||
const owners = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const search = ref("");
|
||||
const filter = ref('all');
|
||||
const limit = ref(PAGE_SIZE);
|
||||
|
||||
// Type affiché : "playlist" (ou sans type) = playlist créée à la main sur Subsonics
|
||||
function kind(row) {
|
||||
if (row.source === 'google') return 'google';
|
||||
if (row.type === 'youtube' || row.type === 'spotify' || row.type === 'soundcloud') return row.type;
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
const TYPES = {
|
||||
youtube: { label: "YouTube", icon: "fa-brands fa-youtube", color: "var(--text-error)" },
|
||||
google: { label: "YouTube (compte Google)", icon: "fa-brands fa-youtube", color: "var(--text-error)" },
|
||||
spotify: { label: "Spotify", icon: "fa-brands fa-spotify", color: "var(--mod-color)" },
|
||||
soundcloud: { label: "SoundCloud", icon: "fa-brands fa-soundcloud", color: "var(--owner-color)" },
|
||||
custom: { label: "Personnalisée", icon: "fa-solid fa-list", color: "var(--admin-color)" }
|
||||
};
|
||||
|
||||
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 = [];
|
||||
for (const owner of owners.value || []) {
|
||||
const user = owner.owner || { id: owner.userId, username: owner.username };
|
||||
for (const playlist of owner.playlists || []) {
|
||||
result.push({ ...playlist, key: owner.userId + ":" + playlist.playlistId, userId: owner.userId, owner: user });
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => (a.owner.global_name || a.owner.username || a.userId).localeCompare(b.owner.global_name || b.owner.username || b.userId)
|
||||
|| (a.title || "").localeCompare(b.title || ""));
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
return rows.value.filter(row => {
|
||||
if (filter.value !== 'all' && kind(row) !== filter.value) return false;
|
||||
if (!query) return true;
|
||||
return (row.title || "").toLowerCase().includes(query)
|
||||
|| (row.owner.username || "").toLowerCase().includes(query)
|
||||
|| (row.owner.global_name || "").toLowerCase().includes(query)
|
||||
|| row.userId.includes(query)
|
||||
|| String(row.playlistId).includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
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)`;
|
||||
});
|
||||
|
||||
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;
|
||||
try {
|
||||
const result = await adminRequest("/ADMIN/PLAYLISTS/LIST", {}, 20000);
|
||||
owners.value = Array.isArray(result) ? result : [];
|
||||
error.value = null;
|
||||
} catch (e) {
|
||||
error.value = describeError(e);
|
||||
if (owners.value) adminStore.notifyError(e, "Liste des playlists");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
const ownerName = row.owner.global_name || row.owner.username || row.userId;
|
||||
const ok = await confirmRef.value.ask({
|
||||
title: "Supprimer la playlist",
|
||||
icon: "fa-solid fa-trash",
|
||||
message: `« ${row.title || "Sans titre"} » (${safeNum(row.songCount)} titre(s)) sera définitivement supprimée de la collection de ${ownerName}.`
|
||||
+ (row.source === 'google' ? "\nElle sera recréée à la prochaine synchronisation de son compte Google." : ""),
|
||||
confirmLabel: "Supprimer",
|
||||
confirmIcon: "fa-solid fa-trash"
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await adminRequest("/ADMIN/PLAYLISTS/DELETE", { userId: row.userId, playlistId: row.playlistId });
|
||||
// Retrait local immédiat, sans recharger toute la liste
|
||||
owners.value = (owners.value || []).map(owner => owner.userId !== row.userId ? owner
|
||||
: { ...owner, playlists: (owner.playlists || []).filter(p => p.playlistId !== row.playlistId) });
|
||||
adminStore.notify('success', `Playlist « ${row.title || "Sans titre"} » supprimée`);
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, "Suppression de la playlist");
|
||||
if (e?.code === 'NOT_FOUND') load();
|
||||
}
|
||||
}
|
||||
|
||||
const nowDate = ref(Date.now());
|
||||
let timer = null;
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
timer = setInterval(() => { nowDate.value = Date.now(); }, 30000);
|
||||
});
|
||||
|
||||
onUnmounted(() => clearInterval(timer));
|
||||
</script>
|
||||
<style scoped>
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<section class="admin-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<h2>{{ title }}</h2>
|
||||
<p v-if="subtitle" class="subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="section-actions">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</section>
|
||||
</template>
|
||||
<script setup>
|
||||
// En-tête commun des sections d'administration (titre h2 avec emoji, comme Historique / Conseils)
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
</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>
|
||||
@@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<Modal ref="modal" icon="fa-solid fa-server" :title="guild ? guild.name : 'Serveur'">
|
||||
<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>
|
||||
</div>
|
||||
<AdminKv label="Propriétaire">
|
||||
<AdminUserLine v-if="detail.owner?.username" :user="detail.owner" size="30px"/>
|
||||
<span v-else class="mono">{{ detail.owner?.id || "Inconnu" }}</span>
|
||||
</AdminKv>
|
||||
<AdminKv label="Membres" mono :value="safeNum(detail.memberCount)"/>
|
||||
<AdminKv label="Membres inscrits" mono :value="safeNum(detail.registeredMembers)"/>
|
||||
<AdminKv label="En ligne sur le web" mono :value="safeNum(detail.webUsersOnline)"/>
|
||||
<AdminKv label="Bot arrivé le" :value="formatDate(detail.joinedAt)"/>
|
||||
<AdminKv label="Serveur créé le" :value="formatDate(detail.createdAt)"/>
|
||||
<AdminKv v-if="detail.player" label="Player">
|
||||
<Tag :color="playerStatus(detail.player.status).color">{{ playerStatus(detail.player.status).label }}</Tag>
|
||||
</AdminKv>
|
||||
<Error v-if="detail.missingPermissions?.length">Permissions manquantes : {{ detail.missingPermissions.join(", ") }}</Error>
|
||||
<div v-if="detail.features?.length" class="tags">
|
||||
<Tag v-for="feature in detail.features" :key="feature" color="var(--text-secondary)" icon="fa-solid fa-tag">{{ feature }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</ModalTree>
|
||||
|
||||
<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">
|
||||
<option :value="null">Aucun rôle (tout le monde)</option>
|
||||
<!-- @everyone équivaut à « aucun rôle » côté serveur : pas d'option en double -->
|
||||
<option v-for="role in sortedRoles" :key="role.id" :value="role.id">{{ role.name }}</option>
|
||||
</select>
|
||||
<Button icon="fa-solid fa-rotate" :disabled="savingRole || selectedRole === (detail.secureRole?.id ?? null)" @click="saveRole()">Mettre à jour</Button>
|
||||
</div>
|
||||
</ModalTree>
|
||||
|
||||
<ModalTree title="Salons vocaux" icon="fa-solid fa-volume-high">
|
||||
<div class="tree-content">
|
||||
<Info v-if="!detail.voiceChannels?.length" secondary>Aucun salon vocal</Info>
|
||||
<div v-for="channel in detail.voiceChannels" :key="channel.id" class="row">
|
||||
<span><Icon icon="fa-solid fa-volume-high"/> {{ channel.name }}</span>
|
||||
<span class="row-side">
|
||||
<Tag v-if="detail.player && (detail.player.connection?.channelId || detail.player.channelId) === channel.id" color="var(--main)" icon="fa-solid fa-music">Subsonics</Tag>
|
||||
<span class="mono">{{ safeNum(channel.members) }} membre(s)</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ModalTree>
|
||||
|
||||
<ModalTree title="Membres inscrits" icon="fa-solid fa-users">
|
||||
<div class="tree-content">
|
||||
<input type="text" v-model="memberSearch" placeholder="Rechercher un membre"/>
|
||||
<Info v-if="!filteredMembers.length" secondary>Aucun membre inscrit</Info>
|
||||
<div class="members">
|
||||
<div v-for="member in filteredMembers" :key="member.id" class="member">
|
||||
<div class="member-main">
|
||||
<AdminUserLine :user="member" :online="member.online"/>
|
||||
<div class="tags">
|
||||
<Tag v-if="member.isAdmin" color="var(--admin-color)">Administrateur</Tag>
|
||||
<Tag v-else-if="member.isOwner" color="var(--owner-color)">Propriétaire</Tag>
|
||||
<Tag v-else-if="member.isMod" color="var(--mod-color)">Modérateur</Tag>
|
||||
<Tag v-else color="var(--text-secondary)">Membre</Tag>
|
||||
<Tag v-if="member.isBanned" color="var(--text-error)">Banni</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!member.isAdmin && !member.isOwner && member.id !== myId" class="actions">
|
||||
<IconAction title="Bannir / Débannir de ce serveur" icon="fa-solid fa-hammer"
|
||||
:color="member.isBanned ? 'var(--text-error)' : 'var(--text-tertiary)'" @click="toggleMember(member, 'ban')"/>
|
||||
<IconAction title="Nommer / Retirer modérateur" icon="fa-solid fa-user-shield"
|
||||
:color="member.isMod ? 'var(--mod-color)' : 'var(--text-tertiary)'" @click="toggleMember(member, 'mod')"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalTree>
|
||||
|
||||
<ModalTree title="Zone dangereuse" icon="fa-solid fa-triangle-exclamation">
|
||||
<div class="tree-content">
|
||||
<p class="hint">Faire quitter le bot supprime sa présence sur le serveur : il faudra le réinviter pour l'utiliser à nouveau.</p>
|
||||
<Info v-if="detail.isMediaGuild" secondary>Le serveur de médias est protégé et ne peut pas être quitté.</Info>
|
||||
<Button icon="fa-solid fa-right-from-bracket" :disabled="detail.isMediaGuild || leaving" @click="leaveGuild()">Faire quitter le bot</Button>
|
||||
</div>
|
||||
</ModalTree>
|
||||
</AdminState>
|
||||
</Modal>
|
||||
<AdminConfirm ref="confirmRef"/>
|
||||
</template>
|
||||
<script setup>
|
||||
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 ServerAvatar from '@/components/UI/ServerAvatar.vue';
|
||||
import AdminState from './AdminState.vue';
|
||||
import AdminKv from './AdminKv.vue';
|
||||
import AdminStat from './AdminStat.vue';
|
||||
import AdminUserLine from './AdminUserLine.vue';
|
||||
import AdminConfirm from './AdminConfirm.vue';
|
||||
import { safeNum, formatDate, formatUptime, playerStatus } from './adminFormat';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const userStore = useUserStore();
|
||||
const myId = computed(() => userStore.userInfo?.identity?.id);
|
||||
|
||||
const modal = ref(null);
|
||||
const confirmRef = ref(null);
|
||||
const guild = ref(null);
|
||||
const detail = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const selectedRole = ref(null);
|
||||
const savingRole = ref(false);
|
||||
const leaving = ref(false);
|
||||
const memberSearch = ref("");
|
||||
|
||||
const sortedRoles = computed(() => (detail.value?.roles || []).filter(role => role.name !== "@everyone").sort((a, b) => safeNum(b.position) - safeNum(a.position)));
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
const query = memberSearch.value.trim().toLowerCase();
|
||||
const list = detail.value?.members || [];
|
||||
if (!query) return list;
|
||||
return list.filter(m => [m.id, m.username, m.global_name].some(v => (v || "").toLowerCase().includes(query)));
|
||||
});
|
||||
|
||||
async function load() {
|
||||
if (!guild.value || loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await adminRequest("/ADMIN/GUILDS/GET", { guildId: guild.value.id });
|
||||
selectedRole.value = detail.value?.secureRole?.id ?? null;
|
||||
error.value = null;
|
||||
} catch (e) {
|
||||
error.value = describeError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function open(target) {
|
||||
guild.value = target;
|
||||
detail.value = null;
|
||||
error.value = null;
|
||||
memberSearch.value = "";
|
||||
modal.value?.open();
|
||||
load();
|
||||
}
|
||||
|
||||
async function saveRole() {
|
||||
savingRole.value = true;
|
||||
try {
|
||||
const result = await adminRequest("/ADMIN/GUILDS/SET_SECURE_ROLE", { guildId: detail.value.id, roleId: selectedRole.value });
|
||||
detail.value = { ...detail.value, secureRole: result?.secureRole ?? null };
|
||||
selectedRole.value = result?.secureRole?.id ?? null;
|
||||
adminStore.notify('success', result?.secureRole ? `Rôle sécurisé : ${result.secureRole.name}` : "Rôle sécurisé retiré");
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, "Rôle sécurisé");
|
||||
} finally {
|
||||
savingRole.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;
|
||||
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)
|
||||
};
|
||||
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")}`);
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, kind === 'ban' ? "Bannissement" : "Modération");
|
||||
}
|
||||
}
|
||||
|
||||
async function leaveGuild() {
|
||||
const target = detail.value;
|
||||
const result = await confirmRef.value.ask({
|
||||
title: "Faire quitter le bot",
|
||||
icon: "fa-solid fa-right-from-bracket",
|
||||
message: `Subsonics va quitter définitivement le serveur « ${target.name} ». Le player sera déconnecté et les utilisateurs perdront l'accès.`,
|
||||
requireText: target.name,
|
||||
confirmLabel: "Quitter le serveur",
|
||||
confirmIcon: "fa-solid fa-right-from-bracket"
|
||||
});
|
||||
if (!result) return;
|
||||
leaving.value = true;
|
||||
try {
|
||||
await adminRequest("/ADMIN/GUILDS/LEAVE", { guildId: target.id, confirmName: target.name }, 30000);
|
||||
adminStore.notify('success', `Subsonics a quitté « ${target.name} »`);
|
||||
adminStore.guilds = adminStore.guilds.filter(g => g.id !== target.id);
|
||||
modal.value?.close();
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, "Départ du serveur");
|
||||
} finally {
|
||||
leaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
<style scoped>
|
||||
.tree-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 10px 0 15px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.head-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.id,
|
||||
.metric-id {
|
||||
font-size: 0.7em;
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 35vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background-color: var(--tertiary);
|
||||
padding: 5px 10px 5px 5px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<AdminSection title="🏠 Serveurs" :subtitle="adminStore.guilds.length + ' serveur(s) Discord accueillent Subsonics'">
|
||||
<template #actions>
|
||||
<input type="text" class="search" v-model="search" placeholder="Rechercher un serveur (nom ou ID)"/>
|
||||
<Button icon="fa-solid fa-rotate" :disabled="adminStore.guildsLoading" @click="adminStore.fetchGuilds(true)">Rafraîchir</Button>
|
||||
</template>
|
||||
|
||||
<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'"
|
||||
@retry="adminStore.fetchGuilds()">
|
||||
<div class="servers-list">
|
||||
<Box v-for="guild in filtered" :key="guild.id" level="second" padding="closed" no-shadow class="server-item">
|
||||
<div class="server-main">
|
||||
<ServerAvatar :src="guild.icon" :server-id="guild.id"/>
|
||||
<div class="server-info">
|
||||
<p class="name">{{ guild.name }}</p>
|
||||
<p class="data">
|
||||
<Icon style="font-size: 10px;" :color="guild.player ? 'var(--mod-color)' : 'var(--text-error)'" icon="fa-solid fa-circle"/>
|
||||
{{ guild.player ? "Player connecté" : "Player hors ligne" }} - {{ safeNum(guild.memberCount) }} membres
|
||||
</p>
|
||||
<div class="tags">
|
||||
<Tag color="var(--admin-color)" icon="fa-solid fa-id-card">{{ safeNum(guild.registeredMembers) }} inscrit(s)</Tag>
|
||||
<Tag v-if="guild.webUsersOnline" color="var(--text-success)" icon="fa-solid fa-globe">{{ guild.webUsersOnline }} en ligne</Tag>
|
||||
<Tag v-if="guild.secureRole" color="var(--owner-color)" icon="fa-solid fa-shield-halved">{{ guild.secureRole.name }}</Tag>
|
||||
<Tag v-if="guild.isMediaGuild" color="var(--mod-color)" icon="fa-solid fa-photo-film">Serveur de médias</Tag>
|
||||
<Tag v-if="guild.missingPermissions?.length" color="var(--text-error)" icon="fa-solid fa-triangle-exclamation">{{ guild.missingPermissions.length }} permission(s) manquante(s)</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-side">
|
||||
<p class="server-stats">
|
||||
<span title="Commandes"><Icon icon="fa-solid fa-terminal"/> {{ safeNum(guild.stats?.commands) }}</span>
|
||||
<span title="Titres joués"><Icon icon="fa-solid fa-music"/> {{ safeNum(guild.stats?.musicPlayed) }}</span>
|
||||
</p>
|
||||
<Button color-lower icon="fa-solid fa-magnifying-glass" @click="detail.open(guild)">Détails</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</AdminState>
|
||||
|
||||
<AdminServerDetail ref="detail"/>
|
||||
</AdminSection>
|
||||
</template>
|
||||
<script setup>
|
||||
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 AdminServerDetail from './AdminServerDetail.vue';
|
||||
import { safeNum } from './adminFormat';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const search = ref("");
|
||||
const detail = ref(null);
|
||||
|
||||
// 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 || ""));
|
||||
if (!query) return list;
|
||||
return list.filter(g => (g.name || "").toLowerCase().includes(query) || String(g.id).includes(query));
|
||||
});
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,81 @@
|
||||
<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>
|
||||
</template>
|
||||
<script setup>
|
||||
// Tuile de chiffre clé (même hiérarchie de surfaces que Metric.vue)
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
value: {
|
||||
default: 0
|
||||
},
|
||||
sub: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "fa-solid fa-chart-bar"
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "var(--text)"
|
||||
}
|
||||
});
|
||||
</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,68 @@
|
||||
<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>
|
||||
<Info v-else-if="empty" secondary class="state-empty">{{ emptyText }}</Info>
|
||||
<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';
|
||||
import Button from '@/components/UI/Button.vue';
|
||||
|
||||
defineEmits(['retry']);
|
||||
defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
empty: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loadingText: {
|
||||
type: String,
|
||||
default: "Chargement en cours"
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: "Rien à afficher pour le moment"
|
||||
},
|
||||
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,97 @@
|
||||
<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>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</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';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
|
||||
function iconOf(type) {
|
||||
if (type === 'error') return "fa-solid fa-circle-exclamation";
|
||||
if (type === 'success') return "fa-solid fa-check-circle";
|
||||
return "fa-solid fa-circle-info";
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.toasts {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: 4500;
|
||||
max-width: min(420px, calc(100vw - 40px));
|
||||
}
|
||||
|
||||
.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,468 @@
|
||||
<template>
|
||||
<Modal ref="modal" icon="fa-solid fa-user-gear" :title="user ? (user.global_name || user.username) : '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>
|
||||
<AdminKv label="ID" mono :value="detail.id"/>
|
||||
<AdminKv label="Identité mise à jour" :value="detail.lastIdentityUpdate ? formatDate(detail.lastIdentityUpdate) : 'Jamais'"/>
|
||||
<AdminKv label="Liaison Discord">
|
||||
<Tag :color="detail.hasDiscordAuth ? 'var(--text-success)' : 'var(--text-warning)'">{{ detail.hasDiscordAuth ? "Active" : "Absente" }}</Tag>
|
||||
</AdminKv>
|
||||
<AdminKv label="Tokens actifs" mono :value="safeNum(detail.tokenCount)"/>
|
||||
<AdminKv v-if="detail.identity?.locale" label="Langue" :value="detail.identity.locale"/>
|
||||
<div class="tags">
|
||||
<Tag v-for="label in detail.labels" :key="label" :color="labelColor(label)" icon="fa-solid fa-tag">{{ label }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</ModalTree>
|
||||
|
||||
<ModalTree title="Droits globaux" icon="fa-solid fa-star">
|
||||
<div class="tree-content">
|
||||
<Info v-if="isMe" secondary>Tu ne peux pas modifier tes propres droits.</Info>
|
||||
<div class="switch-row">
|
||||
<div>
|
||||
<p class="switch-title"><Icon icon="fa-solid fa-star" style="color: var(--admin-color);"/> Administrateur</p>
|
||||
<p class="hint">Accès complet à cette interface et à toutes les commandes.</p>
|
||||
</div>
|
||||
<Button :disabled="isMe || detail.isDeleted || busy.admin" :icon="detail.isAdmin ? 'fa-solid fa-user-minus' : 'fa-solid fa-user-plus'" @click="setAdmin(!detail.isAdmin)">
|
||||
{{ detail.isAdmin ? "Retirer" : "Nommer" }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="switch-row">
|
||||
<div>
|
||||
<p class="switch-title"><Icon icon="fa-solid fa-hammer" style="color: var(--text-error);"/> Bannissement global</p>
|
||||
<p class="hint">{{ detail.isFullBanned ? "Ce compte est banni de Subsonics." : "Interdit l'accès à Subsonics et déconnecte toutes les sessions." }}</p>
|
||||
</div>
|
||||
<Button :disabled="isMe || (detail.isAdmin && !detail.isFullBanned) || busy.ban" :icon="detail.isFullBanned ? 'fa-solid fa-unlock' : 'fa-solid fa-hammer'" @click="setFullBan(!detail.isFullBanned)">
|
||||
{{ detail.isFullBanned ? "Débannir" : "Bannir" }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalTree>
|
||||
|
||||
<ModalTree title="Serveurs" icon="fa-solid fa-server">
|
||||
<div class="tree-content">
|
||||
<Info v-if="!detail.guilds?.length" secondary>Aucun serveur en commun avec Subsonics</Info>
|
||||
<div class="list">
|
||||
<div v-for="guild in detail.guilds" :key="guild.id" class="row">
|
||||
<div class="row-main">
|
||||
<ServerAvatar class="small-avatar" :src="guild.icon" :server-id="guild.id"/>
|
||||
<div class="row-info">
|
||||
<p class="row-title">{{ guild.name }}</p>
|
||||
<div class="tags">
|
||||
<Tag v-if="guild.isOwner" color="var(--owner-color)">Propriétaire</Tag>
|
||||
<Tag v-if="guild.isMod" color="var(--mod-color)">Modérateur</Tag>
|
||||
<Tag v-if="guild.isBanned" color="var(--text-error)">Banni</Tag>
|
||||
<Tag v-if="detail.currentGuildId === guild.id" color="var(--text-success)">Actif</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<ModalTree title="Sessions" icon="fa-solid fa-plug">
|
||||
<div class="tree-content">
|
||||
<Info v-if="!detail.sockets?.length" secondary>Aucune session ouverte</Info>
|
||||
<div class="list">
|
||||
<div v-for="socketInfo in detail.sockets" :key="socketInfo.socketId" class="row column">
|
||||
<AdminKv label="Adresse" mono :value="socketInfo.address || 'Inconnue'"/>
|
||||
<AdminKv label="Navigateur" :value="socketInfo.userAgent || 'Inconnu'"/>
|
||||
<AdminKv label="Connecté" :value="formatRelative(socketInfo.connectedAt)"/>
|
||||
<AdminKv label="Serveur" mono :value="guildName(socketInfo.guildId)"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<Button :disabled="isMe || busy.disconnect" icon="fa-solid fa-plug-circle-xmark" @click="disconnect(false)">Déconnecter</Button>
|
||||
<Button :disabled="isMe || busy.disconnect" icon="fa-solid fa-key" @click="disconnect(true)">Déconnecter et révoquer les tokens</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalTree>
|
||||
|
||||
<ModalTree title="Données" icon="fa-solid fa-database">
|
||||
<div class="tree-content">
|
||||
<div class="stats">
|
||||
<AdminStat label="Playlists" :value="safeNum(detail.playlistCount ?? detail.playlists?.length)" icon="fa-solid fa-list"/>
|
||||
<AdminStat label="Historique" :value="safeNum(detail.historyCount ?? detail.history?.length)" icon="fa-solid fa-clock-rotate-left"/>
|
||||
<AdminStat label="Médias" :value="safeNum(detail.mediaCount ?? detail.media?.length)" icon="fa-solid fa-photo-film"/>
|
||||
</div>
|
||||
<div v-if="detail.playlists?.length" class="list">
|
||||
<div v-for="playlist in detail.playlists" :key="playlist.playlistId" class="row">
|
||||
<span><Icon icon="fa-solid fa-list"/> {{ playlist.title }}</span>
|
||||
<span class="row-side">{{ safeNum(playlist.songCount) }} titre(s) · {{ playlist.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detail.media?.length" class="list">
|
||||
<div v-for="media in detail.media" :key="media.id" class="row">
|
||||
<a v-if="media.url" :href="media.url" target="_blank" rel="noopener"><Icon icon="fa-solid fa-file-audio"/> {{ media.name }}</a>
|
||||
<span v-else><Icon icon="fa-solid fa-file-audio"/> {{ media.name }}</span>
|
||||
<span class="row-side">{{ formatBytes(media.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detail.history?.length" class="list">
|
||||
<p class="hint">Dernières écoutes</p>
|
||||
<div v-for="(song, index) in detail.history.slice(0, 10)" :key="(song.id || '') + index" class="row">
|
||||
<span class="ellipsis">{{ song.title }}</span>
|
||||
<span class="row-side">{{ song.author }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalTree>
|
||||
|
||||
<ModalTree title="Zone dangereuse" icon="fa-solid fa-triangle-exclamation">
|
||||
<div class="tree-content">
|
||||
<p class="hint">La suppression conserve les bannissements mais efface le compte. Les données (playlists, historique, médias) peuvent être purgées.</p>
|
||||
<Button :disabled="isMe || detail.isAdmin || detail.isDeleted || busy.delete" icon="fa-solid fa-trash" @click="remove()">Supprimer le compte</Button>
|
||||
</div>
|
||||
</ModalTree>
|
||||
</AdminState>
|
||||
</Modal>
|
||||
<AdminConfirm ref="confirmRef"/>
|
||||
</template>
|
||||
<script setup>
|
||||
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 AdminKv from './AdminKv.vue';
|
||||
import AdminStat from './AdminStat.vue';
|
||||
import AdminUserLine from './AdminUserLine.vue';
|
||||
import AdminConfirm from './AdminConfirm.vue';
|
||||
import { safeNum, formatDate, formatRelative, formatBytes } from './adminFormat';
|
||||
|
||||
const emit = defineEmits(['changed']);
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const modal = ref(null);
|
||||
const confirmRef = ref(null);
|
||||
const user = ref(null);
|
||||
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 isMe = computed(() => detail.value?.id === userStore.userInfo?.identity?.id);
|
||||
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);
|
||||
|
||||
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)";
|
||||
}
|
||||
|
||||
function guildName(guildId) {
|
||||
if (!guildId) return "Aucun";
|
||||
return detail.value?.guilds?.find(g => g.id === guildId)?.name || guildId;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!user.value || loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await adminRequest("/ADMIN/USERS/GET", { userId: user.value.id });
|
||||
error.value = null;
|
||||
} catch (e) {
|
||||
error.value = describeError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function open(target) {
|
||||
user.value = target;
|
||||
detail.value = null;
|
||||
error.value = null;
|
||||
modal.value?.open();
|
||||
load();
|
||||
}
|
||||
|
||||
// Un UserSummary renvoyé par le serveur : on le fusionne dans le détail et on prévient la liste
|
||||
function applySummary(summary) {
|
||||
if (!summary) return;
|
||||
detail.value = { ...detail.value, ...summary };
|
||||
emit('changed', summary);
|
||||
}
|
||||
|
||||
async function run(key, task, context) {
|
||||
if (busy[key]) return;
|
||||
busy[key] = true;
|
||||
try {
|
||||
await task();
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, context);
|
||||
} finally {
|
||||
busy[key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
run('refresh', async () => {
|
||||
applySummary(await adminRequest("/ADMIN/USERS/REFRESH", { userId: detail.value.id }, 20000));
|
||||
adminStore.notify('success', `Identité de ${displayName.value} rafraîchie`);
|
||||
}, "Rafraîchissement de l'identité");
|
||||
}
|
||||
|
||||
async function setAdmin(value) {
|
||||
if (value) {
|
||||
const ok = await confirmRef.value.ask({
|
||||
title: "Nommer administrateur",
|
||||
icon: "fa-solid fa-star",
|
||||
message: `${displayName.value} aura un accès complet à Subsonics et à cette interface.`,
|
||||
confirmLabel: "Nommer administrateur",
|
||||
confirmIcon: "fa-solid fa-star"
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
run('admin', async () => {
|
||||
applySummary(await adminRequest("/ADMIN/USERS/SET_ADMIN", { userId: detail.value.id, value }));
|
||||
adminStore.notify('success', `${displayName.value} ${value ? "est désormais administrateur" : "n'est plus administrateur"}`);
|
||||
}, "Droits administrateur");
|
||||
}
|
||||
|
||||
async function setFullBan(value) {
|
||||
let reason;
|
||||
if (value) {
|
||||
const result = await confirmRef.value.ask({
|
||||
title: "Bannir de Subsonics",
|
||||
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)",
|
||||
reasonMin: 0,
|
||||
reasonMax: 200,
|
||||
confirmLabel: "Bannir",
|
||||
confirmIcon: "fa-solid fa-hammer"
|
||||
});
|
||||
if (!result) return;
|
||||
reason = result.reason || undefined;
|
||||
}
|
||||
run('ban', async () => {
|
||||
applySummary(await adminRequest("/ADMIN/USERS/SET_FULL_BAN", { userId: detail.value.id, value, reason }));
|
||||
adminStore.notify('success', `${displayName.value} ${value ? "a été banni" : "a été débanni"}`);
|
||||
if (value) load();
|
||||
}, "Bannissement global");
|
||||
}
|
||||
|
||||
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];
|
||||
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")}`);
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, kind === 'ban' ? "Bannissement du serveur" : "Modération");
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect(revokeTokens) {
|
||||
const ok = await confirmRef.value.ask({
|
||||
title: revokeTokens ? "Déconnecter et révoquer" : "Déconnecter",
|
||||
icon: "fa-solid fa-plug-circle-xmark",
|
||||
message: revokeTokens
|
||||
? `Toutes les sessions de ${displayName.value} seront fermées et ses tokens révoqués : il devra se reconnecter avec Discord.`
|
||||
: `Toutes les sessions ouvertes de ${displayName.value} seront fermées.`,
|
||||
confirmLabel: "Déconnecter",
|
||||
confirmIcon: "fa-solid fa-plug-circle-xmark"
|
||||
});
|
||||
if (!ok) return;
|
||||
run('disconnect', async () => {
|
||||
const result = await adminRequest("/ADMIN/USERS/DISCONNECT", { userId: detail.value.id, revokeTokens });
|
||||
adminStore.notify('success', `${safeNum(result?.disconnectedSockets)} session(s) fermée(s)` + (revokeTokens ? `, ${safeNum(result?.revokedTokens)} token(s) révoqué(s)` : ""));
|
||||
await load();
|
||||
emit('changed', null);
|
||||
}, "Déconnexion forcée");
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const target = detail.value;
|
||||
const result = await confirmRef.value.ask({
|
||||
title: "Supprimer le compte",
|
||||
icon: "fa-solid fa-trash",
|
||||
message: `Le compte de ${displayName.value} sera supprimé et toutes ses sessions fermées. Les bannissements sont conservés.`,
|
||||
requireText: target.username || target.id,
|
||||
checkbox: "Purger aussi ses playlists, son historique et ses médias",
|
||||
checkboxDefault: true,
|
||||
confirmLabel: "Supprimer",
|
||||
confirmIcon: "fa-solid fa-trash"
|
||||
});
|
||||
if (!result) return;
|
||||
run('delete', async () => {
|
||||
const res = await adminRequest("/ADMIN/USERS/DELETE", { userId: target.id, purge: result.checked }, 30000);
|
||||
// Le serveur renvoie toujours "purged" (à zéro sans purge) : affiché seulement si demandé
|
||||
const purged = result.checked ? res?.purged : null;
|
||||
adminStore.notify('success', `Compte de ${displayName.value} supprimé` + (purged ? ` (${safeNum(purged.playlists)} playlist(s), ${safeNum(purged.history)} historique(s), ${safeNum(purged.media)} média(s) purgé(s))` : ""));
|
||||
modal.value?.close();
|
||||
emit('changed', null);
|
||||
}, "Suppression du compte");
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
<style scoped>
|
||||
.tree-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 10px 0 15px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.identity {
|
||||
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;
|
||||
}
|
||||
|
||||
.hint {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<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>
|
||||
</template>
|
||||
<script setup>
|
||||
// Variante de UI/User.vue pour une identité brute (UserLite) avec repli d'avatar et pastille « en ligne »
|
||||
import { userAvatar } from './adminFormat';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
online: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showId: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "50px"
|
||||
}
|
||||
});
|
||||
</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>
|
||||
@@ -0,0 +1,241 @@
|
||||
<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>
|
||||
|
||||
<AdminPills v-model="filter" :options="filters"/>
|
||||
|
||||
<!-- Tant que la 1re réponse n'est pas arrivée (même avant onMounted), on affiche le chargement -->
|
||||
<AdminState :loading="!result && !error" :error="!result ? error : null"
|
||||
:empty="!!result && items.length === 0"
|
||||
:empty-text="search ? 'Aucun compte ne correspond à cette recherche' : 'Aucun compte dans cette catégorie'"
|
||||
@retry="load()">
|
||||
<div class="users-list">
|
||||
<div v-for="user in items" :key="user.id" class="user">
|
||||
<div class="group">
|
||||
<AdminUserLine :user="user" :online="isOnline(user)" show-id/>
|
||||
<div class="tags">
|
||||
<Tag v-if="user.isAdmin" color="var(--admin-color)" icon="fa-solid fa-star">Administrateur</Tag>
|
||||
<Tag v-if="user.isFullBanned" color="var(--text-error)" icon="fa-solid fa-hammer">Banni</Tag>
|
||||
<Tag v-if="user.isDeleted" color="var(--text-secondary)" icon="fa-solid fa-user-slash">Supprimé</Tag>
|
||||
<Tag v-if="isOnline(user)" color="var(--text-success)">En ligne<span v-if="user.socketCount > 1"> ({{ user.socketCount }})</span></Tag>
|
||||
<Tag v-if="user.modGuilds?.length" color="var(--mod-color)" icon="fa-solid fa-user-shield">Modo ×{{ user.modGuilds.length }}</Tag>
|
||||
<Tag v-if="user.bannedGuilds?.length" color="var(--text-warning)" icon="fa-solid fa-ban">Banni ×{{ user.bannedGuilds.length }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side">
|
||||
<span class="side-info" :title="'Tokens actifs : ' + safeNum(user.tokenCount)"><Icon icon="fa-solid fa-key"/> {{ safeNum(user.tokenCount) }}</span>
|
||||
<span class="side-info" title="Serveurs communs"><Icon icon="fa-solid fa-server"/> {{ user.guildIds?.length || 0 }}</span>
|
||||
<Button color-lower icon="fa-solid fa-magnifying-glass" @click="detail.open(user)">Détails</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<IconAction title="Page précédente" icon="fa-solid fa-chevron-left" :color="page > 1 ? 'var(--text)' : 'var(--text-tertiary)'" @click="goTo(page - 1)"/>
|
||||
<span class="page-info">Page {{ page }} / {{ pageCount }} · {{ safeNum(result?.total) }} compte(s)</span>
|
||||
<IconAction title="Page suivante" icon="fa-solid fa-chevron-right" :color="page < pageCount ? 'var(--text)' : 'var(--text-tertiary)'" @click="goTo(page + 1)"/>
|
||||
<Icon v-if="loading" icon="fa-solid fa-spinner" spin-pulse class="page-loading"/>
|
||||
</div>
|
||||
</AdminState>
|
||||
|
||||
<AdminUserDetail ref="detail" @changed="onChanged"/>
|
||||
</AdminSection>
|
||||
</template>
|
||||
<script setup>
|
||||
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 AdminPills from './AdminPills.vue';
|
||||
import AdminUserLine from './AdminUserLine.vue';
|
||||
import AdminUserDetail from './AdminUserDetail.vue';
|
||||
import { safeNum } from './adminFormat';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const detail = ref(null);
|
||||
|
||||
const search = ref("");
|
||||
const filter = ref('all');
|
||||
const page = ref(1);
|
||||
const result = ref(null);
|
||||
const loading = ref(false);
|
||||
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: '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)" }
|
||||
];
|
||||
|
||||
// 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 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";
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Une seule requête à la fois ; si les critères changent pendant le chargement, on relance à la fin
|
||||
let queued = false;
|
||||
async function load() {
|
||||
if (loading.value) { queued = true; return; }
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await adminRequest("/ADMIN/USERS/LIST", {
|
||||
search: search.value.trim() || undefined,
|
||||
filter: filter.value,
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE
|
||||
});
|
||||
result.value = data && typeof data === "object" ? data : { items: [], total: 0, page: page.value, pageSize: PAGE_SIZE };
|
||||
error.value = null;
|
||||
} catch (e) {
|
||||
error.value = describeError(e);
|
||||
if (result.value) adminStore.notifyError(e, "Liste des comptes");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (queued) { queued = false; load(); }
|
||||
}
|
||||
}
|
||||
|
||||
function goTo(target) {
|
||||
if (target < 1 || target > pageCount.value || target === page.value) return;
|
||||
page.value = target;
|
||||
load();
|
||||
}
|
||||
|
||||
// Recherche avec un léger délai pour ne pas inonder le serveur
|
||||
let searchTimer = null;
|
||||
watch(search, () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => { page.value = 1; load(); }, 300);
|
||||
});
|
||||
|
||||
watch(filter, () => {
|
||||
page.value = 1;
|
||||
load();
|
||||
});
|
||||
|
||||
// Un compte modifié depuis le détail : on remplace la ligne sans recharger toute la page
|
||||
function onChanged(summary) {
|
||||
if (!result.value) return;
|
||||
if (!summary) return load();
|
||||
result.value = {
|
||||
...result.value,
|
||||
items: items.value.map(u => u.id === summary.id ? { ...u, ...summary } : u)
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => load());
|
||||
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>
|
||||
@@ -0,0 +1,318 @@
|
||||
<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()">
|
||||
{{ adminStore.warpChecking ? "Vérification…" : "Revérifier" }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
<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)"
|
||||
: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)"
|
||||
: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>
|
||||
|
||||
<div class="cards">
|
||||
<AdminCard title="Configuration" icon="fa-solid fa-gear" color="var(--text-secondary)">
|
||||
<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>
|
||||
</AdminKv>
|
||||
<AdminKv label="Ordre des routes">
|
||||
<Tag v-for="(route, index) in warp.routes || []" :key="route + index" :color="route === 'warp' ? 'var(--owner-color)' : 'var(--admin-color)'" icon="fa-solid fa-route">
|
||||
{{ index + 1 }}. {{ route === 'warp' ? "WARP" : "Direct" }}
|
||||
</Tag>
|
||||
</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>
|
||||
|
||||
<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="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>
|
||||
</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>
|
||||
</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()">
|
||||
{{ saving ? "Enregistrement…" : "Enregistrer et vérifier" }}
|
||||
</Button>
|
||||
<Button class="secondary" icon="fa-solid fa-xmark" :disabled="!dirty || saving" @click="resetForm()">Annuler</Button>
|
||||
</div>
|
||||
</AdminCard>
|
||||
|
||||
<AdminCard title="Processus yt-dlp actifs" icon="fa-brands fa-youtube" color="var(--text-error)">
|
||||
<Info v-if="!processes.length" secondary>Aucun téléchargement en cours</Info>
|
||||
<AdminKv v-for="proc in processes" :key="proc.guildId" :label="proc.guildName || proc.guildId">
|
||||
<Tag :color="proc.ytdlp.route === 'warp' ? 'var(--owner-color)' : 'var(--admin-color)'" icon="fa-solid fa-route">
|
||||
{{ proc.ytdlp.route === 'warp' ? "WARP" : "Direct" }}
|
||||
</Tag>
|
||||
<span class="mono">PID {{ proc.ytdlp.pid }}</span>
|
||||
<span class="since">{{ formatRelative(proc.ytdlp.startedAt, nowDate) }}</span>
|
||||
</AdminKv>
|
||||
</AdminCard>
|
||||
</div>
|
||||
</AdminState>
|
||||
|
||||
<AdminConfirm ref="confirmRef"/>
|
||||
</AdminSection>
|
||||
</template>
|
||||
<script setup>
|
||||
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 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 AdminCard from './AdminCard.vue';
|
||||
import AdminKv from './AdminKv.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));
|
||||
|
||||
// ----- É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
|
||||
const confirmRef = ref(null);
|
||||
const saving = ref(false);
|
||||
const form = reactive({ enabled: false, url: "", fallbackDirect: true });
|
||||
const PROXY_URL = /^(https?|socks5h?):\/\/[^\s/?#]+(\/)?$/i;
|
||||
|
||||
function resetForm() {
|
||||
form.enabled = !!warp.value?.file?.enabled;
|
||||
form.fallbackDirect = warp.value?.file?.fallbackDirect !== false;
|
||||
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);
|
||||
});
|
||||
|
||||
const urlError = computed(() => {
|
||||
const url = form.url.trim();
|
||||
if (url && !PROXY_URL.test(url)) return "URL invalide : schéma http(s):// ou socks5(h):// suivi de hôte:port, sans chemin";
|
||||
if (form.enabled && !url && !warp.value?.file?.url) return "Renseignez une URL pour activer le proxy";
|
||||
return null;
|
||||
});
|
||||
|
||||
// Configuration modifiée ailleurs (poussée, autre admin) : le formulaire suit tant qu'il n'est pas modifié
|
||||
watch(() => JSON.stringify(warp.value?.file || null), () => {
|
||||
if (!dirty.value || saving.value) resetForm();
|
||||
}, { immediate: true });
|
||||
|
||||
async function sendConfig(patch) {
|
||||
saving.value = true;
|
||||
try {
|
||||
const result = await adminRequest("/ADMIN/WARP/CONFIG", patch, 30000);
|
||||
adminStore.setWarp(result?.status);
|
||||
resetForm();
|
||||
const changes = result?.changes || [];
|
||||
if (!changes.length) adminStore.notify('info', "Configuration WARP inchangée");
|
||||
else {
|
||||
const status = result?.status;
|
||||
const health = status?.configured ? (status.healthy ? " · proxy opérationnel" : " · proxy injoignable") : "";
|
||||
adminStore.notify(status?.configured && !status.healthy ? 'error' : 'success', "Configuration WARP enregistrée" + health);
|
||||
}
|
||||
} catch (e) {
|
||||
adminStore.notifyError(e, "Configuration WARP");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!dirty.value || urlError.value || saving.value) return;
|
||||
const file = warp.value.file;
|
||||
const locked = warp.value.locked || {};
|
||||
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 (!Object.keys(patch).length) return;
|
||||
await sendConfig(patch);
|
||||
}
|
||||
|
||||
async function clearUrl() {
|
||||
const ok = await confirmRef.value.ask({
|
||||
title: "Effacer l'URL du proxy",
|
||||
icon: "fa-solid fa-trash",
|
||||
message: "Le proxy WARP sera désactivé et yt-dlp utilisera uniquement la connexion directe.",
|
||||
confirmLabel: "Effacer",
|
||||
confirmIcon: "fa-solid fa-trash"
|
||||
});
|
||||
if (!ok) return;
|
||||
await sendConfig({ url: "" });
|
||||
}
|
||||
|
||||
const nowDate = ref(Date.now());
|
||||
let timer = null;
|
||||
|
||||
onMounted(() => {
|
||||
adminStore.fetchWarp();
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
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);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* [Subsonics Chopin - Frontend]
|
||||
* File: adminFormat.js
|
||||
* Description: Fonctions de formatage de l'interface d'administration (toutes protégées contre NaN)
|
||||
*/
|
||||
|
||||
export function safeNum(value, fallback = 0) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
// 1536 -> "1.5 Ko"
|
||||
export function formatBytes(bytes) {
|
||||
let value = safeNum(bytes);
|
||||
const units = ["o", "Ko", "Mo", "Go", "To"];
|
||||
let i = 0;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
// Durée longue en secondes -> "2j 03h 04m" / "3h 05m 12s" / "45s"
|
||||
export function formatUptime(seconds) {
|
||||
let s = Math.max(0, Math.floor(safeNum(seconds)));
|
||||
const d = Math.floor(s / 86400); s -= d * 86400;
|
||||
const h = Math.floor(s / 3600); s -= h * 3600;
|
||||
const m = Math.floor(s / 60); s -= m * 60;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
if (d > 0) return `${d}j ${pad(h)}h ${pad(m)}m`;
|
||||
if (h > 0) return `${h}h ${pad(m)}m ${pad(s)}s`;
|
||||
if (m > 0) return `${m}m ${pad(s)}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
// Position en millisecondes -> "3:05" / "1:02:05"
|
||||
export function formatClock(ms) {
|
||||
const total = Math.max(0, Math.floor(safeNum(ms) / 1000));
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
}
|
||||
|
||||
function toDate(value) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const date = new Date(typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
// Date ISO / timestamp -> "06/10/2026 21:31:11"
|
||||
export function formatDate(value) {
|
||||
const date = toDate(value);
|
||||
if (!date) return "Inconnue";
|
||||
return date.toLocaleString("fr-FR");
|
||||
}
|
||||
|
||||
// Date ISO / timestamp -> "il y a 5 min" / "dans 3 min"
|
||||
export function formatRelative(value, now = Date.now()) {
|
||||
const date = toDate(value);
|
||||
if (!date) return "Jamais";
|
||||
const diff = Math.round((date.getTime() - now) / 1000);
|
||||
const abs = Math.abs(diff);
|
||||
let text;
|
||||
if (abs < 5) return "à l'instant";
|
||||
if (abs < 60) text = `${abs} s`;
|
||||
else if (abs < 3600) text = `${Math.floor(abs / 60)} min`;
|
||||
else if (abs < 86400) text = `${Math.floor(abs / 3600)} h`;
|
||||
else text = `${Math.floor(abs / 86400)} j`;
|
||||
return diff < 0 ? `il y a ${text}` : `dans ${text}`;
|
||||
}
|
||||
|
||||
// Statut d'un player (état v2) -> libellé + couleur de Tag
|
||||
export function playerStatus(status) {
|
||||
switch (status) {
|
||||
case 'playing': return { label: "En lecture", color: "var(--text-success)", icon: "fa-solid fa-play" };
|
||||
case 'paused': return { label: "En pause", color: "var(--text-warning)", icon: "fa-solid fa-pause" };
|
||||
case 'loading': return { label: "Chargement", color: "var(--admin-color)", icon: "fa-solid fa-spinner" };
|
||||
case 'stalled': return { label: "Bloqué", color: "var(--text-error)", icon: "fa-solid fa-triangle-exclamation" };
|
||||
case 'idle': return { label: "Inactif", color: "var(--text-secondary)", icon: "fa-solid fa-circle" };
|
||||
case 'offline': return { label: "Hors ligne", color: "var(--text-error)", icon: "fa-solid fa-circle" };
|
||||
default: return { label: status || "Inconnu", color: "var(--text-secondary)", icon: "fa-solid fa-circle" };
|
||||
}
|
||||
}
|
||||
|
||||
// Avatar Discord d'un utilisateur (repli sur l'avatar par défaut si absent)
|
||||
export function userAvatar(user) {
|
||||
if (user?.avatar) return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}`;
|
||||
let index = 0;
|
||||
try { index = Number((BigInt(user?.id || 0) >> BigInt(22)) % BigInt(6)); } catch (e) { index = 0; }
|
||||
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
|
||||
}
|
||||
|
||||
// Classes d'erreur yt-dlp (backend/src/youtube/errors.js) -> libellé français
|
||||
export const YOUTUBE_CLASSES = {
|
||||
BOT_CHECK: "Vérification anti-robot",
|
||||
IP_BLOCKED: "IP bloquée par YouTube",
|
||||
CAPTCHA: "Captcha demandé",
|
||||
RATE_LIMITED: "Limite de requêtes YouTube",
|
||||
HTTP_403: "Erreur 403 (accès refusé)",
|
||||
HTTP_429: "Erreur 429 (trop de requêtes)",
|
||||
AGE_RESTRICTED: "Restriction d'âge",
|
||||
LOGIN_REQUIRED: "Connexion requise",
|
||||
MEMBERS_ONLY: "Réservé aux membres",
|
||||
PRIVATE: "Vidéo privée",
|
||||
UNAVAILABLE: "Vidéo indisponible",
|
||||
GEO_BLOCKED: "Bloquée dans ce pays",
|
||||
POT_MISSING: "PO token manquant",
|
||||
SABR: "Flux SABR uniquement",
|
||||
JS_CHALLENGE: "Défi JavaScript non résolu",
|
||||
FORMAT_UNAVAILABLE: "Format indisponible",
|
||||
PAGE_RELOAD: "Page à recharger",
|
||||
COOKIES_ROTATED: "Cookies invalidés (rotation)",
|
||||
EMPTY_DOWNLOAD: "Téléchargement vide",
|
||||
BINARY_MISSING: "yt-dlp introuvable",
|
||||
TIMEOUT: "Délai dépassé",
|
||||
UNKNOWN: "Erreur inconnue"
|
||||
};
|
||||
|
||||
export function youtubeClass(cls) {
|
||||
if (!cls) return "Aucune";
|
||||
return YOUTUBE_CLASSES[cls] || cls;
|
||||
}
|
||||
|
||||
// Origine des cookies YouTube ("paste", "file", "legacy", "browser:firefox")
|
||||
export function cookieSource(source) {
|
||||
if (!source) return "Inconnue";
|
||||
if (source === "paste") return "Texte collé";
|
||||
if (source === "file") return "Fichier importé";
|
||||
if (source === "legacy") return "Ancien fichier (reprise automatique)";
|
||||
if (source.startsWith("browser:")) return "Navigateur (" + source.slice(8) + ")";
|
||||
return source;
|
||||
}
|
||||
|
||||
// État du compte YouTube (cookies.getStatus() ou overview.ytdlp.cookies) -> libellé + couleur de Tag
|
||||
export function youtubeAccount(cookies) {
|
||||
if (!cookies?.present) return { key: 'none', label: "Aucun compte", color: "var(--text-secondary)", icon: "fa-solid fa-user-slash" };
|
||||
if (cookies.invalidatedAt) return { key: 'invalid', label: "Invalidé", color: "var(--text-error)", icon: "fa-solid fa-circle-xmark" };
|
||||
if (!cookies.loggedIn) return { key: 'anonymous', label: "Non connecté", color: "var(--text-warning)", icon: "fa-solid fa-triangle-exclamation" };
|
||||
return { key: 'valid', label: "Connecté", color: "var(--text-success)", icon: "fa-solid fa-circle-check" };
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<Box :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)"/>
|
||||
</div>
|
||||
<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)"/>
|
||||
</div>
|
||||
<!-- Paramètres : thème, comptes connectés (YouTube), support, confidentialité, accès à l'administration -->
|
||||
<UserSettings ref="userSettings"/>
|
||||
</Box>
|
||||
|
||||
|
||||
</template>
|
||||
<script setup>
|
||||
import Box from '../UI/Box.vue';
|
||||
@@ -23,9 +22,8 @@ if(!socket.connected) {
|
||||
}
|
||||
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import Modal from '../UI/Modal.vue';
|
||||
import UserSettings from '../Widget/User/UserSettings.vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
<div class="player">
|
||||
<div class="player-line">
|
||||
<VideoList :player-offline="!online" :video="video" />
|
||||
<ActualChannel/>
|
||||
<div class="player-tags">
|
||||
<ActualChannel/>
|
||||
<Tag v-for="tag in statusTags" :key="tag.key" :color="tag.color" :icon="tag.icon" :title="tag.title">{{ tag.text }}</Tag>
|
||||
</div>
|
||||
<DurationBar ref="durationBar" :buffering="buffering" :pause="pause" class="duration-bar" @seek="seek" :offline="!online || !video" :current-time="currentTime" :total-duration="totalDuration" />
|
||||
</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>
|
||||
@@ -16,8 +19,8 @@
|
||||
<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()" :class="{'playIcon': online && video, 'itm-main': true}">
|
||||
<Play v-if="pause || !online"/>
|
||||
<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"/>
|
||||
@@ -35,18 +38,21 @@
|
||||
<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">
|
||||
<DurationBar ref="durationBar" mobile :buffering="buffering" :pause="pause" class="duration-bar" @seek="seek" :offline="!online || !video" :current-time="currentTime" :total-duration="totalDuration" />
|
||||
<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()" :class="{'playIcon': online && video, 'itm-main': true}">
|
||||
<Play v-if="pause || !online"/>
|
||||
<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>
|
||||
</span>
|
||||
<ActualChannel/>
|
||||
<div class="player-tags">
|
||||
<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>
|
||||
@@ -79,7 +85,7 @@ import Play from '@/assets/Icons/Play.vue';
|
||||
import Box from '../UI/Box.vue';
|
||||
import DurationBar from '../UI/DurationBar.vue';
|
||||
import VideoList from "../UI/VideoList.vue";
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { onMounted, onUnmounted, ref, computed, watch } from 'vue';
|
||||
import Forward from '@/assets/Icons/Forward.vue';
|
||||
import Backward from '@/assets/Icons/Backward.vue';
|
||||
import Loop from '@/assets/Icons/Loop.vue';
|
||||
@@ -93,75 +99,121 @@ import Video from '../UI/Video.vue';
|
||||
import Tag from '../UI/Tag.vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { useGlobalStore } from '@/stores/globalStore';
|
||||
import { IOListener, IORequest } from '@/utils/IORequest';
|
||||
import { IORequest, IORequestAsync } from '@/utils/IORequest';
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import ActualChannel from '../Widget/View/Player/ActualChannel.vue';
|
||||
import { socket } from '@/socket';
|
||||
import Events from '@/utils/Events';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
const globalStore = useGlobalStore();
|
||||
// État du player : alimenté par /PLAYER/UPDATE (listener unique enregistré par SocketEnvironment)
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
// Durée d'affichage d'une erreur (lastError ou action refusée)
|
||||
const ERROR_DISPLAY_MS = 10000;
|
||||
const ACTION_ERROR_DISPLAY_MS = 5000;
|
||||
|
||||
const playerInstance = ref(null);
|
||||
const currentTime = ref(0);
|
||||
const totalDuration = ref(0);
|
||||
const pause = ref(true);
|
||||
const online = ref(false); // This should be set based on the actual online status of the player
|
||||
const loop = ref(false);
|
||||
const shuffle = ref(false);
|
||||
const video = ref(null);
|
||||
const playerOpen = ref(false);
|
||||
const durationBar = ref(null);
|
||||
const buffering = ref(false);
|
||||
const pauseBusy = ref(false);
|
||||
|
||||
const playerMobile = ref(null);
|
||||
const playerMobileToggle = ref(null);
|
||||
const playerHeight = ref(0);
|
||||
|
||||
IOListener("/PLAYER/UPDATE", (response) => {
|
||||
updatePlayer(response)
|
||||
})
|
||||
// === ÉTAT DÉRIVÉ DU STATUT ===
|
||||
const status = computed(() => playerStore.status);
|
||||
const online = computed(() => playerStore.online);
|
||||
const video = computed(() => playerStore.current);
|
||||
const loop = computed(() => playerStore.loop);
|
||||
const shuffle = computed(() => playerStore.shuffle);
|
||||
|
||||
Events.on("GUILD_JOINED", () => {
|
||||
askUpdate()
|
||||
})
|
||||
// Icône Play : en pause, hors-ligne, ou chargement qui démarrera en pause (pendingPause)
|
||||
const showPlayIcon = computed(() => {
|
||||
if (!online.value) return true;
|
||||
if (status.value === 'paused') return true;
|
||||
if (status.value === 'loading') return playerStore.pendingPause;
|
||||
return false;
|
||||
});
|
||||
|
||||
Events.on("player:needUpdate", () => {
|
||||
Events.emit("player:update", playerInstance.value);
|
||||
})
|
||||
const pauseTitle = computed(() => {
|
||||
if (status.value === 'loading') return playerStore.pendingPause ? "Démarrera en pause" : "Mettre en pause au démarrage";
|
||||
return showPlayIcon.value ? "Lecture" : "Pause";
|
||||
});
|
||||
|
||||
function askUpdate() {
|
||||
IORequest("/PLAYER/STATE", (response) => {
|
||||
console.log("Ask Player Update")
|
||||
updatePlayer(response)
|
||||
})
|
||||
// Les deux barres (desktop et mobile) lisent les mêmes valeurs du store : plus de ref partagée
|
||||
const durationBarProps = computed(() => ({
|
||||
positionMs: playerStore.positionMs,
|
||||
durationMs: playerStore.durationMs,
|
||||
status: status.value,
|
||||
seekable: playerStore.seekable,
|
||||
revision: playerStore.revision
|
||||
}));
|
||||
|
||||
// === ERREURS (lastError du serveur ou action refusée) ===
|
||||
const shownErrorAt = ref(null);
|
||||
const actionError = ref(null);
|
||||
let errorTimer = null;
|
||||
let actionErrorTimer = null;
|
||||
|
||||
watch(() => playerStore.lastError?.at, (at) => {
|
||||
clearTimeout(errorTimer);
|
||||
shownErrorAt.value = null;
|
||||
if (!at) return;
|
||||
// N'affiche pas une erreur ancienne reçue avec l'état initial
|
||||
const age = Math.max(0, playerStore.serverNow() - at);
|
||||
if (age >= ERROR_DISPLAY_MS) return;
|
||||
shownErrorAt.value = at;
|
||||
errorTimer = setTimeout(() => { shownErrorAt.value = null; }, ERROR_DISPLAY_MS - age);
|
||||
}, { immediate: true });
|
||||
|
||||
function shorten(text, max = 60) {
|
||||
return text.length > max ? text.slice(0, max - 1) + '…' : text;
|
||||
}
|
||||
|
||||
function updatePlayer(player) {
|
||||
if(player) {
|
||||
online.value = player.playerState;
|
||||
pause.value = player.paused;
|
||||
currentTime.value = player.duration ? player.duration : 0;
|
||||
totalDuration.value = player.current ? player.current.duration : 0;
|
||||
video.value = player.current;
|
||||
shuffle.value = player.shuffle;
|
||||
loop.value = player.loop;
|
||||
buffering.value = player.playerState == "buffering";
|
||||
if(durationBar.value) {
|
||||
durationBar.value.updateLocalValues();
|
||||
}
|
||||
playerInstance.value = player;
|
||||
} else {
|
||||
online.value = false;
|
||||
pause.value = true;
|
||||
currentTime.value = 0;
|
||||
totalDuration.value = 0;
|
||||
video.value = null;
|
||||
shuffle.value = false;
|
||||
loop.value = false;
|
||||
buffering.value = false;
|
||||
playerInstance.value = null;
|
||||
}
|
||||
Events.emit("player:update", player);
|
||||
function reasonMessage(reason, fallback) {
|
||||
switch (reason) {
|
||||
case 'no_music': return "Aucune musique en cours";
|
||||
case 'stalled': return "Connexion vocale instable";
|
||||
default: return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// Réponse d'action refusée : false (ancien format), {ok:false} (nouveau) ou "no_music"
|
||||
function isRefused(response) {
|
||||
return response === false || response === "no_music" || (response && typeof response === 'object' && response.ok === false);
|
||||
}
|
||||
|
||||
function showActionError(response, fallback) {
|
||||
const reason = response && typeof response === 'object' ? (response.reason || response.error) : response;
|
||||
actionError.value = { text: reasonMessage(reason, fallback), at: Date.now() };
|
||||
clearTimeout(actionErrorTimer);
|
||||
actionErrorTimer = setTimeout(() => { actionError.value = null; }, ACTION_ERROR_DISPLAY_MS);
|
||||
}
|
||||
|
||||
// Pastilles d'état affichées à côté du salon (aucune dans les états normaux)
|
||||
const statusTags = computed(() => {
|
||||
const tags = [];
|
||||
const lastError = playerStore.lastError;
|
||||
if (actionError.value) {
|
||||
tags.push({ key: 'action-' + actionError.value.at, color: 'var(--text-error)', icon: 'fa-solid fa-circle-exclamation', text: actionError.value.text, title: actionError.value.text });
|
||||
} else if (lastError && shownErrorAt.value === lastError.at) {
|
||||
const message = lastError.message || "Erreur de lecture";
|
||||
tags.push({ key: 'error-' + lastError.at, color: 'var(--text-error)', icon: 'fa-solid fa-circle-exclamation', text: shorten(message), title: message + (lastError.code ? ' (' + lastError.code + ')' : '') });
|
||||
}
|
||||
if (status.value === 'loading') {
|
||||
const text = playerStore.loadingLabel + (playerStore.pendingPause ? ' - en pause' : '');
|
||||
tags.push({ key: 'loading', color: 'var(--text-warning)', icon: 'fa-solid fa-spinner', text, title: text });
|
||||
} else if (status.value === 'stalled') {
|
||||
tags.push({ key: 'stalled', color: 'var(--text-warning)', icon: 'fa-solid fa-triangle-exclamation', text: "Connexion vocale instable", title: "La connexion au salon vocal est instable, la lecture reprendra automatiquement" });
|
||||
}
|
||||
return tags;
|
||||
});
|
||||
|
||||
function onGuildJoined() {
|
||||
playerStore.requestState();
|
||||
}
|
||||
|
||||
Events.on("GUILD_JOINED", onGuildJoined);
|
||||
|
||||
function togglePlayerOpen(event) {
|
||||
if(!playerOpen.value || playerMobileToggle.value.contains(event.target)) {
|
||||
if (playerOpen.value) {
|
||||
@@ -185,10 +237,15 @@ function togglePlayerOpen(event) {
|
||||
}
|
||||
|
||||
|
||||
function togglePause() {
|
||||
IORequest("/PLAYER/PAUSE", (data) => {
|
||||
console.log("Player pause toggled:", data);
|
||||
})
|
||||
// Le résultat réel est affiché : l'icône suit l'état poussé par le serveur (paused / pendingPause),
|
||||
// un refus est signalé au lieu d'être ignoré
|
||||
async function togglePause() {
|
||||
if (pauseBusy.value || !online.value || !video.value) return;
|
||||
pauseBusy.value = true;
|
||||
const data = await IORequestAsync("/PLAYER/PAUSE", undefined, 10000);
|
||||
pauseBusy.value = false;
|
||||
console.log("Player pause toggled:", data);
|
||||
if (isRefused(data)) showActionError(data, "Impossible de mettre en pause");
|
||||
}
|
||||
|
||||
function toggleLoop() {
|
||||
@@ -228,12 +285,15 @@ function forward() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if(video.value) {
|
||||
totalDuration.value = video.value.duration;
|
||||
}
|
||||
socket.removeListener("/PLAYER/STATE")
|
||||
askUpdate()
|
||||
playerStore.requestState();
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
Events.off("GUILD_JOINED", onGuildJoined);
|
||||
clearTimeout(errorTimer);
|
||||
clearTimeout(actionErrorTimer);
|
||||
// Le prochain montage (autre serveur, reconnexion) repart d'un état vierge
|
||||
playerStore.reset();
|
||||
})
|
||||
|
||||
|
||||
@@ -248,18 +308,17 @@ onClickOutside(playerMobile, () => {
|
||||
}
|
||||
});
|
||||
|
||||
function seek(time) {
|
||||
IORequest("/PLAYER/SEEK", (data) => {
|
||||
console.log("Player seeked:", data);
|
||||
}, { time: time })
|
||||
}
|
||||
|
||||
watch(() => video.value, () => {
|
||||
if(video.value) {
|
||||
totalDuration.value = video.value.duration;
|
||||
// Appelé par DurationBar (position en ms, seconde entière) ; renvoie false si le seek est refusé
|
||||
// pour que la barre abandonne immédiatement l'affichage de la cible
|
||||
async function seek(ms) {
|
||||
const data = await IORequestAsync("/PLAYER/SEEK", { time: Math.floor(ms / 1000) }, 10000);
|
||||
console.log("Player seeked:", data);
|
||||
if (isRefused(data)) {
|
||||
showActionError(data, "Impossible de se déplacer dans le morceau");
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
@@ -381,6 +440,14 @@ watch(() => video.value, () => {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Salon + pastilles d'état (chargement, connexion instable, erreur) */
|
||||
.player-tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
|
||||
@media screen and (max-width: 768px),
|
||||
screen and (max-height: 607px) {
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
<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" />
|
||||
<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>
|
||||
<label>Importer depuis YouTube</label>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -34,12 +34,14 @@ import { IORequest } from '@/utils/IORequest';
|
||||
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 { useGlobalStore } from '@/stores/globalStore';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import PlaylistView from '@/components/Widget/View/Playlist/PlaylistView.vue';
|
||||
import { socket } from '@/socket';
|
||||
import { refreshPlaylist } from '@/utils/Logic';
|
||||
|
||||
const modal = ref(null);
|
||||
const playlists = ref([]);
|
||||
@@ -51,8 +53,8 @@ const error = ref(null);
|
||||
|
||||
const globalStore = useGlobalStore();
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
//TODO: Faire la synchro google
|
||||
//TODO: Faire la possibilité d'envoyer une playlist
|
||||
|
||||
|
||||
@@ -69,12 +71,32 @@ 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);
|
||||
});
|
||||
|
||||
function addPlaylist() {
|
||||
if ((!isYoutube && newPlaylistTitle.value?.trim() === '') || (isYoutube && urlLink.value?.trim() === '')) {
|
||||
if ((!isYoutube.value && !newPlaylistTitle.value?.trim()) || (isYoutube.value && !urlLink.value?.trim())) {
|
||||
return;
|
||||
}
|
||||
if(isYoutube.value) newPlaylistTitle.value = urlLink.value;
|
||||
@@ -137,7 +159,7 @@ label {
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px),
|
||||
screen and (max-height: 607) {
|
||||
screen and (max-height: 607px) {
|
||||
.playlist-items {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</Glider>
|
||||
<div class="list-wrapper">
|
||||
<Transition :name="direction">
|
||||
<component :is="selectedTab === 0 ? NextList : PreviousList" :key="selectedTab" :online="online" :nextList="nextList" :previousList="previousList"></component>
|
||||
<component :is="selectedTab === 0 ? NextList : PreviousList" :key="selectedTab" :online="online" v-bind="selectedTab === 0 ? { nextList, nextCount } : { previousList }"></component>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
@@ -16,39 +16,25 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useGlobalStore } from '@/stores/globalStore';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { IORequest } from '@/utils/IORequest';
|
||||
import Glider from '../UI/Glider.vue';
|
||||
import VideoList from '../UI/VideoList.vue';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import Events from '@/utils/Events';
|
||||
import IconAction from '../UI/IconAction.vue';
|
||||
import { useId } from "vue"
|
||||
import VideoQueue from '../UI/VideoQueue.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();
|
||||
const online = ref(false);
|
||||
const nextList = ref(null)
|
||||
const previousList = ref(null)
|
||||
// Files lues directement dans le store du player (plus d'écoute d'événement à retirer)
|
||||
const playerStore = usePlayerStore();
|
||||
const online = computed(() => playerStore.online)
|
||||
const nextList = computed(() => playerStore.next)
|
||||
const nextCount = computed(() => playerStore.nextCount)
|
||||
const previousList = computed(() => playerStore.previous)
|
||||
const direction = ref('forward')
|
||||
|
||||
const id = useId()
|
||||
const selectedTab = ref(0)
|
||||
|
||||
Events.on("player:update", (player) => {
|
||||
if(player) nextList.value = player.next;
|
||||
if(player) previousList.value = player.previous;
|
||||
if(player) online.value = player.playerState
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
Events.emit("player:needUpdate");
|
||||
|
||||
})
|
||||
|
||||
watch(selectedTab, (newTab, oldTab) => {
|
||||
if (newTab > oldTab) {
|
||||
direction.value = 'forward'; // glide vers la gauche
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="announcements">
|
||||
<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"/>
|
||||
<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)"/>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
<script setup>
|
||||
/**
|
||||
* Annonces globales ("/ANNOUNCE") et maintenance ("/MAINTENANCE") pour TOUS les utilisateurs.
|
||||
* Monté une seule fois dans App.vue : comme routes.js fait socket.removeAllListeners() à chaque navigation,
|
||||
* les écouteurs sont réenregistrés après chaque navigation (router.afterEach).
|
||||
*/
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { socket } from '@/socket';
|
||||
import IconAction from '@/components/UI/IconAction.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const items = ref([]);
|
||||
let nextId = 0;
|
||||
|
||||
function dismiss(id) {
|
||||
items.value = items.value.filter(item => item.id !== id);
|
||||
}
|
||||
|
||||
function push(item, duration) {
|
||||
const id = ++nextId;
|
||||
items.value = [...items.value, { ...item, id }].slice(-3);
|
||||
if (duration) setTimeout(() => dismiss(id), duration);
|
||||
return id;
|
||||
}
|
||||
|
||||
function onAnnounce(data) {
|
||||
if (!data?.message) return;
|
||||
const warn = data.level === 'warn';
|
||||
push({
|
||||
kind: warn ? 'warn' : 'info',
|
||||
icon: warn ? "fa-solid fa-triangle-exclamation" : "fa-solid fa-bullhorn",
|
||||
title: warn ? "Avertissement de l'équipe Subsonics" : "Annonce de l'équipe Subsonics",
|
||||
message: data.message
|
||||
}, warn ? null : 20000);
|
||||
}
|
||||
|
||||
function onMaintenance(data) {
|
||||
if (data?.type !== 'restart') return;
|
||||
// Une seule bannière de redémarrage à la fois
|
||||
items.value = items.value.filter(item => item.kind !== 'restart');
|
||||
push({
|
||||
kind: 'restart',
|
||||
icon: "fa-solid fa-spinner",
|
||||
title: "Entracte : Subsonics redémarre",
|
||||
message: data.reason ? `Raison : ${data.reason}. La musique reprendra dans quelques instants.` : "La musique reprendra dans quelques instants."
|
||||
});
|
||||
}
|
||||
|
||||
// Retour du serveur après un redémarrage annoncé : on remplace la bannière
|
||||
function onConnect() {
|
||||
const restart = items.value.find(item => item.kind === 'restart' && !item.back);
|
||||
if (!restart) return;
|
||||
dismiss(restart.id);
|
||||
push({
|
||||
kind: 'restart',
|
||||
back: true,
|
||||
icon: "fa-solid fa-check-circle",
|
||||
title: "Subsonics est de retour !",
|
||||
message: "Le concert peut reprendre."
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
const listeners = [
|
||||
["/ANNOUNCE", onAnnounce],
|
||||
["/MAINTENANCE", onMaintenance],
|
||||
["connect", onConnect]
|
||||
];
|
||||
|
||||
function register() {
|
||||
for (const [event, handler] of listeners) {
|
||||
socket.off(event, handler);
|
||||
socket.on(event, handler);
|
||||
}
|
||||
}
|
||||
|
||||
function unregister() {
|
||||
for (const [event, handler] of listeners) socket.off(event, handler);
|
||||
}
|
||||
|
||||
let removeHook = null;
|
||||
|
||||
onMounted(() => {
|
||||
register();
|
||||
removeHook = router.afterEach(() => register());
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
removeHook?.();
|
||||
unregister();
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.announcements {
|
||||
position: fixed;
|
||||
top: 15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: 5000;
|
||||
width: min(600px, calc(100vw - 30px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.announcement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--secondary);
|
||||
color: var(--text);
|
||||
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.35);
|
||||
border-left: 5px solid var(--main);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.announcement-icon {
|
||||
font-size: 1.5em;
|
||||
color: var(--main);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.announcement.warn {
|
||||
border-left-color: var(--text-warning);
|
||||
}
|
||||
|
||||
.announcement.warn .announcement-icon {
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
.announcement.restart {
|
||||
background: linear-gradient(90deg, var(--main-active), var(--main));
|
||||
border-left-color: var(--main-active);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.announcement.restart .announcement-icon,
|
||||
.announcement.restart .announcement-message {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.announcement-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.announcement-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.announcement-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.announcement-message {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-line;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.announce-enter-active,
|
||||
.announce-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.announce-enter-from,
|
||||
.announce-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,19 @@
|
||||
<template>
|
||||
<div v-if="!buffering" class="duration-bar">
|
||||
<p v-if="!offline && localCurrentTime != 0">{{ getVideoDuration(localCurrentTime) }}</p>
|
||||
<div v-if="!showLoader" class="duration-bar">
|
||||
<p v-if="!inactive">{{ getVideoDuration(displaySec) }}</p>
|
||||
<p class="offline" v-else>-:--</p>
|
||||
<div ref="progressBar" :class="{'progress-bar': true, 'progress-bar-mobile': mobile}" @click="seek" @mousemove="hoverSeek" @mouseleave="hoverPercent = null" @pointerdown="startDrag">
|
||||
<div ref="progressBar" :class="{'progress-bar': true, 'progress-bar-mobile': mobile, 'progress-bar-locked': !canSeek}" @mousemove="hoverSeek" @mouseleave="hoverPercent = null" @pointerdown="startDrag">
|
||||
<span class="progress" :style="{ width: percent + '%' }"></span>
|
||||
<span class="progress-hover" v-if="hoverPercent !== null" :style="{ width: hoverPercent + '%' }"></span>
|
||||
<span :class="{ 'progress-selector': true, 'progress-selector-active': !props.offline }" :style="{ left: percent + '%' }"></span>
|
||||
<span :class="{ 'progress-selector': true, 'progress-selector-active': canSeek }" :style="{ left: percent + '%' }"></span>
|
||||
</div>
|
||||
<p v-if="!offline && localTotalDuration == 0"><Icon color="var(--text-error)" icon="fa-circle"/> LIVE</p>
|
||||
<p v-else-if="!offline">{{ getVideoDuration(localTotalDuration) }}</p>
|
||||
<p v-if="!inactive && isLive"><Icon color="var(--text-error)" icon="fa-circle"/> LIVE</p>
|
||||
<p v-else-if="!inactive">{{ getVideoDuration(durationSec) }}</p>
|
||||
<p class="offline" v-else>-:--</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="offline">-:--</p>
|
||||
<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>
|
||||
@@ -20,142 +21,163 @@
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<p class="offline">-:--</p>
|
||||
<p :class="{'offline': !durationSec}">{{ durationSec ? getVideoDuration(durationSec) : '-:--' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getVideoDuration } from "@/utils/TimeConverter"
|
||||
import { ref, computed, onMounted, watch, onBeforeUnmount } from "vue"
|
||||
import { ref, shallowRef, computed, watch, onBeforeUnmount } from "vue"
|
||||
|
||||
// Barre sans état temporel : la position vient du store (extrapolée depuis l'ancre).
|
||||
// État local uniquement pendant le drag et pour un seek en attente (pendingSeek).
|
||||
const props = defineProps({
|
||||
currentTime: Number,
|
||||
totalDuration: Number,
|
||||
pause: Boolean,
|
||||
offline: Boolean,
|
||||
positionMs: { type: Number, default: 0 },
|
||||
durationMs: { type: Number, default: null },
|
||||
status: { type: String, default: 'offline' },
|
||||
seekable: Boolean,
|
||||
mobile: Boolean,
|
||||
buffering: Boolean
|
||||
// Compteur d'états reçus (playerStore.revision) : détecte l'arrivée d'un état plus récent
|
||||
revision: { type: Number, default: 0 },
|
||||
// Listener @seek du parent, déclaré en prop pour récupérer sa Promise (false = seek refusé)
|
||||
onSeek: Function
|
||||
})
|
||||
|
||||
const localCurrentTime = ref(props.offline ? 0 : props.currentTime)
|
||||
const localTotalDuration = ref(props.offline ? 0 : props.totalDuration)
|
||||
// Délai maximal d'affichage d'un seek en attente sans confirmation du serveur
|
||||
const PENDING_SEEK_TIMEOUT = 15000
|
||||
// Écart toléré entre la cible et la position reçue pour considérer le seek effectué
|
||||
const PENDING_SEEK_TOLERANCE = 2500
|
||||
|
||||
const progressBar = ref(null)
|
||||
const isDragging = ref(false)
|
||||
const dragMs = ref(0)
|
||||
const hoverPercent = ref(null)
|
||||
// { ms, revision, sawLoading } tant que le serveur n'a pas confirmé le seek
|
||||
const pendingSeek = shallowRef(null)
|
||||
let pendingTimer = null
|
||||
|
||||
const emit = defineEmits(["seek"])
|
||||
const inactive = computed(() => props.status === 'offline' || props.status === 'idle')
|
||||
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))
|
||||
|
||||
const displayMs = computed(() => {
|
||||
if (isDragging.value) return dragMs.value
|
||||
if (pendingSeek.value) return pendingSeek.value.ms
|
||||
return props.positionMs
|
||||
})
|
||||
const displaySec = computed(() => Math.floor(displayMs.value / 1000))
|
||||
const durationSec = computed(() => props.durationMs ? Math.floor(props.durationMs / 1000) : 0)
|
||||
// En chargement : afficher la position seulement si elle a un sens (seek ou reprise en cours de morceau)
|
||||
const showLoaderTime = computed(() => !!pendingSeek.value || displayMs.value >= 1000)
|
||||
|
||||
const percent = computed(() => {
|
||||
if (!localTotalDuration.value) return 0
|
||||
const p = (localCurrentTime.value / localTotalDuration.value) * 100
|
||||
if (!props.durationMs) return 0
|
||||
const p = (displayMs.value / props.durationMs) * 100
|
||||
return Math.max(0, Math.min(100, p))
|
||||
})
|
||||
|
||||
const hoverPercent = ref(null)
|
||||
|
||||
function seek(e) {
|
||||
if(props.offline) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const clickPos = (e.clientX - rect.left) / rect.width
|
||||
const newTime = clickPos * localTotalDuration.value
|
||||
localCurrentTime.value = newTime
|
||||
emit("seek", newTime)
|
||||
function positionFromEvent(e) {
|
||||
const rect = progressBar.value.getBoundingClientRect()
|
||||
let pos = (e.clientX - rect.left) / rect.width
|
||||
return Math.max(0, Math.min(1, pos))
|
||||
}
|
||||
|
||||
function hoverSeek(e) {
|
||||
if(props.offline) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
let hoverCalcPercent = ((e.clientX - rect.left) / rect.width) * 100
|
||||
hoverCalcPercent = Math.max(0, Math.min(100, hoverCalcPercent))
|
||||
hoverPercent.value = hoverCalcPercent
|
||||
// Add a tooltip or some indication of the current time like a title attribute
|
||||
e.currentTarget.title = getVideoDuration((hoverCalcPercent / 100) * localTotalDuration.value)
|
||||
if (!canSeek.value) {
|
||||
hoverPercent.value = null
|
||||
e.currentTarget.removeAttribute("title")
|
||||
return
|
||||
}
|
||||
const pos = positionFromEvent(e)
|
||||
hoverPercent.value = pos * 100
|
||||
// Indique la position visée au survol
|
||||
e.currentTarget.title = getVideoDuration(Math.floor(pos * props.durationMs / 1000))
|
||||
}
|
||||
|
||||
// === DRAG LOGIC ===
|
||||
// === DRAG LOGIC === (un clic simple = pointerdown + pointerup, un seul seek émis)
|
||||
function startDrag(e) {
|
||||
if(props.offline) return
|
||||
if (!canSeek.value) return
|
||||
isDragging.value = true
|
||||
updateDrag(e)
|
||||
|
||||
dragMs.value = positionFromEvent(e) * props.durationMs
|
||||
window.addEventListener("pointermove", updateDrag)
|
||||
window.addEventListener("pointerup", stopDrag)
|
||||
window.addEventListener("pointercancel", cancelDrag)
|
||||
}
|
||||
|
||||
function updateDrag(e) {
|
||||
if(props.offline) return
|
||||
if (!isDragging.value) return
|
||||
const rect = e.currentTarget?.getBoundingClientRect?.()
|
||||
|| progressBar.value.getBoundingClientRect()
|
||||
dragMs.value = positionFromEvent(e) * props.durationMs
|
||||
}
|
||||
|
||||
let pos = (e.clientX - rect.left) / rect.width
|
||||
pos = Math.max(0, Math.min(1, pos))
|
||||
function removeDragListeners() {
|
||||
window.removeEventListener("pointermove", updateDrag)
|
||||
window.removeEventListener("pointerup", stopDrag)
|
||||
window.removeEventListener("pointercancel", cancelDrag)
|
||||
}
|
||||
|
||||
const newTime = pos * localTotalDuration.value
|
||||
localCurrentTime.value = newTime
|
||||
function cancelDrag() {
|
||||
isDragging.value = false
|
||||
removeDragListeners()
|
||||
}
|
||||
|
||||
function stopDrag(e) {
|
||||
if(props.offline) return
|
||||
if (!isDragging.value) return
|
||||
removeDragListeners()
|
||||
isDragging.value = false
|
||||
|
||||
const rect = progressBar.value.getBoundingClientRect()
|
||||
let pos = (e.clientX - rect.left) / rect.width
|
||||
pos = Math.max(0, Math.min(1, pos))
|
||||
|
||||
const newTime = pos * localTotalDuration.value
|
||||
localCurrentTime.value = newTime
|
||||
emit("seek", newTime)
|
||||
|
||||
window.removeEventListener("pointermove", updateDrag)
|
||||
window.removeEventListener("pointerup", stopDrag)
|
||||
if (!canSeek.value || !props.durationMs) return
|
||||
// Seconde entière : même valeur que celle appliquée par le serveur
|
||||
const targetSec = Math.floor(positionFromEvent(e) * props.durationMs / 1000)
|
||||
requestSeek(targetSec * 1000)
|
||||
}
|
||||
|
||||
// === INIT ===
|
||||
onMounted(() => {
|
||||
if(!props.offline) {
|
||||
localCurrentTime.value = props.currentTime
|
||||
localTotalDuration.value = props.totalDuration
|
||||
// === SEEK EN ATTENTE ===
|
||||
async function requestSeek(ms) {
|
||||
const pending = { ms, revision: props.revision, sawLoading: false }
|
||||
pendingSeek.value = pending
|
||||
clearTimeout(pendingTimer)
|
||||
pendingTimer = setTimeout(() => clearPendingSeek(pending), PENDING_SEEK_TIMEOUT)
|
||||
let accepted = true
|
||||
try {
|
||||
accepted = props.onSeek ? (await props.onSeek(ms)) !== false : true
|
||||
} catch (e) {
|
||||
accepted = false
|
||||
}
|
||||
if (!accepted) clearPendingSeek(pending)
|
||||
}
|
||||
|
||||
watch(() => props.offline, (newOffline) => {
|
||||
if (newOffline) {
|
||||
localCurrentTime.value = 0
|
||||
localTotalDuration.value = 0
|
||||
}
|
||||
})
|
||||
function clearPendingSeek(pending) {
|
||||
if (pending && pendingSeek.value !== pending) return
|
||||
clearTimeout(pendingTimer)
|
||||
pendingTimer = null
|
||||
pendingSeek.value = null
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
if(!isDragging.value && localCurrentTime.value < localTotalDuration.value && !props.pause) {
|
||||
localCurrentTime.value += 1
|
||||
}
|
||||
}, 1000)
|
||||
// Un état plus récent est arrivé : le seek est terminé quand le serveur n'est plus en chargement
|
||||
// et qu'il a soit traversé un chargement, soit atteint la position visée
|
||||
watch(() => props.revision, () => {
|
||||
const pending = pendingSeek.value
|
||||
if (!pending || props.revision === pending.revision) return
|
||||
if (inactive.value) return clearPendingSeek(pending)
|
||||
if (props.status === 'loading') {
|
||||
pending.sawLoading = true
|
||||
return
|
||||
}
|
||||
if (pending.sawLoading || Math.abs(props.positionMs - pending.ms) <= PENDING_SEEK_TOLERANCE) {
|
||||
clearPendingSeek(pending)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.currentTime, (newTime) => {
|
||||
if(props.offline) return
|
||||
if (!isDragging.value) localCurrentTime.value = newTime
|
||||
})
|
||||
|
||||
watch(() => props.totalDuration, (newDuration) => {
|
||||
if(props.offline) return
|
||||
localTotalDuration.value = newDuration
|
||||
})
|
||||
|
||||
watch(inactive, (value) => {
|
||||
if (value) {
|
||||
cancelDrag()
|
||||
clearPendingSeek()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("pointermove", updateDrag)
|
||||
window.removeEventListener("pointerup", stopDrag)
|
||||
})
|
||||
|
||||
function updateLocalValues() {
|
||||
if(props.offline) return
|
||||
localCurrentTime.value = props.currentTime
|
||||
localTotalDuration.value = props.totalDuration
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
updateLocalValues
|
||||
removeDragListeners()
|
||||
clearTimeout(pendingTimer)
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -180,6 +202,11 @@ defineExpose({
|
||||
background: var(--tertiary);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.progress-bar-locked {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.progress-bar-mobile {
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<div class="metric">
|
||||
<div class="metric-header">
|
||||
<Icon class="metric-icon" :icon="getIcons(metric.name)" />
|
||||
<Icon class="metric-icon" :icon="getIcons(metricName)" />
|
||||
<div class="metric-info">
|
||||
<p class="metric-name">{{ metric.description.replace(server.id, "").replace(server.name, "").replace(":", "") }}</p>
|
||||
<p class="metric-id">{{ metric.name }}</p>
|
||||
<p class="metric-name">{{ label }}</p>
|
||||
<p class="metric-id">{{ metricName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="metric-value" v-if="metric.name.includes('Seconds')">{{ getReadableDuration(metric.value) }}</p>
|
||||
<p v-else class="metric-value">{{ metric.value }}</p>
|
||||
<p class="metric-value" v-if="metricName.includes('Seconds')">{{ formatStatDuration(metric.value) }}</p>
|
||||
<p v-else class="metric-value">{{ formatStatCount(metric.value) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { getReadableDuration } from '@/utils/TimeConverter';
|
||||
import { computed } from 'vue';
|
||||
import { formatStatDuration, formatStatCount } from '@/utils/TimeConverter';
|
||||
|
||||
const props = defineProps({
|
||||
metric: {
|
||||
@@ -26,6 +26,18 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
// Valeurs sûres : le nom ou la description peuvent manquer sur une ancienne métrique
|
||||
const metricName = computed(() => typeof props.metric?.name === "string" ? props.metric.name : "")
|
||||
|
||||
// Description sans l'identifiant ni le nom du serveur (« ... sur le serveur : 1234 » → « ... sur le serveur »)
|
||||
const label = computed(() => {
|
||||
let text = typeof props.metric?.description === "string" ? props.metric.description : metricName.value
|
||||
for (const part of [props.server?.id, props.server?.name]) {
|
||||
if (part) text = text.split(String(part)).join("")
|
||||
}
|
||||
return text.replace(":", "").trim()
|
||||
})
|
||||
|
||||
function getIcons(name) {
|
||||
if(name.includes("Commands")) return "fa-solid fa-terminal";
|
||||
if(name.includes("Music")) return "fa-solid fa-music";
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
<template>
|
||||
<div class="metrics" v-if="metrics && metrics.length > 0">
|
||||
<Metric v-for="metric in metrics" :key="metric.id" :metric="metric" :server="server"/>
|
||||
<div class="metrics" v-if="metrics.length > 0">
|
||||
<Metric v-for="metric in metrics" :key="metric.name" :metric="metric" :server="server"/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="second">Aucune statistique enregistrée !</p>
|
||||
<p class="second" v-if="loading">Chargement des statistiques...</p>
|
||||
<p class="second" v-else-if="error">{{ error }}</p>
|
||||
<p class="second" v-else>Aucune statistique enregistrée !</p>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { IORequest } from '@/utils/IORequest';
|
||||
import { IORequestAsync } from '@/utils/IORequest';
|
||||
import Events from '@/utils/Events';
|
||||
import { ref } from 'vue';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { getReadableDuration } from '@/utils/TimeConverter';
|
||||
import Metric from '@/components/UI/Metric.vue';
|
||||
|
||||
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const metrics = ref(null);
|
||||
const metrics = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const props = defineProps({
|
||||
server: {
|
||||
@@ -27,12 +30,50 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
Events.on("GUILD_JOINED", () => {
|
||||
if(!(userStore.userInfo.identity.isAdmin || userStore.userInfo.identity.isMod || userStore.userInfo.identity.id == props.server.owner)) return;
|
||||
IORequest("/MOD/STATS", (data) => {
|
||||
metrics.value = data;
|
||||
function canSeeStats() {
|
||||
const identity = userStore.userInfo?.identity;
|
||||
if(!identity) return false;
|
||||
return identity.isAdmin || identity.isMod || identity.id == props.server?.owner;
|
||||
}
|
||||
|
||||
// Ne garde que les métriques exploitables (nom présent, une seule fois par nom)
|
||||
function sanitize(data) {
|
||||
if(!Array.isArray(data)) return [];
|
||||
const seen = new Set();
|
||||
return data.filter(metric => {
|
||||
if(!metric || typeof metric.name !== "string" || seen.has(metric.name)) return false;
|
||||
seen.add(metric.name);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
if(!canSeeStats()) {
|
||||
// Pas de droits sur ce serveur : ne pas laisser affichées les statistiques du serveur précédent
|
||||
metrics.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
const data = await IORequestAsync("/MOD/STATS");
|
||||
loading.value = false;
|
||||
if(data === null) {
|
||||
error.value = "Impossible de récupérer les statistiques (aucune réponse du serveur).";
|
||||
return;
|
||||
}
|
||||
if(!Array.isArray(data)) {
|
||||
// false : accès refusé ou serveur introuvable ; objet : erreur interne
|
||||
metrics.value = [];
|
||||
error.value = "Statistiques indisponibles pour ce serveur.";
|
||||
return;
|
||||
}
|
||||
metrics.value = sanitize(data);
|
||||
}
|
||||
|
||||
// Chargement au montage (paramètres ouverts après l'arrivée sur le serveur) et à chaque changement de serveur
|
||||
onMounted(loadStats);
|
||||
Events.on("GUILD_JOINED", loadStats);
|
||||
onUnmounted(() => Events.off("GUILD_JOINED", loadStats));
|
||||
|
||||
</script>
|
||||
|
||||
@@ -51,4 +92,4 @@ Events.on("GUILD_JOINED", () => {
|
||||
max-height: 30vh;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -10,6 +10,8 @@
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<!-- Le serveur limite la liste envoyée (100 titres) : indiquer les titres non affichés -->
|
||||
<p class="more-items" v-if="online && hiddenCount > 0">+ {{ hiddenCount }} titre{{ hiddenCount > 1 ? 's' : '' }} dans la liste de lecture</p>
|
||||
<div class="allSpace" v-if="online && (!nextList || nextList.length === 0)">
|
||||
<p class="text-secondary"><Icon font-size="2em" icon="fa-solid fa-circle-xmark"/> Aucun élément dans la liste de lecture</p>
|
||||
</div>
|
||||
@@ -70,8 +72,14 @@ const localNextList = ref([])
|
||||
|
||||
const isAFile = computed(() => {
|
||||
if(selectedIndex.value == null) return false;
|
||||
const video = props.nextList[selectedIndex.value];
|
||||
return video.type === "attachment";
|
||||
const video = props.nextList?.[selectedIndex.value];
|
||||
return video?.type === "attachment";
|
||||
})
|
||||
|
||||
// Titres présents côté serveur mais non envoyés (nextCount = taille réelle de la file)
|
||||
const hiddenCount = computed(() => {
|
||||
const shown = props.nextList ? props.nextList.length : 0;
|
||||
return Number.isFinite(props.nextCount) ? Math.max(0, props.nextCount - shown) : 0;
|
||||
})
|
||||
|
||||
const dragOptions = {
|
||||
@@ -90,15 +98,17 @@ const dragOptions = {
|
||||
const props = defineProps({
|
||||
selectedTab: Number,
|
||||
online: {},
|
||||
nextList: Array
|
||||
nextList: Array,
|
||||
nextCount: Number
|
||||
})
|
||||
|
||||
// Copie locale : le drag réordonne cette copie sans modifier l'état du store
|
||||
watch(() => props.nextList, (newList) => {
|
||||
localNextList.value = newList
|
||||
localNextList.value = newList ? [...newList] : []
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
localNextList.value = props.nextList
|
||||
localNextList.value = props.nextList ? [...props.nextList] : []
|
||||
})
|
||||
|
||||
|
||||
@@ -204,6 +214,13 @@ function onDragEnd(event) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.more-items {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<section class="list-container">
|
||||
<div v-if="previousList && previousList.length > 0" v-for="(video, index) in previousList" :key="id" class="list-video">
|
||||
<div v-if="previousList && previousList.length > 0" v-for="(video, index) in previousList" :key="index" class="list-video">
|
||||
<VideoQueue little :video="video" :index="index"></VideoQueue>
|
||||
<IconAction @click="setSelectedIndex(index)" icon="fa-ellipsis-vertical"></IconAction>
|
||||
</div>
|
||||
@@ -60,8 +60,8 @@ const playlistSelector = ref(null)
|
||||
|
||||
const isAFile = computed(() => {
|
||||
if(selectedIndex.value == null) return false;
|
||||
const video = props.previousList[selectedIndex.value];
|
||||
return video.type === "attachment";
|
||||
const video = props.previousList?.[selectedIndex.value];
|
||||
return video?.type === "attachment";
|
||||
})
|
||||
|
||||
function setSelectedIndex(index) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<DefaultSplash width="600px;">
|
||||
<template v-if="state === 'pending'">
|
||||
<h1><Icon spin-pulse icon="fa-solid fa-spinner"/> Connexion à YouTube</h1>
|
||||
<p>Liaison de votre compte en cours, veuillez patienter.</p>
|
||||
</template>
|
||||
<template v-else-if="state === 'success'">
|
||||
<h1><Icon icon="fa-brands fa-youtube"/> Compte YouTube connecté</h1>
|
||||
<Success>{{ message }}</Success>
|
||||
<p class="second">Redirection en cours…</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h1><Icon icon="fa-brands fa-youtube"/> Connexion à YouTube impossible</h1>
|
||||
<Error>{{ message }}</Error>
|
||||
<Button icon="fa-solid fa-arrow-left" @click="goBack()">Retour aux paramètres</Button>
|
||||
</template>
|
||||
</DefaultSplash>
|
||||
</template>
|
||||
<script setup>
|
||||
// Retour du consentement Google (/oauth2callback?code=...&state=...) : le code est transmis au serveur
|
||||
// par le socket authentifié, puis l'utilisateur revient sur la page d'où il est parti.
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import DefaultSplash from '@/components/UI/DefaultSplash.vue';
|
||||
import Button from '@/components/UI/Button.vue';
|
||||
import Error from '@/components/UI/Error.vue';
|
||||
import Success from '@/components/UI/Success.vue';
|
||||
import { IORequestAsync } from '@/utils/IORequest';
|
||||
import { useGlobalStore } from '@/stores/globalStore';
|
||||
import { HANDLED_KEY, REOPEN_KEY, RETURN_KEY, storageGet, storageSet } from './googleOAuthReturn';
|
||||
|
||||
// HANDLED_KEY : un state n'est utilisable qu'une fois, évite un second envoi si le composant est remonté (reconnexion du socket)
|
||||
const REDIRECT_DELAY_MS = 1500;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const globalStore = useGlobalStore();
|
||||
|
||||
const state = ref('pending');
|
||||
const message = ref('');
|
||||
let redirectTimer = null;
|
||||
|
||||
// Page d'origine (mémorisée avant de partir chez Google), sinon dernière page connue, sinon la liste des serveurs
|
||||
function returnRoute() {
|
||||
const saved = storageGet(RETURN_KEY);
|
||||
if (saved && saved.startsWith('/') && !saved.startsWith('//') && !saved.startsWith('/oauth2callback')) return saved;
|
||||
if (globalStore.lastRoute && globalStore.lastRoute !== '/oauth2callback') return globalStore.lastRoute;
|
||||
return '/servers';
|
||||
}
|
||||
|
||||
// Retour sur la page d'origine, où les paramètres se rouvrent sur « Comptes connectés » (UserSettings.vue)
|
||||
function goBack() {
|
||||
const target = returnRoute();
|
||||
storageSet(RETURN_KEY, null);
|
||||
storageSet(REOPEN_KEY, String(Date.now()));
|
||||
router.replace(target);
|
||||
}
|
||||
|
||||
function fail(text) {
|
||||
state.value = 'error';
|
||||
message.value = text;
|
||||
}
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
access_denied: "Vous avez refusé l'accès à votre compte YouTube.",
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const code = typeof route.query.code === 'string' ? route.query.code : null;
|
||||
const oauthState = typeof route.query.state === 'string' ? route.query.state : null;
|
||||
if (route.query.error) {
|
||||
return fail(ERROR_MESSAGES[route.query.error] || "Google a refusé la connexion (" + route.query.error + ").");
|
||||
}
|
||||
if (!code || !oauthState) {
|
||||
return fail('Réponse de Google incomplète : relancez la connexion depuis Paramètres > Comptes connectés.');
|
||||
}
|
||||
if (storageGet(HANDLED_KEY) === oauthState) {
|
||||
// Déjà transmis (composant remonté) : simple retour
|
||||
state.value = 'success';
|
||||
message.value = 'Votre compte YouTube est connecté.';
|
||||
redirectTimer = setTimeout(goBack, REDIRECT_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
storageSet(HANDLED_KEY, oauthState);
|
||||
const response = await IORequestAsync('/GOOGLE/CALLBACK', { code, state: oauthState }, 50000);
|
||||
if (!response) return fail('Le serveur ne répond pas, veuillez réessayer.');
|
||||
if (!response.ok) return fail(response.message || 'La liaison du compte a échoué.');
|
||||
const channel = response.status?.channel?.title;
|
||||
state.value = 'success';
|
||||
message.value = (channel ? `Chaîne « ${channel} » liée. ` : 'Compte lié. ') + "Vos playlists sont en cours d'importation.";
|
||||
redirectTimer = setTimeout(goBack, REDIRECT_DELAY_MS);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(redirectTimer);
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.second {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
<div @click="deleteAccount = !deleteAccount" class="options">
|
||||
<input v-model="deleteAccount" type="checkbox" id="deleteAccount"/>
|
||||
<p class="text-second">Je suis sur de vouloir supprimer mon compte et toutes les données associées.</p>
|
||||
<p class="text-second">Je suis sûr de vouloir supprimer mon compte et toutes les données associées.</p>
|
||||
</div>
|
||||
<Button @click="closeModal()">Annuler</Button>
|
||||
<Button :disabled="!deleteAccount" @click="deleteAcc()">Confirmer la suppression</Button>
|
||||
|
||||
@@ -1,47 +1,122 @@
|
||||
<template>
|
||||
<Modal ref="modal" icon="fa-solid fa-gear" title="Paramètres">
|
||||
<Modal ref="modal" icon="fa-solid fa-gear" title="Paramètres">
|
||||
<ModalTree v-if="showAdmin" class="admin-tree" title="Administration" icon="fa-solid fa-star">
|
||||
<div class="admin-entry">
|
||||
<p class="settings-text">Vous êtes administrateur de Subsonics : gérez le bot, les utilisateurs, les serveurs et la lecture YouTube.</p>
|
||||
<Button class="admin-button" icon="fa-solid fa-screwdriver-wrench" @click="goToAdmin()">Ouvrir l'administration</Button>
|
||||
</div>
|
||||
</ModalTree>
|
||||
<ModalTree title="Thème" icon="fa-solid fa-palette">
|
||||
<ThemeSelector />
|
||||
</ModalTree>
|
||||
<ModalTree title="Comptes connectés" icon="fa-solid fa-link">
|
||||
<div ref="accountsSection">
|
||||
<!-- Monté à la première ouverture : pas de requête ni d'écouteur tant que les paramètres restent fermés -->
|
||||
<YoutubeAccount v-if="accountsLoaded" ref="youtubeAccount" />
|
||||
</div>
|
||||
</ModalTree>
|
||||
<ModalTree title="Support" icon="fa-solid fa-headset">
|
||||
<BugReport/>
|
||||
<BugReport />
|
||||
</ModalTree>
|
||||
<ModalTree title="Mon compte & Confidentialité" icon="fa-solid fa-shield-halved">
|
||||
<PrivacySettings />
|
||||
</ModalTree>
|
||||
<ModalTree title="Informations" icon="fa-solid fa-circle-info">
|
||||
<InformationSettings/>
|
||||
<ModalTree title="Informations" icon="fa-solid fa-circle-info">
|
||||
<InformationSettings />
|
||||
</ModalTree>
|
||||
</Modal>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Modal from '@/components/UI/Modal.vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import ThemeSelector from '@/components/Widget/User/Settings/ThemeSelector.vue';
|
||||
import ModalTree from '@/components/UI/ModalTree.vue';
|
||||
import Button from '@/components/UI/Button.vue';
|
||||
import ThemeSelector from './Settings/ThemeSelector.vue';
|
||||
import BugReport from './Settings/BugReport.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import PrivacySettings from './Settings/PrivacySettings.vue';
|
||||
import InformationSettings from './Settings/InformationSettings.vue';
|
||||
import YoutubeAccount from './YoutubeAccount.vue';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { REOPEN_KEY, storageTake } from './googleOAuthReturn';
|
||||
|
||||
const REOPEN_MAX_AGE_MS = 2 * 60 * 1000;
|
||||
|
||||
const modal = ref(null);
|
||||
const youtubeAccount = ref(null);
|
||||
const accountsSection = ref(null);
|
||||
const accountsLoaded = ref(false);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// userInfo est remplacé à chaque /USER/READY : le bouton suit le label en direct
|
||||
const isAdmin = computed(() => !!userStore.userInfo?.labels?.includes('ADMIN'));
|
||||
const showAdmin = computed(() => isAdmin.value && !route.path.startsWith('/admin'));
|
||||
|
||||
function open() {
|
||||
if (!modal.value) return;
|
||||
modal.value.open();
|
||||
if (accountsLoaded.value) youtubeAccount.value?.refresh();
|
||||
else accountsLoaded.value = true;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open() {
|
||||
if (modal.value) {
|
||||
modal.value.open();
|
||||
}
|
||||
},
|
||||
close() {
|
||||
if (modal.value) {
|
||||
modal.value.close();
|
||||
}
|
||||
function close() {
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
function goToAdmin() {
|
||||
close();
|
||||
router.push('/admin');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Retour de la liaison Google : on rouvre les paramètres sur la section « Comptes connectés »
|
||||
// (horodaté : une marque oubliée ne rouvre pas les paramètres plus tard par surprise)
|
||||
const reopenAt = Number(storageTake(REOPEN_KEY));
|
||||
if (reopenAt && Date.now() - reopenAt < REOPEN_MAX_AGE_MS) {
|
||||
open();
|
||||
nextTick(() => accountsSection.value?.scrollIntoView({ block: 'nearest' }));
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
<style scoped>
|
||||
.settings-text {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-secondary);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
.admin-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.admin-tree :deep(.settings-icon),
|
||||
.admin-tree :deep(.settings-icon-replace) {
|
||||
color: var(--admin-color);
|
||||
}
|
||||
|
||||
.admin-button {
|
||||
background-color: var(--admin-color);
|
||||
}
|
||||
|
||||
.admin-button:hover {
|
||||
background-color: color-mix(in srgb, var(--admin-color) 85%, black);
|
||||
}
|
||||
|
||||
.admin-button:active {
|
||||
background-color: color-mix(in srgb, var(--admin-color) 70%, black);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px),
|
||||
screen and (max-height: 607px) {
|
||||
.admin-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,483 @@
|
||||
<template>
|
||||
<!-- Compte YouTube (Google) de l'utilisateur : import et synchronisation de ses playlists -->
|
||||
<div class="youtube-account">
|
||||
<p class="yt-desc">
|
||||
Liez votre compte YouTube pour importer vos playlists (y compris privées et « J'aime ») dans Subsonics et les garder à jour.
|
||||
L'accès demandé à Google est en lecture seule.
|
||||
</p>
|
||||
<Info secondary>Sans rapport avec les cookies YouTube du bot, gérés par les administrateurs pour la lecture des musiques.</Info>
|
||||
|
||||
<p v-if="!status && !loadFailed" class="yt-state">
|
||||
<Icon icon="fa-solid fa-spinner" spin-pulse /> Chargement de l'état du compte…
|
||||
</p>
|
||||
<div v-else-if="!status" class="yt-state-error">
|
||||
<Error>Impossible de récupérer l'état de votre compte YouTube.</Error>
|
||||
<Button icon="fa-solid fa-rotate-right" :disabled="loading" @click="loadStatus()">Réessayer</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="yt-card" :class="{ disabled: !status.configured }">
|
||||
<div class="yt-header">
|
||||
<img v-if="avatar" class="yt-avatar" :src="avatar" alt="" referrerpolicy="no-referrer" @error="avatarFailed = true">
|
||||
<Icon v-else class="yt-icon" icon="fa-brands fa-youtube" />
|
||||
<div class="yt-info">
|
||||
<p class="yt-title" :title="title">{{ title }}</p>
|
||||
<p class="yt-sub">{{ subtitle }}</p>
|
||||
</div>
|
||||
<Tag class="yt-tag" :color="tag.color" :icon="tag.icon">{{ tag.label }}</Tag>
|
||||
</div>
|
||||
|
||||
<ul v-if="linkedOk" class="yt-details">
|
||||
<li :class="{ syncing: status.syncing }">
|
||||
<Icon :icon="status.syncing ? 'fa-solid fa-spinner' : 'fa-solid fa-clock-rotate-left'" :spin-pulse="status.syncing" />
|
||||
{{ lastSyncText }}
|
||||
</li>
|
||||
<li v-if="status.lastSync?.ok && !status.syncing">
|
||||
<Icon icon="fa-solid fa-list" />
|
||||
{{ countText(status.lastSync) }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Error v-if="status.linked && !status.valid" class="yt-alert">
|
||||
{{ status.invalidReason || "L'autorisation Google n'est plus valide" }} : reconnectez votre compte.
|
||||
</Error>
|
||||
<Error v-else-if="lastSyncFailed" class="yt-alert">
|
||||
Échec de la dernière synchronisation : {{ status.lastSync.message || 'erreur inconnue' }}
|
||||
</Error>
|
||||
<Info v-if="!status.configured" secondary>
|
||||
La liaison de comptes YouTube n'est pas configurée sur ce bot (identifiants Google manquants). Contactez un administrateur.
|
||||
</Info>
|
||||
|
||||
<!-- Confirmation de déconnexion -->
|
||||
<div v-if="confirmRevoke" class="yt-confirm">
|
||||
<p>Déconnecter ce compte YouTube ? Les playlists déjà importées sont conservées mais ne seront plus synchronisées.</p>
|
||||
<div class="yt-actions">
|
||||
<Button class="yt-cancel" icon="fa-solid fa-xmark" :disabled="busy" @click="confirmRevoke = false">Annuler</Button>
|
||||
<Button icon="fa-solid fa-link-slash" :disabled="busy" @click="revoke()">
|
||||
{{ pending === 'revoke' ? 'Déconnexion…' : 'Déconnecter' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="yt-actions">
|
||||
<Button v-if="!status.configured" icon="fa-solid fa-link" disabled>Connecter mon compte</Button>
|
||||
<Button v-else-if="!status.linked" icon="fa-solid fa-link" :disabled="busy" @click="connect()">
|
||||
{{ pending === 'connect' ? 'Redirection vers Google…' : 'Connecter mon compte' }}
|
||||
</Button>
|
||||
<template v-else>
|
||||
<Button v-if="!status.valid" icon="fa-solid fa-link" :disabled="busy" @click="connect()">
|
||||
{{ pending === 'connect' ? 'Redirection vers Google…' : 'Reconnecter' }}
|
||||
</Button>
|
||||
<Button v-else icon="fa-solid fa-rotate" :disabled="busy || status.syncing || cooldownLeft > 0" @click="sync()"
|
||||
title="Importer / mettre à jour les playlists du compte">
|
||||
{{ status.syncing || pending === 'sync' ? 'Synchronisation…' : 'Synchroniser' }}
|
||||
</Button>
|
||||
<Button class="yt-cancel" icon="fa-solid fa-link-slash" :disabled="busy" @click="confirmRevoke = true">Déconnecter</Button>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="linkedOk && !status.syncing && cooldownLeft > 0" class="yt-hint">
|
||||
Prochaine synchronisation possible dans {{ cooldownLeft }} s.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="yt-feedback" v-if="feedback">
|
||||
<Error v-if="feedback.error">{{ feedback.text }}</Error>
|
||||
<Success v-else>{{ feedback.text }}</Success>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Button from '@/components/UI/Button.vue';
|
||||
import Error from '@/components/UI/Error.vue';
|
||||
import Info from '@/components/UI/Info.vue';
|
||||
import Success from '@/components/UI/Success.vue';
|
||||
import Tag from '@/components/UI/Tag.vue';
|
||||
import { socket } from '@/socket';
|
||||
import { IORequestAsync } from '@/utils/IORequest';
|
||||
import { RETURN_KEY, storageSet } from './googleOAuthReturn';
|
||||
|
||||
const FEEDBACK_MS = 8000;
|
||||
// Aligné sur SYNC_COOLDOWN_MS (backend, PlaylistManager.js)
|
||||
const SYNC_COOLDOWN_MS = 60 * 1000;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const status = ref(null);
|
||||
const loadFailed = ref(false);
|
||||
const loading = ref(false);
|
||||
// Action en cours : 'connect' | 'sync' | 'revoke' | null
|
||||
const pending = ref(null);
|
||||
const busy = computed(() => pending.value !== null);
|
||||
const confirmRevoke = ref(false);
|
||||
const avatarFailed = ref(false);
|
||||
const feedback = ref(null);
|
||||
const cooldownUntil = ref(0);
|
||||
const now = ref(Date.now());
|
||||
let feedbackTimer = null;
|
||||
let cooldownTimer = null;
|
||||
let alive = true;
|
||||
|
||||
const linkedOk = computed(() => !!(status.value?.linked && status.value?.valid));
|
||||
const lastSyncFailed = computed(() => linkedOk.value && !status.value.syncing && status.value.lastSync && !status.value.lastSync.ok);
|
||||
const cooldownLeft = computed(() => Math.max(0, Math.ceil((cooldownUntil.value - now.value) / 1000)));
|
||||
|
||||
const avatar = computed(() => {
|
||||
const url = status.value?.linked ? status.value?.channel?.thumbnail : null;
|
||||
return url && !avatarFailed.value ? url : null;
|
||||
});
|
||||
|
||||
const title = computed(() => {
|
||||
const s = status.value;
|
||||
if (!s?.linked) return 'YouTube';
|
||||
return s.channel?.title || 'Compte YouTube';
|
||||
});
|
||||
|
||||
const subtitle = computed(() => {
|
||||
const s = status.value;
|
||||
if (!s) return '';
|
||||
if (!s.configured) return 'Liaison indisponible sur ce bot';
|
||||
if (!s.linked) return 'Aucun compte lié';
|
||||
if (!s.channel) return s.linkedAt ? `Compte Google sans chaîne, lié le ${formatDate(s.linkedAt, false)}` : 'Compte Google sans chaîne';
|
||||
return s.linkedAt ? `Lié le ${formatDate(s.linkedAt, false)}` : 'Compte lié';
|
||||
});
|
||||
|
||||
const tag = computed(() => {
|
||||
const s = status.value;
|
||||
if (!s?.configured) return { label: 'Indisponible', color: 'var(--text-secondary)', icon: 'fa-solid fa-ban' };
|
||||
if (!s.linked) return { label: 'Non connecté', color: 'var(--text-secondary)', icon: 'fa-solid fa-circle' };
|
||||
if (!s.valid) return { label: 'À reconnecter', color: 'var(--text-warning)', icon: 'fa-solid fa-triangle-exclamation' };
|
||||
return { label: 'Connecté', color: 'var(--text-success)', icon: 'fa-solid fa-circle-check' };
|
||||
});
|
||||
|
||||
const lastSyncText = computed(() => {
|
||||
const s = status.value;
|
||||
if (s?.syncing) return 'Synchronisation en cours…';
|
||||
if (!s?.lastSync?.at) return 'Jamais synchronisé';
|
||||
const when = formatDate(s.lastSync.at, true);
|
||||
return s.lastSync.ok ? `Dernière synchronisation le ${when}` : `Dernière tentative le ${when}`;
|
||||
});
|
||||
|
||||
function formatDate(iso, withTime) {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '?';
|
||||
return date.toLocaleString('fr-FR', withTime ? { dateStyle: 'short', timeStyle: 'short' } : { dateStyle: 'long' });
|
||||
}
|
||||
|
||||
function plural(count, singular, pluralForm) {
|
||||
return `${count} ${count > 1 ? pluralForm : singular}`;
|
||||
}
|
||||
|
||||
function countText(lastSync) {
|
||||
const parts = [
|
||||
plural(lastSync.playlists || 0, 'playlist importée', 'playlists importées'),
|
||||
plural(lastSync.songs || 0, 'titre', 'titres')
|
||||
];
|
||||
if (lastSync.skipped > 0) parts.push(plural(lastSync.skipped, 'ignorée', 'ignorées'));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function showFeedback(text, error = false) {
|
||||
feedback.value = { text, error };
|
||||
clearTimeout(feedbackTimer);
|
||||
feedbackTimer = setTimeout(() => { feedback.value = null; }, FEEDBACK_MS);
|
||||
}
|
||||
|
||||
function startCooldown(ms) {
|
||||
cooldownUntil.value = Date.now() + ms;
|
||||
now.value = Date.now();
|
||||
clearInterval(cooldownTimer);
|
||||
cooldownTimer = setInterval(() => {
|
||||
now.value = Date.now();
|
||||
if (now.value >= cooldownUntil.value) {
|
||||
clearInterval(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Délai restant estimé depuis l'heure de la dernière synchronisation (ignoré si les horloges divergent)
|
||||
function cooldownFromStatus(s) {
|
||||
if (!s?.lastSync?.ok || !s.lastSync.at) return false;
|
||||
const elapsed = Date.now() - Date.parse(s.lastSync.at);
|
||||
if (!Number.isFinite(elapsed) || elapsed < 0 || elapsed >= SYNC_COOLDOWN_MS) return false;
|
||||
startCooldown(SYNC_COOLDOWN_MS - elapsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fin de synchronisation détectée par le passage syncing true -> false
|
||||
function applyStatus(next) {
|
||||
if (!next || typeof next !== 'object') return;
|
||||
const previous = status.value;
|
||||
status.value = next;
|
||||
loadFailed.value = false;
|
||||
if (previous?.channel?.thumbnail !== next.channel?.thumbnail) avatarFailed.value = false;
|
||||
if (!next.linked) confirmRevoke.value = false;
|
||||
if (previous?.syncing && !next.syncing && next.lastSync) {
|
||||
if (next.lastSync.ok) {
|
||||
// Les playlists affichées sont rafraîchies par /PLAYLISTS/UPDATE (Layout/Playlist.vue)
|
||||
showFeedback('Synchronisation terminée : ' + countText(next.lastSync));
|
||||
startCooldown(SYNC_COOLDOWN_MS);
|
||||
} else {
|
||||
showFeedback(next.lastSync.message || 'La synchronisation a échoué', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
loadFailed.value = false;
|
||||
const response = await IORequestAsync('/GOOGLE/STATUS', undefined, 10000);
|
||||
if (!alive) return;
|
||||
loading.value = false;
|
||||
if (response && typeof response === 'object') {
|
||||
applyStatus(response);
|
||||
cooldownFromStatus(response);
|
||||
} else if (!status.value) {
|
||||
loadFailed.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
pending.value = 'connect';
|
||||
const url = await IORequestAsync('/GOOGLE/AUTH', undefined, 10000);
|
||||
if (!alive) return;
|
||||
let valid = false;
|
||||
try {
|
||||
valid = typeof url === 'string' && new URL(url).protocol === 'https:';
|
||||
} catch (e) {
|
||||
valid = false;
|
||||
}
|
||||
if (!valid) {
|
||||
pending.value = null;
|
||||
showFeedback("Impossible d'obtenir le lien de connexion Google, réessayez plus tard.", true);
|
||||
// La configuration a peut-être changé côté serveur
|
||||
loadStatus();
|
||||
return;
|
||||
}
|
||||
// Page d'origine : GoogleCallback y revient puis rouvre les paramètres
|
||||
storageSet(RETURN_KEY, route.fullPath);
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
async function sync() {
|
||||
pending.value = 'sync';
|
||||
const response = await IORequestAsync('/GOOGLE/YOUTUBE/SYNC', undefined, 15000);
|
||||
if (!alive) return;
|
||||
pending.value = null;
|
||||
if (!response) return showFeedback('Le serveur ne répond pas, réessayez plus tard.', true);
|
||||
if (response.status) applyStatus(response.status);
|
||||
if (response.ok) return;
|
||||
if (response.error === 'COOLDOWN' && !cooldownFromStatus(status.value)) startCooldown(15 * 1000);
|
||||
showFeedback(response.message || 'Synchronisation impossible', true);
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
pending.value = 'revoke';
|
||||
const response = await IORequestAsync('/GOOGLE/REVOKE', undefined, 30000);
|
||||
if (!alive) return;
|
||||
pending.value = null;
|
||||
confirmRevoke.value = false;
|
||||
if (!response?.ok) return showFeedback('La déconnexion a échoué, réessayez plus tard.', true);
|
||||
if (response.status) applyStatus(response.status);
|
||||
if (response.removed && !response.revoked) {
|
||||
showFeedback("Compte YouTube déconnecté. Google n'a pas confirmé la révocation : vous pouvez retirer l'accès depuis votre compte Google.");
|
||||
} else {
|
||||
showFeedback('Compte YouTube déconnecté.');
|
||||
}
|
||||
}
|
||||
|
||||
// routes.js retire tous les écouteurs socket à chaque navigation : réenregistrement après chacune
|
||||
function register() {
|
||||
socket.off('/GOOGLE/STATUS/UPDATE', applyStatus);
|
||||
socket.on('/GOOGLE/STATUS/UPDATE', applyStatus);
|
||||
}
|
||||
|
||||
let removeHook = null;
|
||||
|
||||
onMounted(() => {
|
||||
register();
|
||||
removeHook = router.afterEach(() => register());
|
||||
loadStatus();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
alive = false;
|
||||
removeHook?.();
|
||||
socket.off('/GOOGLE/STATUS/UPDATE', applyStatus);
|
||||
clearTimeout(feedbackTimer);
|
||||
clearInterval(cooldownTimer);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
// Appelé à chaque ouverture des paramètres
|
||||
refresh() {
|
||||
confirmRevoke.value = false;
|
||||
if (!busy.value) loadStatus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.youtube-account {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.youtube-account p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.yt-desc {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.yt-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.yt-state-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.yt-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
.yt-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.yt-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-icon {
|
||||
width: 40px;
|
||||
font-size: 2em;
|
||||
color: #ff0033;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.disabled .yt-icon {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.yt-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.yt-title {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.yt-sub {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.yt-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-details {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.8em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.yt-details li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.yt-details li.syncing {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.yt-alert {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.yt-confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.yt-confirm p {
|
||||
font-size: 0.85em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.yt-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.yt-actions > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Action secondaire (Annuler / Déconnecter) : moins mise en avant que l'action principale */
|
||||
.yt-cancel:not(.disabled) {
|
||||
background-color: var(--tertiary);
|
||||
}
|
||||
|
||||
.yt-cancel:not(.disabled):hover {
|
||||
background-color: var(--quaternary);
|
||||
}
|
||||
|
||||
.yt-hint {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.yt-feedback {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px),
|
||||
screen and (max-height: 607px) {
|
||||
.yt-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
// Retour après le consentement Google : la page est rechargée (store perdu), on passe donc par sessionStorage.
|
||||
// RETURN_KEY : page d'origine ; HANDLED_KEY : state déjà transmis ; REOPEN_KEY : rouvrir les paramètres au retour.
|
||||
export const RETURN_KEY = 'googleOAuthReturn';
|
||||
export const HANDLED_KEY = 'googleOAuthHandled';
|
||||
export const REOPEN_KEY = 'googleOAuthReopenSettings';
|
||||
|
||||
export function storageGet(key) {
|
||||
try {
|
||||
return sessionStorage.getItem(key);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function storageSet(key, value) {
|
||||
try {
|
||||
if (value === null) sessionStorage.removeItem(key);
|
||||
else sessionStorage.setItem(key, value);
|
||||
} catch (e) {
|
||||
// Stockage indisponible (navigation privée stricte) : sans conséquence
|
||||
}
|
||||
}
|
||||
|
||||
// Lit puis efface une clé (consommation unique)
|
||||
export function storageTake(key) {
|
||||
const value = storageGet(key);
|
||||
if (value !== null) storageSet(key, null);
|
||||
return value;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<h2>🔄️ Historique personnel</h2>
|
||||
<p class="info">Voici les derniers titres que tu as écoutés (tout serveur confondu) !</p>
|
||||
<div v-if="history && history.length" class="history-list">
|
||||
<VideoComposable v-for="video in history.reverse()" :key="id" :video="video" />
|
||||
<VideoComposable v-for="(video, index) in reversedHistory" :key="index" :video="video" />
|
||||
</div>
|
||||
<p v-else-if="loading" class="none"><Icon icon="fa-spinner" spin-pulse /> Chargement en cours</p>
|
||||
<p v-else class="none">
|
||||
@@ -13,30 +13,55 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { IORequest } from '@/utils/IORequest';
|
||||
import { onMounted, onUnmounted, ref, computed, watch } from 'vue';
|
||||
import { IORequestAsync } from '@/utils/IORequest';
|
||||
import VideoComposable from '@/components/Widget/VideoComposable.vue';
|
||||
import Events from '@/utils/Events';
|
||||
import { useId, watch } from 'vue'
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
const id = useId()
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
const history = ref([]);
|
||||
const loading = ref(true);
|
||||
let mounted = false;
|
||||
let requestInFlight = false;
|
||||
let requestAgain = false;
|
||||
|
||||
// Du plus récent au plus ancien (copie : ne jamais inverser le tableau source pendant le rendu)
|
||||
const reversedHistory = computed(() => Array.isArray(history.value) ? [...history.value].reverse() : []);
|
||||
|
||||
// Une seule requête en vol ; une relance si un nouveau changement survient entre-temps
|
||||
async function refreshHistory() {
|
||||
if (requestInFlight) {
|
||||
requestAgain = true;
|
||||
return;
|
||||
}
|
||||
requestInFlight = true;
|
||||
loading.value = true;
|
||||
const data = await IORequestAsync('/USER/HISTORY', undefined, 10000);
|
||||
requestInFlight = false;
|
||||
if (!mounted) return;
|
||||
loading.value = false;
|
||||
if (data !== null) history.value = Array.isArray(data) ? data : [];
|
||||
if (requestAgain) {
|
||||
requestAgain = false;
|
||||
refreshHistory();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
IORequest('/USER/HISTORY', data => {
|
||||
loading.value = false;
|
||||
history.value = data;
|
||||
});
|
||||
mounted = true;
|
||||
refreshHistory();
|
||||
})
|
||||
|
||||
Events.on("player:update", () => {
|
||||
loading.value = true;
|
||||
IORequest('/USER/HISTORY', data => {
|
||||
loading.value = false;
|
||||
history.value = data;
|
||||
});
|
||||
onUnmounted(() => {
|
||||
mounted = false;
|
||||
})
|
||||
|
||||
// Ne recharger l'historique que lorsque le morceau courant change (pas à chaque mise à jour du player).
|
||||
// undefined = aucun état reçu : le premier état ne déclenche rien (déjà chargé au montage).
|
||||
watch(() => playerStore.hasState ? (playerStore.current?.id ?? null) : undefined, (newId, oldId) => {
|
||||
if (oldId === undefined || newId === undefined) return;
|
||||
refreshHistory();
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
<template>
|
||||
<div class="channel-message">
|
||||
<div class="channel-message" :title="botChannelTitle">
|
||||
<Tag color="var(--text-error)" v-if="!globalStore.currentChannel"><span class="tag-channel"><span>Vous</span> <span>êtes</span> déconnecté </span></Tag>
|
||||
<Tag color="var(--text-success)" v-else><span class="tag-channel">Connecté <span>à </span> <Icon icon="fa-solid fa-volume-up"/> <span>{{ globalStore.currentChannel.name }}</span></span></Tag>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { useGlobalStore } from '@/stores/globalStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import Tag from '@/components/UI/Tag.vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { updateChannel } from '@/utils/Logic';
|
||||
|
||||
const globalStore = useGlobalStore();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
// Salon où se trouve Subsonics (état du player), indiqué au survol
|
||||
const botChannelTitle = computed(() => {
|
||||
const name = playerStore.connection?.channelName;
|
||||
if (!playerStore.online || !name) return null;
|
||||
return "Subsonics est dans : " + name;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
updateChannel()
|
||||
// Ce composant est monté plusieurs fois (desktop, mobile) : le salon est déjà demandé par
|
||||
// l'interface après /GUILD/JOIN et sur /CHANNEL/UPDATE. On ne le redemande que s'il est inconnu,
|
||||
// pour éviter de l'effacer (clignotement « déconnecté ») à chaque montage.
|
||||
if (!globalStore.currentChannel) updateChannel()
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -37,4 +49,4 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
<div v-else class="search-playlist-thumbnail"><div class="defaultIcon"><Icon icon="fa-music" /></div></div>
|
||||
<div class="search-playlist-info">
|
||||
<p class="search-playlist-title">{{ results.title }} <Icon v-if="results.url" @click="openPlaylistPage()" class="link-icon" icon="fa-solid fa-link" /></p>
|
||||
<p class="search-playlist-stats"><Tag color="var(--text-warning)">{{ results.songs.length }} titres</Tag><Tag v-if="results.views" color="var(--text-success)">{{ results.views }} vues</Tag ><Tag v-if="results.songs.length > 0">Durée : {{ results.readduration }}</Tag></p>
|
||||
<p class="search-playlist-stats"><Tag color="var(--text-warning)">{{ results.truncated ? `Aperçu : ${results.songs.length} titres sur ${results.totalTracks}` : `${results.songs.length} titres` }}</Tag><Tag v-if="results.views" color="var(--text-success)">{{ results.views }} vues</Tag ><Tag v-if="results.songs.length > 0">Durée : {{ results.readduration }}</Tag></p>
|
||||
<div @click="openAuthorPage()" class="search-playlist-author-info">
|
||||
<img v-if="results.authorAvatar" :src="results.authorAvatar" alt="Author Thumbnail" class="search-playlist-author" />
|
||||
<div v-else class="search-playlist-author-placeholder"></div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useLoginStore } from './stores/loginStore'
|
||||
import { useGlobalStore } from './stores/globalStore'
|
||||
import Interface from './views/Interface.vue'
|
||||
import events from './utils/Events'
|
||||
import OAuthCallback from './views/OAuthCallback.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -22,6 +23,10 @@ const router = createRouter({
|
||||
{path: "/redirect", component: Redirect},
|
||||
{path: "/servers", component: Servers},
|
||||
{path: "/servers/:guildId", component: Interface, props: true},
|
||||
// Chargée à la demande : le tableau de bord d'administration n'alourdit pas le bundle principal
|
||||
{path: "/admin", component: () => import('@/views/Admin.vue')},
|
||||
// Retour du consentement Google (URI de redirection déclarée : <site>/oauth2callback)
|
||||
{path: "/oauth2callback", component: OAuthCallback},
|
||||
{path: "/:pathMatch(.*)*", component: PageError, props: {message: "404 - Page inexistante"}},
|
||||
]
|
||||
})
|
||||
@@ -62,6 +67,18 @@ router.beforeEach((to, from, next) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Administration : connexion requise (les droits sont vérifiés par le serveur à chaque requête)
|
||||
if (to.path === "/admin" && !loginStore.token) {
|
||||
next("/login")
|
||||
return
|
||||
}
|
||||
|
||||
// Liaison Google : réservée à un utilisateur connecté (le code est transmis par son socket)
|
||||
if (to.path === "/oauth2callback" && !loginStore.token) {
|
||||
next("/login?error=" + encodeURIComponent("Connectez-vous avant de lier votre compte YouTube"))
|
||||
return
|
||||
}
|
||||
|
||||
if (to.path === "/login" && localStorage.getItem("token")) {
|
||||
next("/")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* [Subsonics Chopin - Frontend]
|
||||
* File: adminStore.js
|
||||
* Description: État partagé de l'interface d'administration (vue d'ensemble, players, serveurs, WARP, YouTube, toasts)
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { adminRequest, adminListen, describeError } from '@/utils/AdminRequest';
|
||||
|
||||
const SECTION_KEY = "adminSection";
|
||||
|
||||
function readSection() {
|
||||
try { return localStorage.getItem(SECTION_KEY) || "overview"; } catch (e) { return "overview"; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Point d'ancrage pour extrapoler la position d'un player localement (sans dérive) :
|
||||
* affiché = min(durationMs, ms + rate * (performance.now() - t0))
|
||||
*/
|
||||
function makeAnchor(player) {
|
||||
const pos = player?.position;
|
||||
const playing = player?.status ? player.status === 'playing' : (player?.playing && !player?.paused);
|
||||
if (pos && typeof pos === 'object') {
|
||||
return { ms: Number(pos.ms) || 0, rate: pos.rate === 1 && playing ? 1 : 0, t0: performance.now() };
|
||||
}
|
||||
// Ancien format : position en secondes
|
||||
const seconds = typeof pos === 'number' ? pos : Number(player?.duration) || 0;
|
||||
return { ms: seconds * 1000, rate: playing ? 1 : 0, t0: performance.now() };
|
||||
}
|
||||
|
||||
export const useAdminStore = defineStore('admin', () => {
|
||||
|
||||
// Section active (conservée si SocketEnvironment remonte la page après une reconnexion)
|
||||
const section = ref(readSection());
|
||||
|
||||
function setSection(id) {
|
||||
section.value = id;
|
||||
try { localStorage.setItem(SECTION_KEY, id); } catch (e) { /* stockage indisponible */ }
|
||||
}
|
||||
|
||||
// ----- Toasts -----
|
||||
const toasts = ref([]);
|
||||
let toastId = 0;
|
||||
|
||||
function dismiss(id) {
|
||||
toasts.value = toasts.value.filter(t => t.id !== id);
|
||||
}
|
||||
|
||||
function notify(type, text, duration) {
|
||||
const id = ++toastId;
|
||||
toasts.value.push({ id, type, text });
|
||||
if (toasts.value.length > 5) toasts.value.shift();
|
||||
setTimeout(() => dismiss(id), duration ?? (type === 'error' ? 8000 : 4000));
|
||||
}
|
||||
|
||||
function notifyError(error, context) {
|
||||
notify('error', (context ? context + " : " : "") + describeError(error));
|
||||
}
|
||||
|
||||
// ----- Vue d'ensemble -----
|
||||
const overview = ref(null);
|
||||
const overviewLoading = ref(false);
|
||||
const overviewError = ref(null);
|
||||
|
||||
async function fetchOverview() {
|
||||
if (overviewLoading.value) return;
|
||||
overviewLoading.value = true;
|
||||
try {
|
||||
overview.value = await adminRequest("/ADMIN/OVERVIEW");
|
||||
overviewError.value = null;
|
||||
} catch (e) {
|
||||
overviewError.value = describeError(e);
|
||||
} finally {
|
||||
overviewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Players -----
|
||||
const players = ref([]);
|
||||
const playersLoaded = ref(false);
|
||||
const playersLoading = ref(false);
|
||||
const playersError = ref(null);
|
||||
|
||||
// Fusionne un PlayerSummary : un état plus ancien (même instance, seq inférieur) est ignoré,
|
||||
// un état de même seq garde l'ancre existante pour ne pas faire sauter la position.
|
||||
function mergePlayer(old, incoming) {
|
||||
if (!incoming) return old;
|
||||
const sameInstance = old && incoming.instanceId && old.instanceId === incoming.instanceId;
|
||||
const hasSeq = typeof incoming.seq === 'number' && typeof old?.seq === 'number';
|
||||
if (sameInstance && hasSeq && incoming.seq < old.seq) return old;
|
||||
if (sameInstance && hasSeq && incoming.seq === old.seq) return { ...incoming, anchor: old.anchor };
|
||||
return { ...incoming, anchor: makeAnchor(incoming) };
|
||||
}
|
||||
|
||||
function ingestPlayers(list) {
|
||||
if (!Array.isArray(list)) return;
|
||||
const previous = new Map(players.value.map(p => [p.guildId, p]));
|
||||
players.value = list
|
||||
.filter(p => p && p.guildId && p.status !== 'offline')
|
||||
.map(p => mergePlayer(previous.get(p.guildId), p));
|
||||
playersLoaded.value = true;
|
||||
playersError.value = null;
|
||||
}
|
||||
|
||||
function ingestPlayer(guildId, summary) {
|
||||
if (!summary || summary.status === 'offline') {
|
||||
players.value = players.value.filter(p => p.guildId !== guildId);
|
||||
return;
|
||||
}
|
||||
const index = players.value.findIndex(p => p.guildId === summary.guildId);
|
||||
if (index === -1) players.value = [...players.value, mergePlayer(null, summary)];
|
||||
else {
|
||||
const copy = [...players.value];
|
||||
copy[index] = mergePlayer(copy[index], summary);
|
||||
players.value = copy;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlayers() {
|
||||
if (playersLoading.value) return;
|
||||
playersLoading.value = true;
|
||||
try {
|
||||
ingestPlayers(await adminRequest("/ADMIN/PLAYERS/LIST"));
|
||||
} catch (e) {
|
||||
playersError.value = describeError(e);
|
||||
} finally {
|
||||
playersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// value : état voulu pour loop / shuffle (idempotent : un double clic ne rebascule pas)
|
||||
async function playerAction(guildId, action, value) {
|
||||
try {
|
||||
const payload = { guildId, action };
|
||||
if (typeof value === 'boolean') payload.value = value;
|
||||
const summary = await adminRequest("/ADMIN/PLAYERS/ACTION", payload);
|
||||
ingestPlayer(guildId, summary);
|
||||
return true;
|
||||
} catch (e) {
|
||||
notifyError(e, "Action « " + action + " »");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Serveurs -----
|
||||
const guilds = ref([]);
|
||||
const guildsLoaded = ref(false);
|
||||
const guildsLoading = ref(false);
|
||||
const guildsError = ref(null);
|
||||
|
||||
async function fetchGuilds(refresh = false) {
|
||||
if (guildsLoading.value) return;
|
||||
guildsLoading.value = true;
|
||||
try {
|
||||
const list = await adminRequest(refresh ? "/ADMIN/GUILDS/REFRESH" : "/ADMIN/GUILDS/LIST", {}, refresh ? 30000 : 15000);
|
||||
guilds.value = Array.isArray(list) ? list : [];
|
||||
guildsLoaded.value = true;
|
||||
guildsError.value = null;
|
||||
if (refresh) notify('success', "Liste des serveurs rafraîchie");
|
||||
} catch (e) {
|
||||
guildsError.value = describeError(e);
|
||||
} finally {
|
||||
guildsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- WARP -----
|
||||
const warp = ref(null);
|
||||
const warpLoading = ref(false);
|
||||
const warpError = ref(null);
|
||||
const warpChecking = ref(false);
|
||||
|
||||
async function fetchWarp() {
|
||||
if (warpLoading.value) return;
|
||||
warpLoading.value = true;
|
||||
try {
|
||||
warp.value = await adminRequest("/ADMIN/WARP/STATUS");
|
||||
warpError.value = null;
|
||||
} catch (e) {
|
||||
warpError.value = describeError(e);
|
||||
} finally {
|
||||
warpLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Statut complet (poussée, réponse de /ADMIN/WARP/CONFIG...)
|
||||
function setWarp(data) {
|
||||
if (data && typeof data === 'object') {
|
||||
warp.value = data;
|
||||
warpError.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkWarp() {
|
||||
if (warpChecking.value) return;
|
||||
warpChecking.value = true;
|
||||
try {
|
||||
warp.value = await adminRequest("/ADMIN/WARP/CHECK", {}, 20000);
|
||||
notify(warp.value?.healthy ? 'success' : 'error', warp.value?.healthy ? "WARP opérationnel" : "WARP injoignable");
|
||||
} catch (e) {
|
||||
notifyError(e, "Vérification WARP");
|
||||
} finally {
|
||||
warpChecking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- YouTube (cookies, yt-dlp, réglages, diagnostics) -----
|
||||
const youtube = ref(null);
|
||||
const youtubeLoading = ref(false);
|
||||
const youtubeError = ref(null);
|
||||
|
||||
async function fetchYoutube() {
|
||||
if (youtubeLoading.value) return;
|
||||
youtubeLoading.value = true;
|
||||
try {
|
||||
youtube.value = await adminRequest("/ADMIN/YOUTUBE/STATUS");
|
||||
youtubeError.value = null;
|
||||
} catch (e) {
|
||||
youtubeError.value = describeError(e);
|
||||
} finally {
|
||||
youtubeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Statut complet renvoyé par une action (import, réglages...) ou poussé : même forme que STATUS
|
||||
function setYoutube(data) {
|
||||
if (data && typeof data === 'object') {
|
||||
youtube.value = data;
|
||||
youtubeError.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Configuration (config.json, secrets masqués) et intégrations -----
|
||||
const config = ref(null);
|
||||
const configLoading = ref(false);
|
||||
const configError = ref(null);
|
||||
|
||||
async function fetchConfig() {
|
||||
if (configLoading.value) return;
|
||||
configLoading.value = true;
|
||||
try {
|
||||
setConfig(await adminRequest("/ADMIN/CONFIG/GET"));
|
||||
} catch (e) {
|
||||
configError.value = describeError(e);
|
||||
} finally {
|
||||
configLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Même forme pour GET, la réponse de SET et la poussée : { config, restartRequired, integrations }
|
||||
function setConfig(data) {
|
||||
if (data && typeof data === 'object' && data.config) {
|
||||
config.value = data;
|
||||
configError.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Présence des comptes (poussée "/ADMIN/USERS/UPDATE") -----
|
||||
const presence = ref(null);
|
||||
const onlineIds = computed(() => new Set((presence.value?.online || []).map(u => u.id)));
|
||||
|
||||
// ----- Poussées temps réel -----
|
||||
// Enregistrées au montage de la vue Admin, retirées au démontage (routes.js fait removeAllListeners)
|
||||
const offs = [];
|
||||
|
||||
function attach() {
|
||||
detach();
|
||||
offs.push(adminListen("/ADMIN/OVERVIEW/UPDATE", (data) => {
|
||||
if (data) { overview.value = data; overviewError.value = null; }
|
||||
}));
|
||||
offs.push(adminListen("/ADMIN/PLAYERS/UPDATE", ingestPlayers));
|
||||
offs.push(adminListen("/ADMIN/WARP/UPDATE", setWarp));
|
||||
offs.push(adminListen("/ADMIN/CONFIG/UPDATE", setConfig));
|
||||
offs.push(adminListen("/ADMIN/GUILDS/UPDATE", (data) => {
|
||||
if (Array.isArray(data)) { guilds.value = data; guildsLoaded.value = true; }
|
||||
}));
|
||||
offs.push(adminListen("/ADMIN/USERS/UPDATE", (data) => { if (data) presence.value = data; }));
|
||||
offs.push(adminListen("/ADMIN/YOUTUBE/UPDATE", setYoutube));
|
||||
}
|
||||
|
||||
function detach() {
|
||||
while (offs.length) offs.pop()();
|
||||
}
|
||||
|
||||
return {
|
||||
section, setSection,
|
||||
toasts, notify, notifyError, dismiss,
|
||||
overview, overviewLoading, overviewError, fetchOverview,
|
||||
players, playersLoaded, playersLoading, playersError, fetchPlayers, playerAction, ingestPlayer,
|
||||
guilds, guildsLoaded, guildsLoading, guildsError, fetchGuilds,
|
||||
warp, warpLoading, warpError, warpChecking, fetchWarp, checkWarp, setWarp,
|
||||
config, configLoading, configError, fetchConfig, setConfig,
|
||||
youtube, youtubeLoading, youtubeError, fetchYoutube, setYoutube,
|
||||
presence, onlineIds,
|
||||
attach, detach
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* [Subsonics Chopin - Frontend]
|
||||
* File: playerStore.js
|
||||
* Description: État du player de la guilde courante (source unique pour Player, DurationBar, Queue, History...)
|
||||
*
|
||||
* - Alimenté par /PLAYER/UPDATE (poussé par le serveur) et /PLAYER/STATE (à la demande).
|
||||
* - Filtre les états périmés : même instanceId et seq <= dernier seq reçu => ignoré.
|
||||
* - Position extrapolée depuis une ancre { ms, rate, t0 = performance.now() } : aucune dérive,
|
||||
* aucun compteur +1, valeur juste au retour sur l'onglet.
|
||||
* - Compatible avec l'ancien format d'état (sans v/status/position) : statut dérivé de playerState.
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, shallowRef, computed } from 'vue';
|
||||
import { socket } from '@/socket';
|
||||
import { IORequestAsync } from '@/utils/IORequest';
|
||||
import { useGlobalStore } from '@/stores/globalStore';
|
||||
|
||||
// Rafraîchissement de l'horloge d'affichage (ms) : suffisant pour l'affichage à la seconde et la barre
|
||||
const TICK_MS = 250;
|
||||
const STATUSES = ['offline', 'idle', 'loading', 'playing', 'paused', 'stalled'];
|
||||
|
||||
// Durée en secondes (Song.duration) -> ms, null si live / inconnue
|
||||
function songDurationMs(song) {
|
||||
const d = Number(song?.duration);
|
||||
return Number.isFinite(d) && d > 0 ? d * 1000 : null;
|
||||
}
|
||||
|
||||
// Statut déduit de l'ancien format (playerState = statut AudioPlayer ou false)
|
||||
function legacyStatus(raw) {
|
||||
if (!raw.playerState) return 'offline';
|
||||
switch (raw.playerState) {
|
||||
case 'playing': return 'playing';
|
||||
case 'paused': return 'paused';
|
||||
case 'autopaused':
|
||||
case 'buffering': return 'stalled';
|
||||
// Idle avec un morceau courant = chargement du morceau (l'ancien backend ne l'exposait pas)
|
||||
case 'idle': return raw.current ? 'loading' : 'idle';
|
||||
default: return raw.current ? 'stalled' : 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
// Convertit un état reçu (v2 ou ancien format) en objet complet et cohérent
|
||||
function normalize(raw) {
|
||||
const isV2 = raw.v >= 2 && STATUSES.includes(raw.status);
|
||||
const current = raw.current || null;
|
||||
const status = isV2 ? raw.status : legacyStatus(raw);
|
||||
|
||||
let durationMs;
|
||||
if (raw.durationMs !== undefined) {
|
||||
const d = Number(raw.durationMs);
|
||||
durationMs = Number.isFinite(d) && d > 0 ? d : null;
|
||||
} else {
|
||||
durationMs = songDurationMs(current);
|
||||
}
|
||||
|
||||
let position;
|
||||
if (raw.position && Number.isFinite(Number(raw.position.ms))) {
|
||||
position = { ms: Number(raw.position.ms), rate: raw.position.rate === 1 ? 1 : 0 };
|
||||
} else {
|
||||
// Ancien format : duration = position en secondes
|
||||
const sec = Number(raw.duration);
|
||||
position = { ms: Number.isFinite(sec) ? sec * 1000 : 0, rate: 1 };
|
||||
}
|
||||
// Garde-fou : la position n'avance que si le morceau joue réellement
|
||||
if (status !== 'playing') position.rate = 0;
|
||||
position.ms = Math.max(0, durationMs ? Math.min(position.ms, durationMs) : position.ms);
|
||||
|
||||
const next = Array.isArray(raw.next) ? raw.next : [];
|
||||
const previous = Array.isArray(raw.previous) ? raw.previous : [];
|
||||
|
||||
return {
|
||||
v: isV2 ? raw.v : 1,
|
||||
guildId: raw.guildId ?? null,
|
||||
instanceId: raw.instanceId ?? null,
|
||||
seq: Number.isFinite(raw.seq) ? raw.seq : null,
|
||||
serverTime: Number.isFinite(raw.serverTime) ? raw.serverTime : null,
|
||||
status,
|
||||
connection: raw.connection || {
|
||||
status: raw.connectionState || null,
|
||||
channelId: raw.channelId ?? null,
|
||||
channelName: raw.channelName ?? null
|
||||
},
|
||||
current,
|
||||
position,
|
||||
durationMs,
|
||||
seekable: typeof raw.seekable === 'boolean' ? raw.seekable : !!(current && durationMs),
|
||||
loop: !!raw.loop,
|
||||
shuffle: !!raw.shuffle,
|
||||
pendingPause: !!raw.pendingPause,
|
||||
loading: raw.loading || null,
|
||||
lastError: raw.lastError || null,
|
||||
next,
|
||||
nextCount: Number.isFinite(raw.nextCount) ? raw.nextCount : next.length,
|
||||
previous,
|
||||
autoLeaveAt: Number.isFinite(raw.autoLeaveAt) ? raw.autoLeaveAt : null
|
||||
};
|
||||
}
|
||||
|
||||
export const usePlayerStore = defineStore('player', () => {
|
||||
const globalStore = useGlobalStore();
|
||||
|
||||
// Dernier état accepté (normalisé), null tant qu'aucun état n'a été reçu
|
||||
const state = shallowRef(null);
|
||||
// Ancre d'extrapolation de la position
|
||||
const anchor = shallowRef(null);
|
||||
// Horloge réactive (performance.now()), avancée seulement quand la position bouge
|
||||
const clock = ref(performance.now());
|
||||
// Compteur d'états acceptés (permet de savoir qu'un état « plus récent » est arrivé)
|
||||
const revision = ref(0);
|
||||
// Décalage estimé horloge serveur - horloge locale (ms)
|
||||
const clockOffset = ref(0);
|
||||
|
||||
// Filtrage des états périmés (non réactif)
|
||||
let instanceId = null;
|
||||
let lastSeq = -1;
|
||||
|
||||
// === HORLOGE (requestAnimationFrame, naturellement suspendu quand l'onglet est caché) ===
|
||||
let rafId = null;
|
||||
let lastTick = 0;
|
||||
|
||||
function clockShouldRun() {
|
||||
return anchor.value?.rate === 1 && !document.hidden;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
rafId = null;
|
||||
const now = performance.now();
|
||||
if (now - lastTick >= TICK_MS) {
|
||||
lastTick = now;
|
||||
clock.value = now;
|
||||
}
|
||||
if (clockShouldRun()) rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function startClock() {
|
||||
if (rafId === null && clockShouldRun()) rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function stopClock() {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
|
||||
// Au retour sur l'onglet : la position est recalculée depuis l'ancre, donc juste immédiatement
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
stopClock();
|
||||
} else {
|
||||
clock.value = performance.now();
|
||||
startClock();
|
||||
}
|
||||
});
|
||||
|
||||
function setAnchor(position) {
|
||||
const now = performance.now();
|
||||
anchor.value = { ms: position.ms, rate: position.rate, t0: now };
|
||||
clock.value = now;
|
||||
lastTick = now;
|
||||
if (position.rate === 1) startClock(); else stopClock();
|
||||
}
|
||||
|
||||
// === RÉCEPTION D'UN ÉTAT ===
|
||||
function apply(normalized) {
|
||||
state.value = normalized;
|
||||
if (normalized.serverTime) clockOffset.value = normalized.serverTime - Date.now();
|
||||
setAnchor(normalized.position);
|
||||
revision.value++;
|
||||
}
|
||||
|
||||
// Renvoie true si l'état a été accepté
|
||||
function ingest(raw) {
|
||||
if (!raw || typeof raw !== 'object') return false;
|
||||
// État d'une autre guilde (réponse tardive après un changement de serveur)
|
||||
const guild = globalStore.lastGuild;
|
||||
if (raw.guildId && guild && guild !== 'null' && raw.guildId !== guild) return false;
|
||||
// État périmé de la même instance de player
|
||||
if (raw.instanceId && raw.instanceId === instanceId && Number.isFinite(raw.seq) && raw.seq <= lastSeq) {
|
||||
console.log(`Player : état ignoré (seq ${raw.seq} <= ${lastSeq})`);
|
||||
return false;
|
||||
}
|
||||
if (raw.instanceId) {
|
||||
instanceId = raw.instanceId;
|
||||
lastSeq = Number.isFinite(raw.seq) ? raw.seq : -1;
|
||||
}
|
||||
apply(normalize(raw));
|
||||
return true;
|
||||
}
|
||||
|
||||
function setOffline() {
|
||||
instanceId = null;
|
||||
lastSeq = -1;
|
||||
apply(normalize({ v: 2, status: 'offline', guildId: globalStore.lastGuild }));
|
||||
}
|
||||
|
||||
// Oublie tout (démontage du player, changement de serveur)
|
||||
function reset() {
|
||||
stopClock();
|
||||
instanceId = null;
|
||||
lastSeq = -1;
|
||||
state.value = null;
|
||||
anchor.value = null;
|
||||
}
|
||||
|
||||
// === SOCKET ===
|
||||
// Un seul listener /PLAYER/UPDATE actif à la fois : un nouvel appel remplace le précédent.
|
||||
// La fonction renvoyée ne retire le listener que s'il est toujours celui de l'appelant
|
||||
// (le démontage de l'ancienne page survient APRÈS le setup de la nouvelle).
|
||||
let activeOff = null;
|
||||
function listen() {
|
||||
if (activeOff) activeOff();
|
||||
const handler = (data) => ingest(data);
|
||||
socket.on('/PLAYER/UPDATE', handler);
|
||||
const off = () => {
|
||||
socket.off('/PLAYER/UPDATE', handler);
|
||||
if (activeOff === off) activeOff = null;
|
||||
};
|
||||
activeOff = off;
|
||||
return off;
|
||||
}
|
||||
|
||||
// Demande l'état complet (une seule requête en vol, une relance si redemandé entre-temps)
|
||||
let requestInFlight = false;
|
||||
let requestAgain = false;
|
||||
async function requestState() {
|
||||
if (requestInFlight) {
|
||||
requestAgain = true;
|
||||
return;
|
||||
}
|
||||
requestInFlight = true;
|
||||
const revisionAtRequest = revision.value;
|
||||
const response = await IORequestAsync('/PLAYER/STATE', undefined, 8000);
|
||||
requestInFlight = false;
|
||||
if (response === false) {
|
||||
// Pas de player : hors-ligne, sauf si un état plus récent est arrivé entre-temps
|
||||
if (revision.value === revisionAtRequest) setOffline();
|
||||
} else if (response) {
|
||||
ingest(response);
|
||||
}
|
||||
if (requestAgain) {
|
||||
requestAgain = false;
|
||||
requestState();
|
||||
}
|
||||
}
|
||||
|
||||
// === GETTERS ===
|
||||
const hasState = computed(() => state.value !== null);
|
||||
const status = computed(() => state.value?.status ?? 'offline');
|
||||
const online = computed(() => status.value !== 'offline');
|
||||
const current = computed(() => state.value?.current ?? null);
|
||||
const next = computed(() => state.value?.next ?? []);
|
||||
const nextCount = computed(() => state.value?.nextCount ?? 0);
|
||||
const previous = computed(() => state.value?.previous ?? []);
|
||||
const loop = computed(() => state.value?.loop ?? false);
|
||||
const shuffle = computed(() => state.value?.shuffle ?? false);
|
||||
const durationMs = computed(() => state.value?.durationMs ?? null);
|
||||
const seekable = computed(() => state.value?.seekable ?? false);
|
||||
const pendingPause = computed(() => state.value?.pendingPause ?? false);
|
||||
const loading = computed(() => state.value?.loading ?? null);
|
||||
const lastError = computed(() => state.value?.lastError ?? null);
|
||||
const connection = computed(() => state.value?.connection ?? { status: null, channelId: null, channelName: null });
|
||||
const autoLeaveAt = computed(() => state.value?.autoLeaveAt ?? null);
|
||||
|
||||
// Position affichée (ms) : min(durée, ancre + rate * temps écoulé)
|
||||
const positionMs = computed(() => {
|
||||
const a = anchor.value;
|
||||
if (!a) return 0;
|
||||
const elapsed = a.rate === 1 ? Math.max(0, clock.value - a.t0) : 0;
|
||||
let ms = a.ms + elapsed;
|
||||
if (durationMs.value) ms = Math.min(ms, durationMs.value);
|
||||
return Math.max(0, ms);
|
||||
});
|
||||
|
||||
// Texte « Chargement… (WARP/direct, essai n) »
|
||||
const loadingLabel = computed(() => {
|
||||
if (status.value !== 'loading') return null;
|
||||
const l = loading.value;
|
||||
const details = [];
|
||||
if (l?.route === 'warp') details.push('WARP');
|
||||
else if (l?.route === 'direct') details.push('direct');
|
||||
if (l?.attempt) details.push('essai ' + l.attempt);
|
||||
return 'Chargement…' + (details.length ? ' (' + details.join(', ') + ')' : '');
|
||||
});
|
||||
|
||||
// Heure serveur estimée (pour comparer since / at / autoLeaveAt)
|
||||
function serverNow() {
|
||||
return Date.now() + clockOffset.value;
|
||||
}
|
||||
|
||||
return {
|
||||
playerState: state,
|
||||
revision,
|
||||
clockOffset,
|
||||
hasState,
|
||||
status,
|
||||
online,
|
||||
current,
|
||||
next,
|
||||
nextCount,
|
||||
previous,
|
||||
loop,
|
||||
shuffle,
|
||||
durationMs,
|
||||
seekable,
|
||||
pendingPause,
|
||||
loading,
|
||||
lastError,
|
||||
connection,
|
||||
autoLeaveAt,
|
||||
positionMs,
|
||||
loadingLabel,
|
||||
ingest,
|
||||
setOffline,
|
||||
reset,
|
||||
listen,
|
||||
requestState,
|
||||
serverNow
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* [Subsonics Chopin - Frontend]
|
||||
* File: AdminRequest.js
|
||||
* Description: Requêtes de l'interface d'administration (ack Socket.IO + timeout)
|
||||
* Enveloppe serveur : { ok: true, data } | { ok: false, error: CODE, message? }
|
||||
*/
|
||||
|
||||
import { socket } from '@/socket';
|
||||
|
||||
// Libellés français des codes d'erreur renvoyés par AdminRequest (backend/src/server/Admin.js)
|
||||
export const ADMIN_ERRORS = {
|
||||
FORBIDDEN: "Accès refusé",
|
||||
INVALID_PARAMS: "Paramètres invalides",
|
||||
NOT_FOUND: "Élément introuvable",
|
||||
SELF_ACTION: "Action impossible sur votre propre compte",
|
||||
TARGET_PROTECTED: "Cible protégée",
|
||||
UNAVAILABLE: "Indisponible",
|
||||
INTERNAL: "Erreur interne du serveur",
|
||||
TIMEOUT: "Délai dépassé",
|
||||
DISCONNECTED: "Non connecté au serveur"
|
||||
};
|
||||
|
||||
/**
|
||||
* Envoie une requête admin et attend l'ack du serveur.
|
||||
* Résout avec `data`, rejette avec une Error portant un `code` (FORBIDDEN, TIMEOUT...).
|
||||
*/
|
||||
export function adminRequest(event, data = {}, timeout = 15000) {
|
||||
// Hors ligne : échec immédiat au lieu d'attendre tout le délai (socket.io mettrait l'envoi en tampon)
|
||||
if (socket.disconnected) {
|
||||
return Promise.reject(Object.assign(new Error("Connexion au serveur perdue"), { code: "DISCONNECTED" }));
|
||||
}
|
||||
return new Promise((resolve, reject) => socket.timeout(timeout).emit(event, data, (err, res) => {
|
||||
if (err) return reject(Object.assign(new Error("Délai dépassé"), { code: "TIMEOUT" }));
|
||||
if (res?.ok) return resolve(res.data);
|
||||
// Un `false` brut (ancien format) est traité comme une erreur interne
|
||||
reject(Object.assign(new Error(res?.message || res?.error || "Erreur"), { code: res?.error || "INTERNAL" }));
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Écoute une poussée du serveur ("/ADMIN/.../UPDATE").
|
||||
* Renvoie la fonction de désinscription (à appeler au démontage).
|
||||
*/
|
||||
export function adminListen(event, callback) {
|
||||
const handler = (data) => callback(data);
|
||||
socket.on(event, handler);
|
||||
return () => socket.off(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Texte lisible d'une erreur admin, avec son code : "Accès refusé : ... (FORBIDDEN)"
|
||||
*/
|
||||
export function describeError(error) {
|
||||
const code = error?.code || "INTERNAL";
|
||||
const label = ADMIN_ERRORS[code] || "Erreur";
|
||||
const message = error?.message && error.message !== code && error.message !== label ? error.message : "";
|
||||
return `${label}${message ? " : " + message : ""} (${code})`;
|
||||
}
|
||||
+44
-2
@@ -11,12 +11,54 @@ export function IORequest(event, callback, data) {
|
||||
}
|
||||
}
|
||||
|
||||
// Écoute un événement poussé par le serveur.
|
||||
// Renvoie une fonction de désinscription qui ne retire QUE ce handler (les appels existants peuvent l'ignorer).
|
||||
export function IOListener(event, callback) {
|
||||
console.log(`IOListener: Listen for event ${event}`);
|
||||
socket.on(event, (data) => {
|
||||
const handler = (data) => {
|
||||
if (callback && typeof callback === 'function') {
|
||||
console.log(`IOListener: Received data for event ${event}:`, data);
|
||||
callback(data);
|
||||
}
|
||||
};
|
||||
socket.on(event, handler);
|
||||
return () => socket.off(event, handler);
|
||||
}
|
||||
|
||||
// Requête / réponse au format historique (réponse émise par IOAnswer sur le même nom d'événement), sous forme de Promise.
|
||||
// Ne rejette jamais : résout la réponse, ou null si rien n'est arrivé avant le délai
|
||||
// (handler côté serveur qui ne répond pas, listeners retirés par une navigation...).
|
||||
export function IORequestAsync(event, data, timeout = 10000) {
|
||||
return new Promise((resolve) => {
|
||||
const handler = (response) => {
|
||||
clearTimeout(timer);
|
||||
console.log(`IORequestAsync: Received response for event ${event}:`, response);
|
||||
resolve(response);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
socket.off(event, handler);
|
||||
console.warn(`IORequestAsync: Aucune réponse pour ${event} après ${timeout} ms`);
|
||||
resolve(null);
|
||||
}, timeout);
|
||||
socket.once(event, handler);
|
||||
console.log(`IORequestAsync: Emitting event ${event} with data:`, data);
|
||||
socket.emit(event, data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Requête / réponse par acknowledgement socket.io : chaque réponse est associée à SA requête.
|
||||
// Utilisable pour les handlers serveur qui répondent via l'ack (callback(value, ack)).
|
||||
// Rejette avec { code: "TIMEOUT" } si le serveur ne répond pas avant le délai.
|
||||
export function IORequestAck(event, data, timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`IORequestAck: Emitting event ${event} with data:`, data);
|
||||
socket.timeout(timeout).emit(event, data, (err, response) => {
|
||||
if (err) {
|
||||
reject(Object.assign(new Error(`Délai dépassé pour ${event}`), { code: "TIMEOUT" }));
|
||||
return;
|
||||
}
|
||||
console.log(`IORequestAck: Received response for event ${event}:`, response);
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,13 +4,14 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import Loader from '@/utils/Loader.vue';
|
||||
import { ref } from 'vue';
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
import { useLoginStore } from '@/stores/loginStore';
|
||||
import { socket } from '@/socket.js';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { IOListener, IORequest } from '@/utils/IORequest';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { useGlobalStore } from '@/stores/globalStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import events from '@/utils/Events.js';
|
||||
import Events from '@/utils/Events.js';
|
||||
|
||||
@@ -21,6 +22,7 @@ import Events from '@/utils/Events.js';
|
||||
const loginStore = useLoginStore();
|
||||
const userStore = useUserStore();
|
||||
const globalStore = useGlobalStore();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
loginStore.clearSession();
|
||||
socket.auth.sessionId = null;
|
||||
@@ -40,13 +42,30 @@ import Events from '@/utils/Events.js';
|
||||
});
|
||||
});
|
||||
|
||||
// État du player : UN seul listener /PLAYER/UPDATE vers le store, réenregistré à chaque page
|
||||
// (routes.js retire tous les listeners socket à chaque navigation)
|
||||
const stopPlayerListener = playerStore.listen();
|
||||
|
||||
// /GUILD/UPDATE peut arriver en rafale : une seule requête /GUILD/LIST par fenêtre de 500 ms
|
||||
const GUILD_LIST_DELAY = 500;
|
||||
let guildListTimer = null;
|
||||
IOListener("/GUILD/UPDATE", () => {
|
||||
IORequest("/GUILD/LIST", (response) => {
|
||||
if(response) {
|
||||
userStore.userInfo.guilds = response;
|
||||
Events.emit("GUILD_LIST_UPDATE", response);
|
||||
}
|
||||
})
|
||||
if (guildListTimer) return;
|
||||
guildListTimer = setTimeout(() => {
|
||||
guildListTimer = null;
|
||||
IORequest("/GUILD/LIST", (response) => {
|
||||
if(response && userStore.userInfo) {
|
||||
userStore.userInfo.guilds = response;
|
||||
Events.emit("GUILD_LIST_UPDATE", response);
|
||||
}
|
||||
})
|
||||
}, GUILD_LIST_DELAY);
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(guildListTimer);
|
||||
guildListTimer = null;
|
||||
stopPlayerListener();
|
||||
})
|
||||
|
||||
IOListener("AUTH_ERROR", (error) => {
|
||||
@@ -102,4 +121,4 @@ import Events from '@/utils/Events.js';
|
||||
}
|
||||
}, 3000); // Essai de reconnexion après 5 secondes
|
||||
}
|
||||
</script>r
|
||||
</script>
|
||||
+96
-62
@@ -1,79 +1,113 @@
|
||||
// Conversion de durées : aucune fonction ne renvoie "NaN".
|
||||
// Même format que backend/src/utils/TimeConverter.js.
|
||||
|
||||
/**
|
||||
* Convertit une valeur en nombre fini. Les chaînes ne sont acceptées que si elles sont strictement numériques
|
||||
* ("116.088163" oui, "117290116.088163116..." non).
|
||||
* @returns {number} Le nombre, ou NaN si la valeur est invalide
|
||||
*/
|
||||
export function toFiniteNumber(value) {
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : NaN
|
||||
if (typeof value === "string" && /^\s*-?\d+(\.\d+)?\s*$/.test(value)) {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : NaN
|
||||
}
|
||||
return NaN
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de secondes entier et >= 0 (0 si la valeur est invalide ou négative)
|
||||
*/
|
||||
function toSeconds(duration) {
|
||||
const value = toFiniteNumber(duration)
|
||||
if (!Number.isFinite(value) || value < 0) return 0
|
||||
return Math.floor(value)
|
||||
}
|
||||
|
||||
function pad(number) {
|
||||
return String(number).padStart(2, "0")
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} duration Durée en secondes
|
||||
* @returns {string} "3m05s" ou "1h02m03s" ("0m00s" si la durée est invalide)
|
||||
*/
|
||||
export function getReadableDuration(duration) {
|
||||
var max = ""
|
||||
duration *= 1000;
|
||||
const total = toSeconds(duration)
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const seconds = total % 60
|
||||
|
||||
const maxhours = Math.floor(duration / 3600000);
|
||||
|
||||
var maxmin = Math.trunc(duration / 60000) - (Math.floor(duration / 60000 / 60) * 60);
|
||||
var maxsec = Math.floor(duration / 1000) - (Math.floor(duration / 1000 / 60) * 60);
|
||||
|
||||
|
||||
if (maxsec < 10) {
|
||||
maxsec = `0${maxsec}`;
|
||||
if (hours > 0) {
|
||||
return `${hours}h${pad(minutes)}m${pad(seconds)}s`
|
||||
}
|
||||
|
||||
|
||||
if(maxhours != 0) {
|
||||
|
||||
if (maxmin < 10) {
|
||||
maxmin = `0${maxmin}`;
|
||||
}
|
||||
|
||||
|
||||
max = maxhours + "h" + maxmin + "m" + maxsec
|
||||
} else {
|
||||
max = maxmin + "m" + maxsec + "s"
|
||||
|
||||
}
|
||||
|
||||
return max
|
||||
return `${minutes}m${pad(seconds)}s`
|
||||
}
|
||||
|
||||
// Get hh:mm:ss format
|
||||
|
||||
/**
|
||||
* @param {number} duration Durée en secondes
|
||||
* @returns {string} "3:05" ou "1:02:03" ; "-:--" si la durée est inconnue (NaN, undefined, chaîne), "0:00" si négative
|
||||
*/
|
||||
export function getVideoDuration(duration) {
|
||||
var max = ""
|
||||
duration *= 1000;
|
||||
const value = toFiniteNumber(duration)
|
||||
if (!Number.isFinite(value)) return "-:--"
|
||||
|
||||
const maxhours = Math.floor(duration / 3600000);
|
||||
const total = toSeconds(value)
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const seconds = total % 60
|
||||
|
||||
var maxmin = Math.trunc(duration / 60000) - (Math.floor(duration / 60000 / 60) * 60);
|
||||
var maxsec = Math.floor(duration / 1000) - (Math.floor(duration / 1000 / 60) * 60);
|
||||
|
||||
|
||||
if (maxsec < 10) {
|
||||
maxsec = `0${maxsec}`;
|
||||
if (hours > 0) {
|
||||
return `${hours}:${pad(minutes)}:${pad(seconds)}`
|
||||
}
|
||||
|
||||
|
||||
if(maxhours != 0) {
|
||||
|
||||
if (maxmin < 10) {
|
||||
maxmin = `0${maxmin}`;
|
||||
}
|
||||
|
||||
|
||||
max = maxhours + ":" + maxmin + ":" + maxsec
|
||||
} else {
|
||||
max = maxmin + ":" + maxsec
|
||||
|
||||
}
|
||||
|
||||
return max
|
||||
|
||||
return `${minutes}:${pad(seconds)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} duration Durée au format hh:mm:ss, mm:ss ou ss
|
||||
* @returns {number} Secondes (0 si le format est invalide)
|
||||
*/
|
||||
export function getSecondsDuration(duration) {
|
||||
// Duration is in format hh:mm:ss and can be just m:ss or mm:ss
|
||||
var durationArray = duration.split(":");
|
||||
var seconds = 0;
|
||||
if(durationArray.length == 3) {
|
||||
seconds = parseInt(durationArray[0]) * 3600 + parseInt(durationArray[1]) * 60 + parseInt(durationArray[2]);
|
||||
} else if(durationArray.length == 2) {
|
||||
seconds = parseInt(durationArray[0]) * 60 + parseInt(durationArray[1]);
|
||||
} else {
|
||||
seconds = parseInt(durationArray[0]);
|
||||
if (typeof duration === "number") return toSeconds(duration)
|
||||
if (typeof duration !== "string" || duration.trim() === "") return 0
|
||||
|
||||
const parts = duration.trim().split(":").map(part => parseInt(part, 10))
|
||||
if (parts.length > 3 || parts.some(part => !Number.isFinite(part) || part < 0)) return 0
|
||||
|
||||
// On lit de droite à gauche : secondes, minutes, heures
|
||||
let seconds = 0
|
||||
let multiplier = 1
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
seconds += parts[i] * multiplier
|
||||
multiplier *= 60
|
||||
}
|
||||
return seconds;
|
||||
return seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Durée cumulée pour les statistiques (peut dépasser plusieurs jours)
|
||||
* @param {number} duration Durée en secondes
|
||||
* @returns {string} "2j 08h34m50s", sinon même format que getReadableDuration
|
||||
*/
|
||||
export function formatStatDuration(duration) {
|
||||
const total = toSeconds(duration)
|
||||
const days = Math.floor(total / 86400)
|
||||
if (days === 0) return getReadableDuration(total)
|
||||
|
||||
const rest = total % 86400
|
||||
const hours = Math.floor(rest / 3600)
|
||||
const minutes = Math.floor((rest % 3600) / 60)
|
||||
const seconds = rest % 60
|
||||
return `${days}j ${pad(hours)}h${pad(minutes)}m${pad(seconds)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compteur lisible ("1 398") ; 0 si la valeur est invalide ou négative
|
||||
*/
|
||||
export function formatStatCount(value) {
|
||||
const number = toFiniteNumber(value)
|
||||
const safe = Number.isFinite(number) && number >= 0 ? number : 0
|
||||
return safe.toLocaleString("fr-FR")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<SocketEnvironment>
|
||||
<AdminDashboard/>
|
||||
</SocketEnvironment>
|
||||
</template>
|
||||
<script setup>
|
||||
// Page fine : le contenu n'est monté qu'une fois /USER/INFO reçu (userStore.userInfo rempli)
|
||||
import SocketEnvironment from '@/utils/SocketEnvironment.vue';
|
||||
import AdminDashboard from '@/components/Admin/AdminDashboard.vue';
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<SocketEnvironment>
|
||||
<GoogleCallback/>
|
||||
</SocketEnvironment>
|
||||
</template>
|
||||
<script setup>
|
||||
// Retour OAuth2 Google (redirect URI = <site>/oauth2callback) : le contenu n'est monté qu'une fois le socket authentifié
|
||||
import { onMounted } from 'vue';
|
||||
import SocketEnvironment from '@/utils/SocketEnvironment.vue';
|
||||
import GoogleCallback from '@/components/Widget/User/GoogleCallback.vue';
|
||||
|
||||
onMounted(() => {
|
||||
document.title = "Connexion YouTube - Subsonics";
|
||||
});
|
||||
</script>
|
||||
Reference in new issue
Block a user