Files
Femboy-Croissant-Bot/server/transcript-server.js
2026-03-15 11:58:43 +01:00

1120 lines
29 KiB
JavaScript

/**
* Serveur web pour héberger les transcripts avec authentification Discord
* Interface professionnelle avec dashboard et statistiques
*/
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const path = require('path');
const fs = require('fs');
const db = require('../functions/database/db.js');
// Note: passport-discord peut nécessiter une installation séparée
let DiscordStrategy;
try {
DiscordStrategy = require('passport-discord').Strategy;
} catch (err) {
console.warn('⚠️ passport-discord non installé. Installer avec: npm install passport-discord');
DiscordStrategy = null;
}
const app = express();
const PORT = process.env.TRANSCRIPT_PORT || 3000;
// Middleware pour parser les données
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Configuration de la session
app.use(session({
secret: process.env.SESSION_SECRET || 'change-me-in-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 heures
}
}));
// Configuration Passport
app.use(passport.initialize());
app.use(passport.session());
// Variable pour savoir si la stratégie Discord est configurée
let discordStrategyConfigured = false;
// Récupérer le Client ID et Secret (avec fallback pour compatibilité)
const discordClientId = process.env.DISCORD_CLIENT_ID || process.env.CLIENT_ID;
const discordClientSecret = process.env.DISCORD_CLIENT_SECRET || process.env.CLIENT_SECRET;
// Configuration Discord OAuth
if (DiscordStrategy && discordClientId && discordClientSecret) {
try {
passport.use('discord', new DiscordStrategy({
clientID: discordClientId,
clientSecret: discordClientSecret,
callbackURL: process.env.DISCORD_CALLBACK_URL || `http://localhost:${PORT}/auth/discord/callback`,
scope: ['identify', 'guilds']
}, (accessToken, refreshToken, profile, done) => {
return done(null, profile);
}));
discordStrategyConfigured = true;
console.log('✅ Stratégie Discord OAuth configurée avec succès');
} catch (err) {
console.error('❌ Erreur lors de la configuration de la stratégie Discord:', err);
discordStrategyConfigured = false;
}
} else {
if (!DiscordStrategy) {
console.warn('⚠️ passport-discord non installé. Installer avec: npm install passport-discord');
} else {
if (!discordClientId) {
console.warn('⚠️ DISCORD_CLIENT_ID ou CLIENT_ID manquant dans .env');
}
if (!discordClientSecret) {
console.warn('⚠️ DISCORD_CLIENT_SECRET ou CLIENT_SECRET manquant dans .env');
}
console.warn('⚠️ Discord OAuth non configuré. Configurer DISCORD_CLIENT_SECRET (ou CLIENT_SECRET) et DISCORD_CLIENT_ID (ou CLIENT_ID) dans .env');
}
discordStrategyConfigured = false;
}
passport.serializeUser((user, done) => {
done(null, user);
});
passport.deserializeUser((obj, done) => {
done(null, obj);
});
// Middleware pour vérifier l'authentification
function isAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.redirect('/login');
}
// Middleware pour vérifier les permissions
async function hasPermission(req, res, next) {
if (!req.isAuthenticated()) {
return res.redirect('/login');
}
const filePath = req.params[0]; // Ex: plainte/2025-11-09/file.html (sans le préfixe /tickets/ ou /transcripts/)
// Le chemin dans la DB est transcripts/type/date/file.html ou tickets/type/date/file.html (ancien format)
// On cherche avec les deux formats pour compatibilité
const transcriptsPath = `transcripts/${filePath}`;
const ticketsPath = `tickets/${filePath}`;
try {
// Chercher avec les deux formats (nouveau: transcripts/, ancien: tickets/)
const [tickets] = await db.query(
'SELECT * FROM tickets WHERE transcriptPath = ? OR transcriptPath = ? OR transcriptPath LIKE ?',
[transcriptsPath, ticketsPath, `%${filePath}`]
);
if (tickets.length === 0) {
return res.status(404).send('Transcript introuvable.');
}
const ticket = tickets[0];
const userId = req.user.id;
if (ticket.userId === userId) {
return next();
}
// Vérifier si l'utilisateur est modérateur
// Pour l'instant, on vérifie si l'utilisateur est dans la liste des modérateurs configurés
// TODO: Implémenter une vérification Discord réelle (rôles, permissions)
const moderatorIds = process.env.MODERATOR_IDS ? process.env.MODERATOR_IDS.split(',') : [];
if (moderatorIds.includes(userId)) {
return next();
}
// Sinon, accès refusé
return res.status(403).send('Accès refusé. Vous n\'avez pas la permission d\'accéder à ce transcript.');
} catch (err) {
console.error('Erreur lors de la vérification des permissions:', err);
return res.status(500).send('Erreur serveur.');
}
}
// Fonction pour générer le HTML de la page
function generateDashboardHTML(user, stats, userTickets, allTickets) {
// Gérer les nouveaux et anciens formats de Discord (discriminator peut être null ou '0')
let userAvatar;
if (user.avatar) {
userAvatar = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`;
} else if (user.discriminator && user.discriminator !== '0') {
userAvatar = `https://cdn.discordapp.com/embed/avatars/${user.discriminator % 5}.png`;
} else {
// Pour les nouveaux comptes sans discriminator, utiliser l'ID mod 5
userAvatar = `https://cdn.discordapp.com/embed/avatars/${parseInt(user.id) % 5}.png`;
}
return `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Transcripts France Femboy</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #333;
}
.navbar {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
padding: 15px 30px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-brand {
font-size: 1.5em;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-decoration: none;
}
.navbar-links {
display: flex;
gap: 20px;
align-items: center;
}
.navbar-links a {
color: #667eea;
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.navbar-links a:hover {
color: #5568d3;
}
.user-menu {
display: flex;
align-items: center;
gap: 15px;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid #667eea;
}
.user-name {
font-weight: 600;
color: #333;
}
.logout-btn {
padding: 8px 16px;
background: #ef4444;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
transition: background 0.2s;
}
.logout-btn:hover {
background: #dc2626;
}
.container {
max-width: 1400px;
margin: 30px auto;
padding: 0 30px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
}
.stat-icon {
font-size: 2.5em;
margin-bottom: 10px;
}
.stat-label {
font-size: 0.9em;
color: #666;
margin-bottom: 5px;
}
.stat-value {
font-size: 2em;
font-weight: 700;
color: #333;
}
.section {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.section-title {
font-size: 1.5em;
font-weight: 700;
margin-bottom: 20px;
color: #333;
display: flex;
align-items: center;
gap: 10px;
}
.tickets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.ticket-card {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 20px;
border-radius: 8px;
transition: transform 0.2s, box-shadow 0.2s;
}
.ticket-card:hover {
transform: translateX(5px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.ticket-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 10px;
}
.ticket-id {
font-weight: 600;
color: #667eea;
font-size: 1.1em;
}
.ticket-type {
display: inline-block;
padding: 4px 12px;
background: #667eea;
color: white;
border-radius: 12px;
font-size: 0.8em;
font-weight: 600;
}
.ticket-info {
font-size: 0.9em;
color: #666;
margin-bottom: 15px;
}
.ticket-status {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.8em;
font-weight: 600;
margin-bottom: 10px;
}
.status-open {
background: #10b981;
color: white;
}
.status-closed {
background: #ef4444;
color: white;
}
.status-pending {
background: #f59e0b;
color: white;
}
.view-btn {
display: inline-block;
padding: 8px 16px;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
transition: background 0.2s;
}
.view-btn:hover {
background: #5568d3;
}
.no-tickets {
text-align: center;
padding: 40px;
color: #666;
font-style: italic;
}
.type-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.8em;
font-weight: 600;
margin-right: 8px;
}
.type-support { background: #3b82f6; color: white; }
.type-plainte { background: #8b5cf6; color: white; }
.type-plainte-staff { background: #ef4444; color: white; }
.type-candidature { background: #10b981; color: white; }
.type-probleme-technique { background: #f59e0b; color: white; }
</style>
</head>
<body>
<nav class="navbar">
<a href="/" class="navbar-brand">🎫 Transcripts Dashboard</a>
<div style="display: flex; align-items: center; gap: 20px;">
<div class="navbar-links">
<a href="/transcripts">Tous les Transcripts</a>
</div>
<div class="user-menu">
<div class="user-info">
<img src="${userAvatar}" alt="${escapeHtml(user.username)}" class="user-avatar">
<span class="user-name">${escapeHtml(user.username)}</span>
</div>
<a href="/auth/logout" class="logout-btn">Déconnexion</a>
</div>
</div>
</nav>
<div class="container">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">📊</div>
<div class="stat-label">Tickets Totaux</div>
<div class="stat-value">${stats.totalTickets}</div>
</div>
<div class="stat-card">
<div class="stat-icon">📝</div>
<div class="stat-label">Mes Tickets</div>
<div class="stat-value">${stats.myTickets}</div>
</div>
<div class="stat-card">
<div class="stat-icon">🔓</div>
<div class="stat-label">Tickets Ouverts</div>
<div class="stat-value">${stats.openTickets}</div>
</div>
<div class="stat-card">
<div class="stat-icon">🔒</div>
<div class="stat-label">Tickets Fermés</div>
<div class="stat-value">${stats.closedTickets}</div>
</div>
</div>
<div class="section">
<h2 class="section-title">📄 Mes Tickets</h2>
${userTickets.length > 0 ? `
<div class="tickets-grid">
${userTickets.map(ticket => generateTicketCard(ticket)).join('')}
</div>
` : '<div class="no-tickets">Aucun ticket réclamé.</div>'}
</div>
<div class="section">
<h2 class="section-title">🌐 Tous les Transcripts</h2>
<p style="margin-bottom: 15px; color: #666;">Voir tous les transcripts sur la <a href="/transcripts" style="color: #667eea; font-weight: 600;">page dédiée</a>.</p>
${allTickets.length > 0 ? `
<div class="tickets-grid">
${allTickets.slice(0, 6).map(ticket => generateTicketCard(ticket)).join('')}
</div>
${allTickets.length > 6 ? `<p style="text-align: center; margin-top: 20px;"><a href="/transcripts" class="view-btn">Voir tous les transcripts (${allTickets.length})</a></p>` : ''}
` : '<div class="no-tickets">Aucun transcript disponible.</div>'}
</div>
</div>
</body>
</html>`;
}
// Fonction pour formater la date selon le fuseau horaire (France par défaut)
// Format: "9/11/2025, 18h43" (format français court avec heure)
function formatDate(timestamp, timezone = 'Europe/Paris') {
try {
const date = new Date(timestamp);
// Utiliser Intl.DateTimeFormat pour gérer correctement les fuseaux horaires
const formatter = new Intl.DateTimeFormat('fr-FR', {
timeZone: timezone,
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
// Formater la date
const parts = formatter.formatToParts(date);
const day = parts.find(p => p.type === 'day').value;
const month = parts.find(p => p.type === 'month').value;
const year = parts.find(p => p.type === 'year').value;
const hour = parts.find(p => p.type === 'hour').value;
const minute = parts.find(p => p.type === 'minute').value;
return `${day}/${month}/${year}, ${hour}h${minute}`;
} catch (err) {
// Fallback sur le format français standard
const date = new Date(timestamp);
const day = date.getDate();
const month = date.getMonth() + 1;
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day}/${month}/${year}, ${hours}h${minutes}`;
}
}
function generateTicketCard(ticket) {
const date = formatDate(ticket.createdAt);
const url = ticket.transcriptPath ? `/${ticket.transcriptPath}` : '#';
const typeClass = `type-${ticket.type.toLowerCase().replace(/\s+/g, '-')}`;
const statusClass = `status-${ticket.status.toLowerCase()}`;
const typeEmojis = {
'Support': '💬',
'Plainte': '📢',
'Plainte Staff': '⚠️',
'Candidature': '📝',
'Problème Technique': '🔧'
};
return `
<div class="ticket-card">
<div class="ticket-header">
<div class="ticket-id">${typeEmojis[ticket.type] || '🎫'} ${ticket.ticketId}</div>
<span class="ticket-type ${typeClass}">${ticket.type}</span>
</div>
<div class="ticket-info">
<div><strong>Créé par:</strong> ${escapeHtml(ticket.userTag)}</div>
${ticket.claimedBy ? `<div><strong>Géré par:</strong> ${ticket.claimedByTag || 'Modérateur'}</div>` : ''}
<div><strong>Date:</strong> ${date}</div>
</div>
<div>
<span class="ticket-status ${statusClass}">${ticket.status}</span>
</div>
${ticket.transcriptPath ? `<a href="${url}" class="view-btn">Voir le transcript</a>` : '<span style="color: #999;">Transcript non disponible</span>'}
</div>
`;
}
function escapeHtml(text) {
if (!text) return '';
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
// Routes
app.get('/login', (req, res) => {
res.send(`
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion - Transcripts</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
margin: 0;
}
.login-box {
background: white;
padding: 50px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
text-align: center;
max-width: 400px;
width: 90%;
}
.login-box h1 {
margin-bottom: 10px;
color: #333;
}
.login-box p {
color: #666;
margin-bottom: 30px;
}
.login-button {
display: inline-block;
padding: 15px 30px;
background: #5865F2;
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
transition: background 0.2s;
}
.login-button:hover {
background: #4752C4;
}
</style>
</head>
<body>
<div class="login-box">
<h1>🔐 Connexion requise</h1>
<p>Pour accéder aux transcripts, vous devez vous connecter avec Discord.</p>
<a href="/auth/discord" class="login-button">Se connecter avec Discord</a>
</div>
</body>
</html>
`);
});
app.get('/auth/discord', (req, res, next) => {
if (!discordStrategyConfigured) {
return res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Erreur de Configuration</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
margin: 0;
padding: 20px;
}
.error-box {
background: white;
padding: 40px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 600px;
}
.error-box h1 {
color: #ef4444;
margin-bottom: 20px;
}
.error-box code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
</style>
</head>
<body>
<div class="error-box">
<h1>⚠️ Discord OAuth non configuré</h1>
<p>Pour utiliser l'authentification Discord, vous devez :</p>
<ol>
<li>Installer <code>passport-discord</code> : <code>npm install passport-discord</code></li>
<li>Configurer <code>DISCORD_CLIENT_ID</code> et <code>DISCORD_CLIENT_SECRET</code> dans <code>.env</code></li>
<li>Configurer <code>DISCORD_CALLBACK_URL</code> dans <code>.env</code></li>
</ol>
<p><strong>Voir <code>server/README.md</code> pour plus d'informations.</strong></p>
</div>
</body>
</html>
`);
}
passport.authenticate('discord')(req, res, next);
});
app.get('/auth/discord/callback',
(req, res, next) => {
if (!discordStrategyConfigured) {
return res.redirect('/login?error=not_configured');
}
passport.authenticate('discord', { failureRedirect: '/login?error=auth_failed' })(req, res, next);
},
(req, res) => {
res.redirect('/');
}
);
app.get('/auth/logout', (req, res) => {
req.logout(() => {
res.redirect('/login');
});
});
// Servir les fichiers statiques (CSS, JS, images, etc.)
// IMPORTANT: Cette route doit être AVANT les routes avec wildcards
app.use('/static', express.static(path.join(__dirname, 'public')));
// Servir les fichiers de transcripts
// Support à la fois /tickets/* et /transcripts/* pour compatibilité
app.get('/tickets/*', isAuthenticated, hasPermission, (req, res) => {
const filePath = req.params[0]; // Ex: plainte/2025-11-09/file.html
// Les fichiers sont toujours dans transcripts/, même si l'URL est /tickets/
const fullPath = path.join(process.cwd(), 'transcripts', filePath);
if (!fs.existsSync(fullPath)) {
return res.status(404).send('Transcript introuvable.');
}
res.sendFile(fullPath);
});
app.get('/transcripts/*', isAuthenticated, hasPermission, (req, res) => {
const filePath = req.params[0]; // Ex: plainte/2025-11-09/file.html
// Les fichiers sont dans transcripts/
const fullPath = path.join(process.cwd(), 'transcripts', filePath);
if (!fs.existsSync(fullPath)) {
return res.status(404).send('Transcript introuvable.');
}
res.sendFile(fullPath);
});
// Page d'accueil (Dashboard)
app.get('/', isAuthenticated, async (req, res) => {
try {
const userId = req.user.id;
// Récupérer les statistiques
const [totalStats] = await db.query('SELECT COUNT(*) as total FROM tickets');
const [myTicketsStats] = await db.query('SELECT COUNT(*) as total FROM tickets WHERE claimedBy = ?', [userId]);
const [openStats] = await db.query('SELECT COUNT(*) as total FROM tickets WHERE status = ?', ['Ouvert']);
const [closedStats] = await db.query('SELECT COUNT(*) as total FROM tickets WHERE status = ?', ['Fermé']);
const stats = {
totalTickets: totalStats[0].total,
myTickets: myTicketsStats[0].total,
openTickets: openStats[0].total,
closedTickets: closedStats[0].total
};
// Récupérer les tickets claimés par l'utilisateur (pour "Mes Tickets")
const [userTickets] = await db.query(
'SELECT * FROM tickets WHERE claimedBy = ? AND transcriptPath IS NOT NULL ORDER BY createdAt DESC LIMIT 20',
[userId]
);
// Récupérer tous les transcripts accessibles (avec transcriptPath) pour la page dédiée
const [allTickets] = await db.query(
'SELECT * FROM tickets WHERE transcriptPath IS NOT NULL ORDER BY createdAt DESC',
[]
);
const html = generateDashboardHTML(req.user, stats, userTickets, allTickets);
res.send(html);
} catch (err) {
console.error('Erreur lors de la génération du dashboard:', err);
res.status(500).send('Erreur serveur.');
}
});
// Page dédiée pour tous les transcripts (tableau)
app.get('/transcripts', isAuthenticated, async (req, res) => {
try {
const userId = req.user.id;
// Récupérer tous les transcripts avec les informations des modérateurs
const [allTickets] = await db.query(
`SELECT * FROM tickets
WHERE transcriptPath IS NOT NULL
ORDER BY createdAt DESC`,
[]
);
// Récupérer les informations des modérateurs qui ont claim les tickets
// Note: On pourrait utiliser l'API Discord pour récupérer les vrais noms, mais pour l'instant on utilise les tags stockés
// Pour améliorer, on pourrait créer une table de cache des utilisateurs Discord
const html = generateTranscriptsPageHTML(req.user, allTickets);
res.send(html);
} catch (err) {
console.error('Erreur lors de la génération de la page transcripts:', err);
res.status(500).send('Erreur serveur.');
}
});
// Fonction pour générer la page HTML des transcripts avec tableau
function generateTranscriptsPageHTML(user, tickets) {
// Gérer les nouveaux et anciens formats de Discord (discriminator peut être null ou '0')
let userAvatar;
if (user.avatar) {
userAvatar = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`;
} else if (user.discriminator && user.discriminator !== '0') {
userAvatar = `https://cdn.discordapp.com/embed/avatars/${user.discriminator % 5}.png`;
} else {
userAvatar = `https://cdn.discordapp.com/embed/avatars/${parseInt(user.id) % 5}.png`;
}
const typeEmojis = {
'Support': '💬',
'Plainte': '📢',
'Plainte Staff': '⚠️',
'Candidature': '📝',
'Problème Technique': '🔧'
};
const statusColors = {
'Ouvert': '#10b981',
'Fermé': '#ef4444',
'En attente': '#f59e0b',
'Supprimé': '#6b7280'
};
return `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tous les Transcripts - France Femboy</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #333;
}
.navbar {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
padding: 15px 30px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-brand {
font-size: 1.5em;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.navbar-links {
display: flex;
gap: 20px;
align-items: center;
}
.navbar-links a {
color: #667eea;
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.navbar-links a:hover {
color: #5568d3;
}
.user-menu {
display: flex;
align-items: center;
gap: 15px;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid #667eea;
}
.user-name {
font-weight: 600;
color: #333;
}
.logout-btn {
padding: 8px 16px;
background: #ef4444;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
transition: background 0.2s;
}
.logout-btn:hover {
background: #dc2626;
}
.container {
max-width: 1400px;
margin: 30px auto;
padding: 0 30px;
}
.section {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.section-title {
font-size: 1.5em;
font-weight: 700;
margin-bottom: 20px;
color: #333;
display: flex;
align-items: center;
gap: 10px;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
thead {
background: #f8f9fa;
}
th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #333;
border-bottom: 2px solid #e5e7eb;
}
td {
padding: 12px;
border-bottom: 1px solid #e5e7eb;
}
tr:hover {
background: #f8f9fa;
}
.ticket-id {
font-weight: 600;
color: #667eea;
font-family: 'Courier New', monospace;
}
.type-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85em;
font-weight: 600;
}
.type-support { background: #3b82f6; color: white; }
.type-plainte { background: #8b5cf6; color: white; }
.type-plainte-staff { background: #ef4444; color: white; }
.type-candidature { background: #10b981; color: white; }
.type-probleme-technique { background: #f59e0b; color: white; }
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85em;
font-weight: 600;
color: white;
}
.view-link {
color: #667eea;
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.view-link:hover {
color: #5568d3;
text-decoration: underline;
}
.no-tickets {
text-align: center;
padding: 40px;
color: #666;
font-style: italic;
}
.moderator-name {
color: #667eea;
font-weight: 500;
}
.date-cell {
font-family: 'Courier New', monospace;
color: #666;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-brand">🎫 Transcripts Dashboard</div>
<div class="navbar-links">
<a href="/">Dashboard</a>
<a href="/transcripts">Tous les Transcripts</a>
</div>
<div class="user-menu">
<div class="user-info">
<img src="${userAvatar}" alt="${escapeHtml(user.username)}" class="user-avatar">
<span class="user-name">${escapeHtml(user.username)}</span>
</div>
<a href="/auth/logout" class="logout-btn">Déconnexion</a>
</div>
</nav>
<div class="container">
<div class="section">
<h2 class="section-title">📋 Tous les Transcripts</h2>
${tickets.length > 0 ? `
<div class="table-container">
<table>
<thead>
<tr>
<th>Ticket</th>
<th>Type</th>
<th>Modérateur</th>
<th>Date</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${tickets.map(ticket => {
const typeClass = `type-${ticket.type.toLowerCase().replace(/\s+/g, '-')}`;
const statusColor = statusColors[ticket.status] || '#6b7280';
const date = formatDate(ticket.createdAt);
const url = ticket.transcriptPath ? `/${ticket.transcriptPath}` : '#';
// Récupérer le nom du modérateur qui a claim le ticket
let moderatorDisplay = 'Non assigné';
if (ticket.claimedBy && ticket.claimedByTag) {
moderatorDisplay = `Géré par: ${escapeHtml(ticket.claimedByTag)}`;
} else if (ticket.claimedBy) {
moderatorDisplay = 'Géré par: Modérateur';
}
return `
<tr>
<td class="ticket-id">${typeEmojis[ticket.type] || '🎫'} ${ticket.ticketId}</td>
<td><span class="type-badge ${typeClass}">${ticket.type}</span></td>
<td class="moderator-name">${moderatorDisplay}</td>
<td class="date-cell">${date}</td>
<td><span class="status-badge" style="background: ${statusColor};">${ticket.status}</span></td>
<td>${ticket.transcriptPath ? `<a href="${url}" class="view-link">Voir</a>` : '<span style="color: #999;">N/A</span>'}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
` : '<div class="no-tickets">Aucun transcript disponible.</div>'}
</div>
</div>
</body>
</html>`;
}
// Démarrer le serveur
app.listen(PORT, () => {
console.log(`🌐 Serveur de transcripts démarré sur le port ${PORT}`);
console.log(`🔗 URL: http://localhost:${PORT}`);
console.log(`📝 Login: http://localhost:${PORT}/login`);
});