Version 2.0.0 - update of everything

This commit is contained in:
raphix committed 2026-10-07 14:05:02 +02:00
1 parent e006d48c0a
commit 6d81a8c1c0
57 files changed
+9407 -364

No files matched your search

+19
View File
@@ -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
View File
@@ -1,4 +1,5 @@
FROM node:lts # 1. Construction du site (Vite)
FROM node:lts AS build
WORKDIR /app WORKDIR /app
@@ -10,8 +11,18 @@ COPY . .
RUN npm run build 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 RUN npm install -g http-server
COPY --from=build /app/dist ./dist
EXPOSE 4001 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
View File
@@ -6,13 +6,12 @@ services:
container_name: subsonics-frontend container_name: subsonics-frontend
ports: ports:
- "4001:4001" - "4001:4001"
volumes: # Pas de volume sur /app/dist : un volume nommé n'est rempli qu'à sa création,
- subsonics-frontend:/app/dist # 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 restart: unless-stopped
logging: logging:
driver: "json-file" driver: "json-file"
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
volumes:
subsonics-frontend:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "chopin-frontend", "name": "chopin-frontend",
"version": "1.5.0", "version": "2.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite --host --port 8080", "dev": "vite --host --port 8080",
+2
View File
@@ -1,8 +1,10 @@
<template> <template>
<router-view/> <router-view/>
<Announcement/>
</template> </template>
<script setup> <script setup>
import { useGlobalStore } from '@/stores/globalStore'; import { useGlobalStore } from '@/stores/globalStore';
import Announcement from '@/components/UI/Announcement.vue';
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
console.log("Subsonics Chopin - Welcome"); console.log("Subsonics Chopin - Welcome");
</script> </script>
+73
View File
@@ -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>
+587
View File
@@ -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>
+168
View File
@@ -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>
+289
View File
@@ -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>
+50
View File
@@ -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>
+698
View File
@@ -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>
+232
View File
@@ -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>
+247
View File
@@ -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>
+282
View File
@@ -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>
+72
View File
@@ -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>
+410
View File
@@ -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>
+90
View File
@@ -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>
+327
View File
@@ -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>
+77
View File
@@ -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>
+401
View File
@@ -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>
+158
View File
@@ -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>
+81
View File
@@ -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>
+68
View File
@@ -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>
+97
View File
@@ -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>
+468
View File
@@ -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>
+107
View File
@@ -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>
+241
View File
@@ -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>
+318
View File
@@ -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
+143
View File
@@ -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" };
}
+6 -8
View File
@@ -1,14 +1,13 @@
<template> <template>
<Box :box-class="'box'" padding="closed"> <Box :box-class="'box'" padding="closed">
<User :user="userStore.userInfo" /> <User :user="userStore.userInfo" />
<div class="user-action"> <div class="user-action">
<IconAction title="Paramètres" icon="fa-solid fa-gear" @click="userSettings.open()"/> <IconAction title="Paramètres" icon="fa-solid fa-gear" @click="userSettings.open()"/>
<IconAction title="Déconnexion" color="red" icon="fa-solid fa-right-from-bracket" @click="signOut(router)"/> <IconAction title="Déconnexion" color="red" icon="fa-solid fa-right-from-bracket" @click="signOut(router)"/>
</div> </div>
<!-- Paramètres : thème, comptes connectés (YouTube), support, confidentialité, accès à l'administration -->
<UserSettings ref="userSettings"/> <UserSettings ref="userSettings"/>
</Box> </Box>
</template> </template>
<script setup> <script setup>
import Box from '../UI/Box.vue'; import Box from '../UI/Box.vue';
@@ -23,9 +22,8 @@ if(!socket.connected) {
} }
import { useUserStore } from '@/stores/userStore'; import { useUserStore } from '@/stores/userStore';
import Modal from '../UI/Modal.vue';
import UserSettings from '../Widget/User/UserSettings.vue'; import UserSettings from '../Widget/User/UserSettings.vue';
import { ref, onMounted } from 'vue'; import { ref } from 'vue';
const userStore = useUserStore(); const userStore = useUserStore();
const router = useRouter(); const router = useRouter();
+148 -81
View File
@@ -5,9 +5,12 @@
<div class="player"> <div class="player">
<div class="player-line"> <div class="player-line">
<VideoList :player-offline="!online" :video="video" /> <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> </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 class="actions">
<div v-if="online" class="actions-div"> <div v-if="online" class="actions-div">
<CustomIcon title="En boucle" :active="loop" @click="toggleLoop"><Loop active icon="fa-solid fa-sync"/></CustomIcon> <CustomIcon title="En boucle" :active="loop" @click="toggleLoop"><Loop active icon="fa-solid fa-sync"/></CustomIcon>
@@ -16,8 +19,8 @@
<span v-else></span> <span v-else></span>
<div :class="{'actions-div': true, 'offline': !online || !video}" > <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"/> <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}"> <div @click="togglePause()" :title="pauseTitle" :class="{'playIcon': online && video, 'itm-main': true}">
<Play v-if="pause || !online"/> <Play v-if="showPlayIcon"/>
<Pause v-else/> <Pause v-else/>
</div> </div>
<Forward title="Suivant" @click="forward" :class="{'playIcon': online && video, 'itm-sec': true, 'enabled': online}" class="itm-sec" icon="fa-solid fa-forward-step"/> <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}"> <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}"> <section @click="togglePlayerOpen" ref="playerMobile" v-if="online" :class="{'player-mobile-unfold': playerOpen, 'player-mobile': true}">
<div v-show="playerOpen" class="player-menu"> <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"> <span class="actions-container">
<div :class="{'actions-div': true, 'offline': !online || !video}" > <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"/> <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}"> <div @click="togglePause()" :title="pauseTitle" :class="{'playIcon': online && video, 'itm-main': true}">
<Play v-if="pause || !online"/> <Play v-if="showPlayIcon"/>
<Pause v-else/> <Pause v-else/>
</div> </div>
<Forward title="Suivant" @click="forward" :class="{'playIcon': online && video, 'itm-sec': true, 'enabled': online}" class="itm-sec" icon="fa-solid fa-forward-step"/> <Forward title="Suivant" @click="forward" :class="{'playIcon': online && video, 'itm-sec': true, 'enabled': online}" class="itm-sec" icon="fa-solid fa-forward-step"/>
</div> </div>
</span> </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" > <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="En boucle" :active="loop" @click="toggleLoop"><Loop active icon="fa-solid fa-sync"/></CustomIcon>
<CustomIcon title="Lecture aléatoire" :active="shuffle" @click="toggleShuffle"><Shuffle icon="fa-solid fa-shuffle"/></CustomIcon> <CustomIcon title="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 Box from '../UI/Box.vue';
import DurationBar from '../UI/DurationBar.vue'; import DurationBar from '../UI/DurationBar.vue';
import VideoList from "../UI/VideoList.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 Forward from '@/assets/Icons/Forward.vue';
import Backward from '@/assets/Icons/Backward.vue'; import Backward from '@/assets/Icons/Backward.vue';
import Loop from '@/assets/Icons/Loop.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 Tag from '../UI/Tag.vue';
import { onClickOutside } from '@vueuse/core'; import { onClickOutside } from '@vueuse/core';
import { useGlobalStore } from '@/stores/globalStore'; import { useGlobalStore } from '@/stores/globalStore';
import { IOListener, IORequest } from '@/utils/IORequest'; import { IORequest, IORequestAsync } from '@/utils/IORequest';
import { useResizeObserver } from '@vueuse/core' import { useResizeObserver } from '@vueuse/core'
import ActualChannel from '../Widget/View/Player/ActualChannel.vue'; import ActualChannel from '../Widget/View/Player/ActualChannel.vue';
import { socket } from '@/socket';
import Events from '@/utils/Events'; import Events from '@/utils/Events';
import { usePlayerStore } from '@/stores/playerStore';
const globalStore = useGlobalStore(); 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 playerOpen = ref(false);
const durationBar = ref(null); const pauseBusy = ref(false);
const buffering = ref(false);
const playerMobile = ref(null); const playerMobile = ref(null);
const playerMobileToggle = ref(null); const playerMobileToggle = ref(null);
const playerHeight = ref(0); const playerHeight = ref(0);
IOListener("/PLAYER/UPDATE", (response) => { // === ÉTAT DÉRIVÉ DU STATUT ===
updatePlayer(response) 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", () => { // Icône Play : en pause, hors-ligne, ou chargement qui démarrera en pause (pendingPause)
askUpdate() 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", () => { const pauseTitle = computed(() => {
Events.emit("player:update", playerInstance.value); if (status.value === 'loading') return playerStore.pendingPause ? "Démarrera en pause" : "Mettre en pause au démarrage";
}) return showPlayIcon.value ? "Lecture" : "Pause";
});
function askUpdate() { // Les deux barres (desktop et mobile) lisent les mêmes valeurs du store : plus de ref partagée
IORequest("/PLAYER/STATE", (response) => { const durationBarProps = computed(() => ({
console.log("Ask Player Update") positionMs: playerStore.positionMs,
updatePlayer(response) 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) { function reasonMessage(reason, fallback) {
if(player) { switch (reason) {
online.value = player.playerState; case 'no_music': return "Aucune musique en cours";
pause.value = player.paused; case 'stalled': return "Connexion vocale instable";
currentTime.value = player.duration ? player.duration : 0; default: return fallback;
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);
} }
// 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) { function togglePlayerOpen(event) {
if(!playerOpen.value || playerMobileToggle.value.contains(event.target)) { if(!playerOpen.value || playerMobileToggle.value.contains(event.target)) {
if (playerOpen.value) { if (playerOpen.value) {
@@ -185,10 +237,15 @@ function togglePlayerOpen(event) {
} }
function togglePause() { // Le résultat réel est affiché : l'icône suit l'état poussé par le serveur (paused / pendingPause),
IORequest("/PLAYER/PAUSE", (data) => { // un refus est signalé au lieu d'être ignoré
console.log("Player pause toggled:", data); 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() { function toggleLoop() {
@@ -228,12 +285,15 @@ function forward() {
} }
onMounted(() => { onMounted(() => {
if(video.value) { playerStore.requestState();
totalDuration.value = video.value.duration; })
}
socket.removeListener("/PLAYER/STATE")
askUpdate()
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) { // Appelé par DurationBar (position en ms, seconde entière) ; renvoie false si le seek est refusé
IORequest("/PLAYER/SEEK", (data) => { // pour que la barre abandonne immédiatement l'affichage de la cible
console.log("Player seeked:", data); async function seek(ms) {
}, { time: time }) const data = await IORequestAsync("/PLAYER/SEEK", { time: Math.floor(ms / 1000) }, 10000);
} console.log("Player seeked:", data);
if (isRefused(data)) {
watch(() => video.value, () => { showActionError(data, "Impossible de se déplacer dans le morceau");
if(video.value) { return false;
totalDuration.value = video.value.duration;
} }
}); return true;
}
</script> </script>
@@ -381,6 +440,14 @@ watch(() => video.value, () => {
gap: 10px; 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), @media screen and (max-width: 768px),
screen and (max-height: 607px) { screen and (max-height: 607px) {
+30 -8
View File
@@ -17,14 +17,14 @@
<Modal icon="fa-list" title="Ajouter une Playlist" ref="modal"> <Modal icon="fa-list" title="Ajouter une Playlist" ref="modal">
<div class="p-modal-content"> <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="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"> <div @click="isYoutube = !isYoutube" class="youtube-import">
<input type="checkbox" v-model="isYoutube" /> <input type="checkbox" v-model="isYoutube" />
<label>Importer depuis Youtube</label> <label>Importer depuis YouTube</label>
</div> </div>
<Button icon="fa-add" :disabled="(!isYoutube && newPlaylistTitle.trim() === '') || (isYoutube && urlLink?.trim() === '')" @click="addPlaylist">Ajouter</Button> <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="isLoading"><Icon icon="fa-spinner" spin-pulse /> Création en cours…</p>
<p class="text-loading" v-if="error"><Error> {{ error }}</Error></p> <p class="text-loading" v-if="error"><Error> {{ error }}</Error></p>
</div> </div>
</Modal> </Modal>
@@ -34,12 +34,14 @@ import { IORequest } from '@/utils/IORequest';
import Button from '../UI/Button.vue'; import Button from '../UI/Button.vue';
import PlaylistItem from '../UI/PlaylistItem.vue'; import PlaylistItem from '../UI/PlaylistItem.vue';
import { onMounted, ref, onUnmounted } from 'vue'; import { onMounted, ref, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import Modal from '../UI/Modal.vue'; import Modal from '../UI/Modal.vue';
import Error from '../UI/Error.vue'; import Error from '../UI/Error.vue';
import Events from '@/utils/Events'; import Events from '@/utils/Events';
import { useGlobalStore } from '@/stores/globalStore'; import { useGlobalStore } from '@/stores/globalStore';
import { useUserStore } from '@/stores/userStore'; 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 modal = ref(null);
const playlists = ref([]); const playlists = ref([]);
@@ -51,8 +53,8 @@ const error = ref(null);
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
const userStore = useUserStore(); const userStore = useUserStore();
const router = useRouter();
//TODO: Faire la synchro google
//TODO: Faire la possibilité d'envoyer une playlist //TODO: Faire la possibilité d'envoyer une playlist
@@ -69,12 +71,32 @@ Events.on("playlist:init", (data) => {
playlists.value = 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(() => { onMounted(() => {
Events.emit("logic:init"); Events.emit("logic:init");
listenPlaylistsUpdate();
removeHook = router.afterEach(() => listenPlaylistsUpdate());
});
onUnmounted(() => {
removeHook?.();
socket.off("/PLAYLISTS/UPDATE", onPlaylistsUpdate);
}); });
function addPlaylist() { function addPlaylist() {
if ((!isYoutube && newPlaylistTitle.value?.trim() === '') || (isYoutube && urlLink.value?.trim() === '')) { if ((!isYoutube.value && !newPlaylistTitle.value?.trim()) || (isYoutube.value && !urlLink.value?.trim())) {
return; return;
} }
if(isYoutube.value) newPlaylistTitle.value = urlLink.value; if(isYoutube.value) newPlaylistTitle.value = urlLink.value;
@@ -137,7 +159,7 @@ label {
} }
@media screen and (max-width: 768px), @media screen and (max-width: 768px),
screen and (max-height: 607) { screen and (max-height: 607px) {
.playlist-items { .playlist-items {
flex-direction: column; flex-direction: column;
} }
+9 -23
View File
@@ -6,7 +6,7 @@
</Glider> </Glider>
<div class="list-wrapper"> <div class="list-wrapper">
<Transition :name="direction"> <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> </Transition>
</div> </div>
@@ -16,39 +16,25 @@
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useGlobalStore } from '@/stores/globalStore'; import { useGlobalStore } from '@/stores/globalStore';
import { useUserStore } from '@/stores/userStore'; import { useUserStore } from '@/stores/userStore';
import { IORequest } from '@/utils/IORequest';
import Glider from '../UI/Glider.vue'; import Glider from '../UI/Glider.vue';
import VideoList from '../UI/VideoList.vue'; import { computed, ref, watch } from '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 NextList from '../Widget/Queue/NextList.vue'; import NextList from '../Widget/Queue/NextList.vue';
import PreviousList from '../Widget/Queue/PreviousList.vue'; import PreviousList from '../Widget/Queue/PreviousList.vue';
import { usePlayerStore } from '@/stores/playerStore';
const router = useRouter(); const router = useRouter();
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
const userStore = useUserStore(); const userStore = useUserStore();
const online = ref(false); // Files lues directement dans le store du player (plus d'écoute d'événement à retirer)
const nextList = ref(null) const playerStore = usePlayerStore();
const previousList = ref(null) 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 direction = ref('forward')
const id = useId()
const selectedTab = ref(0) 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) => { watch(selectedTab, (newTab, oldTab) => {
if (newTab > oldTab) { if (newTab > oldTab) {
direction.value = 'forward'; // glide vers la gauche direction.value = 'forward'; // glide vers la gauche
+191
View File
@@ -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>
+124 -97
View File
@@ -1,18 +1,19 @@
<template> <template>
<div v-if="!buffering" class="duration-bar"> <div v-if="!showLoader" class="duration-bar">
<p v-if="!offline && localCurrentTime != 0">{{ getVideoDuration(localCurrentTime) }}</p> <p v-if="!inactive">{{ getVideoDuration(displaySec) }}</p>
<p class="offline" v-else>-:--</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" :style="{ width: percent + '%' }"></span>
<span class="progress-hover" v-if="hoverPercent !== null" :style="{ width: hoverPercent + '%' }"></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> </div>
<p v-if="!offline && localTotalDuration == 0"><Icon color="var(--text-error)" icon="fa-circle"/> LIVE</p> <p v-if="!inactive && isLive"><Icon color="var(--text-error)" icon="fa-circle"/> LIVE</p>
<p v-else-if="!offline">{{ getVideoDuration(localTotalDuration) }}</p> <p v-else-if="!inactive">{{ getVideoDuration(durationSec) }}</p>
<p class="offline" v-else>-:--</p> <p class="offline" v-else>-:--</p>
</div> </div>
<div v-else> <div v-else class="duration-bar">
<p class="offline">-:--</p> <!-- Chargement (morceau ou seek) : position cible figée + loader -->
<p :class="{'offline': !showLoaderTime}">{{ showLoaderTime ? getVideoDuration(displaySec) : '-:--' }}</p>
<div class="multi-loader"> <div class="multi-loader">
<span></span> <span></span>
<span></span> <span></span>
@@ -20,142 +21,163 @@
<span></span> <span></span>
<span></span> <span></span>
</div> </div>
<p class="offline">-:--</p> <p :class="{'offline': !durationSec}">{{ durationSec ? getVideoDuration(durationSec) : '-:--' }}</p>
</div> </div>
</template> </template>
<script setup> <script setup>
import { getVideoDuration } from "@/utils/TimeConverter" 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({ const props = defineProps({
currentTime: Number, positionMs: { type: Number, default: 0 },
totalDuration: Number, durationMs: { type: Number, default: null },
pause: Boolean, status: { type: String, default: 'offline' },
offline: Boolean, seekable: Boolean,
mobile: 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) // Délai maximal d'affichage d'un seek en attente sans confirmation du serveur
const localTotalDuration = ref(props.offline ? 0 : props.totalDuration) 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 progressBar = ref(null)
const isDragging = ref(false) 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(() => { const percent = computed(() => {
if (!localTotalDuration.value) return 0 if (!props.durationMs) return 0
const p = (localCurrentTime.value / localTotalDuration.value) * 100 const p = (displayMs.value / props.durationMs) * 100
return Math.max(0, Math.min(100, p)) return Math.max(0, Math.min(100, p))
}) })
const hoverPercent = ref(null) function positionFromEvent(e) {
const rect = progressBar.value.getBoundingClientRect()
function seek(e) { let pos = (e.clientX - rect.left) / rect.width
if(props.offline) return return Math.max(0, Math.min(1, pos))
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 hoverSeek(e) { function hoverSeek(e) {
if(props.offline) return if (!canSeek.value) {
const rect = e.currentTarget.getBoundingClientRect() hoverPercent.value = null
let hoverCalcPercent = ((e.clientX - rect.left) / rect.width) * 100 e.currentTarget.removeAttribute("title")
hoverCalcPercent = Math.max(0, Math.min(100, hoverCalcPercent)) return
hoverPercent.value = hoverCalcPercent }
// Add a tooltip or some indication of the current time like a title attribute const pos = positionFromEvent(e)
e.currentTarget.title = getVideoDuration((hoverCalcPercent / 100) * localTotalDuration.value) 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) { function startDrag(e) {
if(props.offline) return if (!canSeek.value) return
isDragging.value = true isDragging.value = true
updateDrag(e) dragMs.value = positionFromEvent(e) * props.durationMs
window.addEventListener("pointermove", updateDrag) window.addEventListener("pointermove", updateDrag)
window.addEventListener("pointerup", stopDrag) window.addEventListener("pointerup", stopDrag)
window.addEventListener("pointercancel", cancelDrag)
} }
function updateDrag(e) { function updateDrag(e) {
if(props.offline) return
if (!isDragging.value) return if (!isDragging.value) return
const rect = e.currentTarget?.getBoundingClientRect?.() dragMs.value = positionFromEvent(e) * props.durationMs
|| progressBar.value.getBoundingClientRect() }
let pos = (e.clientX - rect.left) / rect.width function removeDragListeners() {
pos = Math.max(0, Math.min(1, pos)) window.removeEventListener("pointermove", updateDrag)
window.removeEventListener("pointerup", stopDrag)
window.removeEventListener("pointercancel", cancelDrag)
}
const newTime = pos * localTotalDuration.value function cancelDrag() {
localCurrentTime.value = newTime isDragging.value = false
removeDragListeners()
} }
function stopDrag(e) { function stopDrag(e) {
if(props.offline) return
if (!isDragging.value) return if (!isDragging.value) return
removeDragListeners()
isDragging.value = false isDragging.value = false
if (!canSeek.value || !props.durationMs) return
const rect = progressBar.value.getBoundingClientRect() // Seconde entière : même valeur que celle appliquée par le serveur
let pos = (e.clientX - rect.left) / rect.width const targetSec = Math.floor(positionFromEvent(e) * props.durationMs / 1000)
pos = Math.max(0, Math.min(1, pos)) requestSeek(targetSec * 1000)
const newTime = pos * localTotalDuration.value
localCurrentTime.value = newTime
emit("seek", newTime)
window.removeEventListener("pointermove", updateDrag)
window.removeEventListener("pointerup", stopDrag)
} }
// === INIT === // === SEEK EN ATTENTE ===
onMounted(() => { async function requestSeek(ms) {
if(!props.offline) { const pending = { ms, revision: props.revision, sawLoading: false }
localCurrentTime.value = props.currentTime pendingSeek.value = pending
localTotalDuration.value = props.totalDuration 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) => { function clearPendingSeek(pending) {
if (newOffline) { if (pending && pendingSeek.value !== pending) return
localCurrentTime.value = 0 clearTimeout(pendingTimer)
localTotalDuration.value = 0 pendingTimer = null
} pendingSeek.value = null
}) }
setInterval(() => { // Un état plus récent est arrivé : le seek est terminé quand le serveur n'est plus en chargement
if(!isDragging.value && localCurrentTime.value < localTotalDuration.value && !props.pause) { // et qu'il a soit traversé un chargement, soit atteint la position visée
localCurrentTime.value += 1 watch(() => props.revision, () => {
} const pending = pendingSeek.value
}, 1000) 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) => { watch(inactive, (value) => {
if(props.offline) return if (value) {
if (!isDragging.value) localCurrentTime.value = newTime cancelDrag()
}) clearPendingSeek()
}
watch(() => props.totalDuration, (newDuration) => { })
if(props.offline) return
localTotalDuration.value = newDuration
})
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener("pointermove", updateDrag) removeDragListeners()
window.removeEventListener("pointerup", stopDrag) clearTimeout(pendingTimer)
})
function updateLocalValues() {
if(props.offline) return
localCurrentTime.value = props.currentTime
localTotalDuration.value = props.totalDuration
}
defineExpose({
updateLocalValues
}) })
</script> </script>
@@ -180,6 +202,11 @@ defineExpose({
background: var(--tertiary); background: var(--tertiary);
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
touch-action: none;
}
.progress-bar-locked {
cursor: default;
} }
.progress-bar-mobile { .progress-bar-mobile {
+19 -7
View File
@@ -1,19 +1,19 @@
<template> <template>
<div class="metric"> <div class="metric">
<div class="metric-header"> <div class="metric-header">
<Icon class="metric-icon" :icon="getIcons(metric.name)" /> <Icon class="metric-icon" :icon="getIcons(metricName)" />
<div class="metric-info"> <div class="metric-info">
<p class="metric-name">{{ metric.description.replace(server.id, "").replace(server.name, "").replace(":", "") }}</p> <p class="metric-name">{{ label }}</p>
<p class="metric-id">{{ metric.name }}</p> <p class="metric-id">{{ metricName }}</p>
</div> </div>
</div> </div>
<p class="metric-value" v-if="metric.name.includes('Seconds')">{{ getReadableDuration(metric.value) }}</p> <p class="metric-value" v-if="metricName.includes('Seconds')">{{ formatStatDuration(metric.value) }}</p>
<p v-else class="metric-value">{{ metric.value }}</p> <p v-else class="metric-value">{{ formatStatCount(metric.value) }}</p>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'; import { computed } from 'vue';
import { getReadableDuration } from '@/utils/TimeConverter'; import { formatStatDuration, formatStatCount } from '@/utils/TimeConverter';
const props = defineProps({ const props = defineProps({
metric: { 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) { function getIcons(name) {
if(name.includes("Commands")) return "fa-solid fa-terminal"; if(name.includes("Commands")) return "fa-solid fa-terminal";
if(name.includes("Music")) return "fa-solid fa-music"; if(name.includes("Music")) return "fa-solid fa-music";
@@ -1,24 +1,27 @@
<template> <template>
<div class="metrics" v-if="metrics && metrics.length > 0"> <div class="metrics" v-if="metrics.length > 0">
<Metric v-for="metric in metrics" :key="metric.id" :metric="metric" :server="server"/> <Metric v-for="metric in metrics" :key="metric.name" :metric="metric" :server="server"/>
</div> </div>
<div v-else> <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> </div>
</template> </template>
<script setup> <script setup>
import { IORequest } from '@/utils/IORequest'; import { IORequestAsync } from '@/utils/IORequest';
import Events from '@/utils/Events'; import Events from '@/utils/Events';
import { ref } from 'vue'; import { ref, onMounted, onUnmounted } from 'vue';
import { useUserStore } from '@/stores/userStore'; import { useUserStore } from '@/stores/userStore';
import { getReadableDuration } from '@/utils/TimeConverter';
import Metric from '@/components/UI/Metric.vue'; import Metric from '@/components/UI/Metric.vue';
const userStore = useUserStore(); const userStore = useUserStore();
const metrics = ref(null); const metrics = ref([]);
const loading = ref(false);
const error = ref(null);
const props = defineProps({ const props = defineProps({
server: { server: {
@@ -27,12 +30,50 @@ const props = defineProps({
} }
}); });
Events.on("GUILD_JOINED", () => { function canSeeStats() {
if(!(userStore.userInfo.identity.isAdmin || userStore.userInfo.identity.isMod || userStore.userInfo.identity.id == props.server.owner)) return; const identity = userStore.userInfo?.identity;
IORequest("/MOD/STATS", (data) => { if(!identity) return false;
metrics.value = data; 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> </script>
@@ -51,4 +92,4 @@ Events.on("GUILD_JOINED", () => {
max-height: 30vh; max-height: 30vh;
} }
</style> </style>
+22 -5
View File
@@ -10,6 +10,8 @@
</div> </div>
</template> </template>
</draggable> </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)"> <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> <p class="text-secondary"><Icon font-size="2em" icon="fa-solid fa-circle-xmark"/> Aucun élément dans la liste de lecture</p>
</div> </div>
@@ -70,8 +72,14 @@ const localNextList = ref([])
const isAFile = computed(() => { const isAFile = computed(() => {
if(selectedIndex.value == null) return false; if(selectedIndex.value == null) return false;
const video = props.nextList[selectedIndex.value]; const video = props.nextList?.[selectedIndex.value];
return video.type === "attachment"; 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 = { const dragOptions = {
@@ -90,15 +98,17 @@ const dragOptions = {
const props = defineProps({ const props = defineProps({
selectedTab: Number, selectedTab: Number,
online: {}, 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) => { watch(() => props.nextList, (newList) => {
localNextList.value = newList localNextList.value = newList ? [...newList] : []
}) })
onMounted(() => { onMounted(() => {
localNextList.value = props.nextList localNextList.value = props.nextList ? [...props.nextList] : []
}) })
@@ -204,6 +214,13 @@ function onDragEnd(event) {
display: flex; display: flex;
} }
.more-items {
margin: 0;
text-align: center;
font-size: 0.8em;
color: var(--text-secondary);
}
</style> </style>
+3 -3
View File
@@ -1,6 +1,6 @@
<template> <template>
<section class="list-container"> <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> <VideoQueue little :video="video" :index="index"></VideoQueue>
<IconAction @click="setSelectedIndex(index)" icon="fa-ellipsis-vertical"></IconAction> <IconAction @click="setSelectedIndex(index)" icon="fa-ellipsis-vertical"></IconAction>
</div> </div>
@@ -60,8 +60,8 @@ const playlistSelector = ref(null)
const isAFile = computed(() => { const isAFile = computed(() => {
if(selectedIndex.value == null) return false; if(selectedIndex.value == null) return false;
const video = props.previousList[selectedIndex.value]; const video = props.previousList?.[selectedIndex.value];
return video.type === "attachment"; return video?.type === "attachment";
}) })
function setSelectedIndex(index) { 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> </p>
<div @click="deleteAccount = !deleteAccount" class="options"> <div @click="deleteAccount = !deleteAccount" class="options">
<input v-model="deleteAccount" type="checkbox" id="deleteAccount"/> <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> </div>
<Button @click="closeModal()">Annuler</Button> <Button @click="closeModal()">Annuler</Button>
<Button :disabled="!deleteAccount" @click="deleteAcc()">Confirmer la suppression</Button> <Button :disabled="!deleteAccount" @click="deleteAcc()">Confirmer la suppression</Button>
+93 -18
View File
@@ -1,47 +1,122 @@
<template> <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"> <ModalTree title="Thème" icon="fa-solid fa-palette">
<ThemeSelector /> <ThemeSelector />
</ModalTree> </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"> <ModalTree title="Support" icon="fa-solid fa-headset">
<BugReport/> <BugReport />
</ModalTree> </ModalTree>
<ModalTree title="Mon compte & Confidentialité" icon="fa-solid fa-shield-halved"> <ModalTree title="Mon compte & Confidentialité" icon="fa-solid fa-shield-halved">
<PrivacySettings /> <PrivacySettings />
</ModalTree> </ModalTree>
<ModalTree title="Informations" icon="fa-solid fa-circle-info"> <ModalTree title="Informations" icon="fa-solid fa-circle-info">
<InformationSettings/> <InformationSettings />
</ModalTree> </ModalTree>
</Modal> </Modal>
</template> </template>
<script setup> <script setup>
import { computed, nextTick, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Modal from '@/components/UI/Modal.vue'; 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 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 BugReport from './Settings/BugReport.vue';
import { useRouter } from 'vue-router';
import PrivacySettings from './Settings/PrivacySettings.vue'; import PrivacySettings from './Settings/PrivacySettings.vue';
import InformationSettings from './Settings/InformationSettings.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 modal = ref(null);
const youtubeAccount = ref(null);
const accountsSection = ref(null);
const accountsLoaded = ref(false);
const route = useRoute();
const router = useRouter(); 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({ function close() {
open() { modal.value?.close();
if (modal.value) { }
modal.value.open();
} function goToAdmin() {
}, close();
close() { router.push('/admin');
if (modal.value) { }
modal.value.close();
} 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> </script>
<style scoped> <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;
}
+41 -16
View File
@@ -3,7 +3,7 @@
<h2>🔄️ Historique personnel</h2> <h2>🔄️ Historique personnel</h2>
<p class="info">Voici les derniers titres que tu as écoutés (tout serveur confondu) !</p> <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"> <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> </div>
<p v-else-if="loading" class="none"><Icon icon="fa-spinner" spin-pulse /> Chargement en cours</p> <p v-else-if="loading" class="none"><Icon icon="fa-spinner" spin-pulse /> Chargement en cours</p>
<p v-else class="none"> <p v-else class="none">
@@ -13,30 +13,55 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue'; import { onMounted, onUnmounted, ref, computed, watch } from 'vue';
import { IORequest } from '@/utils/IORequest'; import { IORequestAsync } from '@/utils/IORequest';
import VideoComposable from '@/components/Widget/VideoComposable.vue'; import VideoComposable from '@/components/Widget/VideoComposable.vue';
import Events from '@/utils/Events'; import { usePlayerStore } from '@/stores/playerStore';
import { useId, watch } from 'vue'
const id = useId() const playerStore = usePlayerStore();
const history = ref([]); const history = ref([]);
const loading = ref(true); 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(() => { onMounted(() => {
IORequest('/USER/HISTORY', data => { mounted = true;
loading.value = false; refreshHistory();
history.value = data;
});
}) })
Events.on("player:update", () => { onUnmounted(() => {
loading.value = true; mounted = false;
IORequest('/USER/HISTORY', data => { })
loading.value = false;
history.value = data; // 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> </script>
@@ -1,19 +1,31 @@
<template> <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-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> <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> </div>
</template> </template>
<script setup> <script setup>
import { useGlobalStore } from '@/stores/globalStore'; import { useGlobalStore } from '@/stores/globalStore';
import { usePlayerStore } from '@/stores/playerStore';
import Tag from '@/components/UI/Tag.vue'; import Tag from '@/components/UI/Tag.vue';
import { onMounted } from 'vue'; import { computed, onMounted } from 'vue';
import { updateChannel } from '@/utils/Logic'; import { updateChannel } from '@/utils/Logic';
const globalStore = useGlobalStore(); 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(() => { 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> </script>
@@ -37,4 +49,4 @@ onMounted(() => {
white-space: nowrap; 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 v-else class="search-playlist-thumbnail"><div class="defaultIcon"><Icon icon="fa-music" /></div></div>
<div class="search-playlist-info"> <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-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"> <div @click="openAuthorPage()" class="search-playlist-author-info">
<img v-if="results.authorAvatar" :src="results.authorAvatar" alt="Author Thumbnail" class="search-playlist-author" /> <img v-if="results.authorAvatar" :src="results.authorAvatar" alt="Author Thumbnail" class="search-playlist-author" />
<div v-else class="search-playlist-author-placeholder"></div> <div v-else class="search-playlist-author-placeholder"></div>
+17
View File
@@ -11,6 +11,7 @@ import { useLoginStore } from './stores/loginStore'
import { useGlobalStore } from './stores/globalStore' import { useGlobalStore } from './stores/globalStore'
import Interface from './views/Interface.vue' import Interface from './views/Interface.vue'
import events from './utils/Events' import events from './utils/Events'
import OAuthCallback from './views/OAuthCallback.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -22,6 +23,10 @@ const router = createRouter({
{path: "/redirect", component: Redirect}, {path: "/redirect", component: Redirect},
{path: "/servers", component: Servers}, {path: "/servers", component: Servers},
{path: "/servers/:guildId", component: Interface, props: true}, {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"}}, {path: "/:pathMatch(.*)*", component: PageError, props: {message: "404 - Page inexistante"}},
] ]
}) })
@@ -62,6 +67,18 @@ router.beforeEach((to, from, next) => {
return 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")) { if (to.path === "/login" && localStorage.getItem("token")) {
next("/") next("/")
return return
+298
View File
@@ -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
};
});
+319
View File
@@ -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
};
});
+58
View File
@@ -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
View File
@@ -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) { export function IOListener(event, callback) {
console.log(`IOListener: Listen for event ${event}`); console.log(`IOListener: Listen for event ${event}`);
socket.on(event, (data) => { const handler = (data) => {
if (callback && typeof callback === 'function') { if (callback && typeof callback === 'function') {
console.log(`IOListener: Received data for event ${event}:`, data); console.log(`IOListener: Received data for event ${event}:`, data);
callback(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);
});
});
}
+27 -8
View File
@@ -4,13 +4,14 @@
</template> </template>
<script setup> <script setup>
import Loader from '@/utils/Loader.vue'; import Loader from '@/utils/Loader.vue';
import { ref } from 'vue'; import { ref, onUnmounted } from 'vue';
import { useLoginStore } from '@/stores/loginStore'; import { useLoginStore } from '@/stores/loginStore';
import { socket } from '@/socket.js'; import { socket } from '@/socket.js';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { IOListener, IORequest } from '@/utils/IORequest'; import { IOListener, IORequest } from '@/utils/IORequest';
import { useUserStore } from '@/stores/userStore'; import { useUserStore } from '@/stores/userStore';
import { useGlobalStore } from '@/stores/globalStore'; import { useGlobalStore } from '@/stores/globalStore';
import { usePlayerStore } from '@/stores/playerStore';
import events from '@/utils/Events.js'; import events from '@/utils/Events.js';
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 loginStore = useLoginStore();
const userStore = useUserStore(); const userStore = useUserStore();
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
const playerStore = usePlayerStore();
loginStore.clearSession(); loginStore.clearSession();
socket.auth.sessionId = null; 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", () => { IOListener("/GUILD/UPDATE", () => {
IORequest("/GUILD/LIST", (response) => { if (guildListTimer) return;
if(response) { guildListTimer = setTimeout(() => {
userStore.userInfo.guilds = response; guildListTimer = null;
Events.emit("GUILD_LIST_UPDATE", response); 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) => { IOListener("AUTH_ERROR", (error) => {
@@ -102,4 +121,4 @@ import Events from '@/utils/Events.js';
} }
}, 3000); // Essai de reconnexion après 5 secondes }, 3000); // Essai de reconnexion après 5 secondes
} }
</script>r </script>
+96 -62
View File
@@ -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) { export function getReadableDuration(duration) {
var max = "" const total = toSeconds(duration)
duration *= 1000; const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const seconds = total % 60
const maxhours = Math.floor(duration / 3600000); if (hours > 0) {
return `${hours}h${pad(minutes)}m${pad(seconds)}s`
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}`;
} }
return `${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
} }
// Get hh:mm:ss format // 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) { export function getVideoDuration(duration) {
var max = "" const value = toFiniteNumber(duration)
duration *= 1000; 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); if (hours > 0) {
var maxsec = Math.floor(duration / 1000) - (Math.floor(duration / 1000 / 60) * 60); return `${hours}:${pad(minutes)}:${pad(seconds)}`
if (maxsec < 10) {
maxsec = `0${maxsec}`;
} }
return `${minutes}:${pad(seconds)}`
if(maxhours != 0) {
if (maxmin < 10) {
maxmin = `0${maxmin}`;
}
max = maxhours + ":" + maxmin + ":" + maxsec
} else {
max = maxmin + ":" + maxsec
}
return max
} }
/**
* @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) { export function getSecondsDuration(duration) {
// Duration is in format hh:mm:ss and can be just m:ss or mm:ss if (typeof duration === "number") return toSeconds(duration)
var durationArray = duration.split(":"); if (typeof duration !== "string" || duration.trim() === "") return 0
var seconds = 0;
if(durationArray.length == 3) { const parts = duration.trim().split(":").map(part => parseInt(part, 10))
seconds = parseInt(durationArray[0]) * 3600 + parseInt(durationArray[1]) * 60 + parseInt(durationArray[2]); if (parts.length > 3 || parts.some(part => !Number.isFinite(part) || part < 0)) return 0
} else if(durationArray.length == 2) {
seconds = parseInt(durationArray[0]) * 60 + parseInt(durationArray[1]); // On lit de droite à gauche : secondes, minutes, heures
} else { let seconds = 0
seconds = parseInt(durationArray[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")
}
+10
View File
@@ -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>
+15
View File
@@ -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>