This commit is contained in:
lfirmin
2026-05-10 18:07:51 +02:00
parent 50af2452b1
commit a5f888b3c1
59 changed files with 6682 additions and 108 deletions
+103
View File
@@ -0,0 +1,103 @@
{{define "title"}}Audit Log{{end}}
{{define "header"}}Audit Log{{end}}
{{define "content"}}
<!-- Filters -->
<form method="GET" action="/audit" class="bg-discord-card rounded-lg border border-discord-border p-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 mb-3">
<div>
<label class="block text-xs text-gray-400 mb-1">Admin ID (interne)</label>
<input type="text" name="admin_id" value="{{.FAdminID}}" placeholder="ex: 1"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Action</label>
<select name="action" class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
<option value="">Toutes les actions</option>
{{range .Actions}}
<option value="{{.}}" {{if eq . $.FAction}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Du</label>
<input type="date" name="from" value="{{.FFrom}}"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Au</label>
<input type="date" name="to" value="{{.FTo}}"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
</div>
</div>
<div class="flex items-center gap-2">
<button type="submit"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors">
Filtrer
</button>
<a href="/audit" class="px-4 py-2 text-sm text-gray-400 hover:text-white border border-discord-border rounded-lg transition-colors">
Réinitialiser
</a>
<span class="text-xs text-gray-500 ml-auto">{{.Total}} entrée{{if gt .Total 1}}s{{end}}</span>
</div>
</form>
<!-- Audit table -->
<div class="bg-discord-card rounded-lg border border-discord-border overflow-hidden">
{{if .Entries}}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase border-b border-discord-border">
<th class="px-4 py-2 text-left">Date</th>
<th class="px-4 py-2 text-left">Admin ID</th>
<th class="px-4 py-2 text-left">Action</th>
<th class="px-4 py-2 text-left">Entité</th>
<th class="px-4 py-2 text-left">Entité ID</th>
<th class="px-4 py-2 text-left">IP</th>
</tr>
</thead>
<tbody class="divide-y divide-discord-border">
{{range .Entries}}
<tr class="hover:bg-white/5 transition-colors">
<td class="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">{{.CreatedAt.Format "02/01/2006 15:04:05"}}</td>
<td class="px-4 py-2 text-gray-300 font-mono text-xs">{{.AdminID}}</td>
<td class="px-4 py-2">
<span class="px-2 py-0.5 rounded text-xs font-mono bg-discord-hover text-indigo-300 border border-discord-border">{{.Action}}</span>
</td>
<td class="px-4 py-2 text-gray-400 text-xs">{{.EntityType}}</td>
<td class="px-4 py-2 text-gray-400 text-xs font-mono">
{{if .EntityID.Valid}}{{.EntityID.Int64}}{{else}}—{{end}}
</td>
<td class="px-4 py-2 text-gray-500 text-xs font-mono">{{.IPAddress}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if gt .Pages 1}}
<div class="px-5 py-3 border-t border-discord-border flex items-center gap-2 text-sm">
{{if gt .Page 1}}
<a href="?page={{add .Page -1}}&admin_id={{.FAdminID}}&action={{.FAction}}&from={{.FFrom}}&to={{.FTo}}"
class="px-3 py-1 rounded border border-discord-border text-gray-300 hover:text-white hover:border-gray-500 transition-colors">← Précédent</a>
{{end}}
<span class="text-gray-400">Page {{.Page}} / {{.Pages}}</span>
{{if lt .Page .Pages}}
<a href="?page={{add .Page 1}}&admin_id={{.FAdminID}}&action={{.FAction}}&from={{.FFrom}}&to={{.FTo}}"
class="px-3 py-1 rounded border border-discord-border text-gray-300 hover:text-white hover:border-gray-500 transition-colors">Suivant →</a>
{{end}}
</div>
{{end}}
{{else}}
<div class="px-5 py-12 text-center text-gray-500 text-sm">
Aucune entrée d'audit pour ces critères.
</div>
{{end}}
</div>
{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{define "auth_layout"}}
<!DOCTYPE html>
<html lang="fr" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}TicketBot Admin{{end}}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/static/app.css">
<script>
tailwind.config = {
theme: { extend: { colors: { discord: { bg: '#1e1f22', card: '#2b2d31', accent: '#5865f2', hover: '#404249', border: '#3f4147' } } } }
}
</script>
</head>
<body class="h-full bg-discord-bg text-gray-100 flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white">TicketBot</h1>
<p class="text-gray-400 text-sm mt-1">Panel d'administration</p>
</div>
<div class="bg-discord-card rounded-lg shadow-xl p-8 border border-discord-border">
{{block "content" .}}{{end}}
</div>
</div>
</body>
</html>
{{end}}
+158
View File
@@ -0,0 +1,158 @@
{{define "title"}}Convocations{{end}}
{{define "header"}}Convocations{{end}}
{{define "content"}}
<!-- Config form -->
<div class="bg-discord-card rounded-lg border border-discord-border p-5 mb-6">
<h3 class="font-semibold text-white mb-4">Configuration des convocations</h3>
<form method="POST" action="/convocations" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div>
<label class="block text-xs text-gray-400 mb-1">ID de catégorie Discord (pour les salons)</label>
<input type="text" name="category_id" value="{{if .Config}}{{.Config.CategoryID}}{{end}}"
placeholder="ex: 123456789012345678"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">ID du salon de logs</label>
<input type="text" name="log_channel_id" value="{{if .Config}}{{.Config.LogChannelID}}{{end}}"
placeholder="ex: 123456789012345678"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent">
</div>
<div class="sm:col-span-2 flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="hidden" name="modal_enabled" value="0">
<input type="checkbox" name="modal_enabled" value="1"
{{if and .Config .Config.ModalEnabled}}checked{{end}}
class="w-4 h-4 rounded accent-indigo-500">
<span class="text-sm text-gray-300">Activer le modal (titre + description)</span>
</label>
</div>
<div class="sm:col-span-2 border-t border-discord-border pt-4 mt-1">
<p class="text-xs text-gray-400 uppercase font-semibold tracking-wide mb-3">Panel Discord (bouton convocation)</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs text-gray-400 mb-1">Salon d'envoi du panel</label>
<input type="text" name="panel_channel_id" value="{{if .Config}}{{.Config.PanelChannelID}}{{end}}"
placeholder="ex: 123456789012345678"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Couleur de l'embed</label>
<input type="color" name="panel_embed_color" value="{{if and .Config .Config.PanelEmbedColor}}{{.Config.PanelEmbedColor}}{{else}}#5865f2{{end}}"
class="h-10 w-full rounded cursor-pointer bg-discord-hover border border-discord-border">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Titre de l'embed</label>
<input type="text" name="panel_embed_title" value="{{if .Config}}{{.Config.PanelEmbedTitle}}{{else}}Créer une convocation{{end}}"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent"
placeholder="Créer une convocation">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Description de l'embed</label>
<textarea name="panel_embed_description" rows="2"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent resize-none"
placeholder="Cliquez sur le bouton pour ouvrir une convocation...">{{if .Config}}{{.Config.PanelEmbedDescription}}{{end}}</textarea>
</div>
</div>
</div>
<div class="sm:col-span-2 flex items-center gap-3 justify-end">
<button type="submit"
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors">
Sauvegarder
</button>
</div>
</form>
<!-- Send panel button (separate form) -->
{{if and .Config .Config.PanelChannelID}}
<div class="mt-3 pt-3 border-t border-discord-border flex items-center gap-3">
<form method="POST" action="/convocations/send-panel">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors">
Envoyer le panel sur Discord
</button>
</form>
{{if and .Config .Config.PanelMessageID}}
<span class="text-xs text-green-400 bg-green-900/30 px-2 py-1 rounded">Panel envoyé</span>
{{end}}
</div>
{{end}}
</div>
<!-- Convocation list -->
<div class="bg-discord-card rounded-lg border border-discord-border overflow-hidden">
<div class="px-5 py-4 border-b border-discord-border flex items-center justify-between">
<h3 class="font-semibold text-white">Convocations passées</h3>
<span class="text-xs text-gray-400">{{.Total}} au total</span>
</div>
{{if .Tickets}}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase border-b border-discord-border">
<th class="px-4 py-2 text-left">#</th>
<th class="px-4 py-2 text-left">Utilisateur</th>
<th class="px-4 py-2 text-left">Titre</th>
<th class="px-4 py-2 text-left">Staff</th>
<th class="px-4 py-2 text-left">Statut</th>
<th class="px-4 py-2 text-left">Ouvert le</th>
<th class="px-4 py-2 text-left">Fermé le</th>
</tr>
</thead>
<tbody class="divide-y divide-discord-border">
{{range .Tickets}}
<tr class="hover:bg-white/5 transition-colors">
<td class="px-4 py-2 text-indigo-400 font-mono">{{.TicketNumber}}</td>
<td class="px-4 py-2 text-gray-300 font-mono text-xs">{{.UserID}}</td>
<td class="px-4 py-2 text-white">
{{if .TicketTitle.Valid}}{{.TicketTitle.String}}{{else}}<span class="text-gray-500"></span>{{end}}
</td>
<td class="px-4 py-2 text-gray-400 font-mono text-xs">
{{if .ClaimedBy.Valid}}{{.ClaimedBy.String}}{{else}}—{{end}}
</td>
<td class="px-4 py-2">
{{if eq .Status "open"}}
<span class="px-2 py-0.5 rounded text-xs bg-green-900/40 text-green-400 border border-green-800">Ouvert</span>
{{else if eq .Status "claimed"}}
<span class="px-2 py-0.5 rounded text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-800">Claimé</span>
{{else}}
<span class="px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400">Fermé</span>
{{end}}
</td>
<td class="px-4 py-2 text-gray-400 text-xs">{{.OpenedAt.Format "02/01/2006 15:04"}}</td>
<td class="px-4 py-2 text-gray-400 text-xs">
{{if .ClosedAt.Valid}}{{.ClosedAt.Time.Format "02/01/2006 15:04"}}{{else}}—{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if gt .Pages 1}}
<div class="px-5 py-3 border-t border-discord-border flex items-center gap-2 text-sm">
{{if gt .Page 1}}
<a href="?page={{add .Page -1}}" class="px-3 py-1 rounded border border-discord-border text-gray-300 hover:text-white hover:border-gray-500 transition-colors">← Précédent</a>
{{end}}
<span class="text-gray-400">Page {{.Page}} / {{.Pages}}</span>
{{if lt .Page .Pages}}
<a href="?page={{add .Page 1}}" class="px-3 py-1 rounded border border-discord-border text-gray-300 hover:text-white hover:border-gray-500 transition-colors">Suivant →</a>
{{end}}
</div>
{{end}}
{{else}}
<div class="px-5 py-12 text-center text-gray-500 text-sm">
Aucune convocation enregistrée.
</div>
{{end}}
</div>
{{end}}
+140
View File
@@ -0,0 +1,140 @@
{{define "title"}}Dashboard{{end}}
{{define "header"}}Dashboard{{end}}
{{define "content"}}
<!-- Stat cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
<div class="bg-discord-card rounded-lg p-5 border border-discord-border">
<p class="text-xs text-gray-400 uppercase tracking-wide mb-1">Tickets ouverts</p>
<p class="text-3xl font-bold text-white">{{.Stats.OpenTickets}}</p>
</div>
<div class="bg-discord-card rounded-lg p-5 border border-discord-border">
<p class="text-xs text-gray-400 uppercase tracking-wide mb-1">Fermés (30j)</p>
<p class="text-3xl font-bold text-white">{{.Stats.ClosedLast30}}</p>
</div>
<div class="bg-discord-card rounded-lg p-5 border border-discord-border">
<p class="text-xs text-gray-400 uppercase tracking-wide mb-1">Moy. claim (30j)</p>
<p class="text-3xl font-bold text-white">
{{if .Stats.AvgClaimMinutes}}{{.Stats.AvgClaimMinutes}}<span class="text-lg text-gray-400">min</span>{{else}}—{{end}}
</p>
</div>
<div class="bg-discord-card rounded-lg p-5 border border-discord-border">
<p class="text-xs text-gray-400 uppercase tracking-wide mb-1">Moy. résolution (30j)</p>
<p class="text-3xl font-bold text-white">
{{if .Stats.AvgResolutionMinutes}}{{.Stats.AvgResolutionMinutes}}<span class="text-lg text-gray-400">min</span>{{else}}—{{end}}
</p>
</div>
</div>
<!-- Chart + Bot status -->
<div class="grid grid-cols-1 xl:grid-cols-4 gap-6 mb-6">
<!-- Chart tickets / jour -->
<div class="xl:col-span-3 bg-discord-card rounded-lg p-5 border border-discord-border">
<h3 class="font-semibold text-white mb-4">Tickets ouverts — 30 derniers jours</h3>
{{if .Chart}}
<div class="flex items-end gap-1 h-32">
{{range .Chart}}
<div class="flex-1 flex flex-col items-center gap-1 group relative">
<div class="w-full rounded-t bg-indigo-500/70 hover:bg-indigo-400 transition-colors"
style="height: {{if $.ChartMax}}{{percent .Count $.ChartMax}}%{{else}}0%{{end}}; min-height: {{if .Count}}2px{{else}}0{{end}}">
</div>
<span class="text-[9px] text-gray-500 rotate-45 origin-left whitespace-nowrap">{{.Day}}</span>
<span class="absolute bottom-8 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-xs px-1.5 py-0.5 rounded opacity-0 group-hover:opacity-100 pointer-events-none">{{.Count}}</span>
</div>
{{end}}
</div>
{{else}}
<p class="text-gray-500 text-sm">Pas encore de données.</p>
{{end}}
</div>
<!-- Bot status -->
<div class="bg-discord-card rounded-lg p-5 border border-discord-border">
<h3 class="font-semibold text-white mb-4">Statut du bot</h3>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-400">Connexion</dt>
<dd class="{{if .BotOnline}}text-green-400{{else}}text-red-400{{end}} font-medium flex items-center gap-1">
<span class="w-2 h-2 rounded-full {{if .BotOnline}}bg-green-400{{else}}bg-red-400{{end}}"></span>
{{if .BotOnline}}En ligne{{else}}Hors ligne{{end}}
</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-400">Uptime</dt>
<dd class="text-white">{{.Uptime}}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-400">Latence</dt>
<dd class="text-white">{{if .LatencyMs}}{{.LatencyMs}}ms{{else}}—{{end}}</dd>
</div>
{{if .GuildName}}
<div class="flex justify-between">
<dt class="text-gray-400">Serveur</dt>
<dd class="text-white truncate max-w-[120px]">{{.GuildName}}</dd>
</div>
{{end}}
</dl>
</div>
</div>
<!-- Staff table + Panels -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Staff stats -->
<div class="xl:col-span-2 bg-discord-card rounded-lg border border-discord-border overflow-hidden">
<div class="px-5 py-4 border-b border-discord-border">
<h3 class="font-semibold text-white">Tickets par staff (30j)</h3>
</div>
{{if .Staff}}
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase">
<th class="px-5 py-2 text-left">Staff</th>
<th class="px-4 py-2 text-right">Claimés</th>
<th class="px-4 py-2 text-right">Fermés</th>
<th class="px-4 py-2 text-right">Moy. claim</th>
<th class="px-4 py-2 text-right">Moy. résol.</th>
</tr>
</thead>
<tbody class="divide-y divide-discord-border">
{{range .Staff}}
<tr class="hover:bg-white/5 transition-colors">
<td class="px-5 py-3 text-white font-mono text-xs">{{.StaffID}}</td>
<td class="px-4 py-3 text-right text-indigo-400 font-semibold">{{.Claimed}}</td>
<td class="px-4 py-3 text-right text-gray-300">{{.Closed}}</td>
<td class="px-4 py-3 text-right text-gray-400">{{fmtMin .AvgClaimMinutes}}</td>
<td class="px-4 py-3 text-right text-gray-400">{{fmtMin .AvgResolutionMinutes}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="px-5 py-8 text-gray-500 text-sm text-center">Aucun ticket claimé sur les 30 derniers jours.</p>
{{end}}
</div>
<!-- Panels actifs -->
<div class="bg-discord-card rounded-lg border border-discord-border overflow-hidden">
<div class="px-5 py-4 border-b border-discord-border flex items-center justify-between">
<h3 class="font-semibold text-white">Panels</h3>
<a href="/panels/new" class="text-xs text-indigo-400 hover:text-indigo-300">+ Créer</a>
</div>
{{if .Panels}}
<ul class="divide-y divide-discord-border">
{{range .Panels}}
<li class="px-5 py-3 flex items-center justify-between text-sm">
<span class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color:{{.EmbedColor}}"></span>
<span class="text-white font-medium">{{.Name}}</span>
<span class="text-gray-500">{{len .Types}}</span>
</span>
<a href="/panels/{{.ID}}/edit" class="text-indigo-400 hover:text-indigo-300">Éditer →</a>
</li>
{{end}}
</ul>
{{else}}
<p class="px-5 py-8 text-gray-500 text-sm text-center">
<a href="/panels/new" class="text-indigo-400 hover:text-indigo-300">Créer un panel →</a>
</p>
{{end}}
</div>
</div>
{{end}}
+13
View File
@@ -0,0 +1,13 @@
{{define "content"}}
<div class="text-center py-4">
<div class="text-5xl mb-4">🚫</div>
<h2 class="text-xl font-semibold text-white mb-2">Accès refusé</h2>
<p class="text-gray-400 text-sm mb-6">
Ton compte Discord n'est pas autorisé à accéder à ce panel.<br>
Contacte un administrateur pour obtenir l'accès.
</p>
<a href="/login" class="text-indigo-400 hover:text-indigo-300 text-sm transition-colors">
← Retour à la connexion
</a>
</div>
{{end}}
+125
View File
@@ -0,0 +1,125 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="fr" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{if .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}">{{end}}
<title>{{block "title" .}}Dashboard{{end}} — TicketBot Admin</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<link rel="stylesheet" href="/static/app.css">
<script>
tailwind.config = {
theme: { extend: { colors: { discord: { bg: '#1e1f22', card: '#2b2d31', accent: '#5865f2', hover: '#404249', border: '#3f4147', sidebar: '#1e1f22' } } } }
}
// HTMX CSRF
document.addEventListener('DOMContentLoaded', function() {
document.body.addEventListener('htmx:configRequest', function(e) {
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) e.detail.headers['X-CSRF-Token'] = meta.content;
});
});
</script>
</head>
<body class="h-full bg-discord-bg text-gray-100 flex overflow-hidden">
<!-- Sidebar -->
<aside class="w-60 flex-shrink-0 bg-discord-sidebar flex flex-col h-screen border-r border-discord-border">
<div class="p-4 border-b border-discord-border">
<span class="font-bold text-white text-lg">TicketBot</span>
<span class="text-xs text-gray-400 block">Admin Panel</span>
</div>
<nav class="flex-1 overflow-y-auto p-2 space-y-1">
<a href="/" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded text-gray-300 hover:bg-discord-hover hover:text-white transition-colors {{if eq .ActivePage "dashboard"}}sidebar-link-active{{end}}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
Dashboard
</a>
<a href="/panels" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded text-gray-300 hover:bg-discord-hover hover:text-white transition-colors {{if eq .ActivePage "panels"}}sidebar-link-active{{end}}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
Panels
</a>
<a href="/convocations" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded text-gray-300 hover:bg-discord-hover hover:text-white transition-colors {{if eq .ActivePage "convocations"}}sidebar-link-active{{end}}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3"/></svg>
Convocations
</a>
<a href="/transcripts" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded text-gray-300 hover:bg-discord-hover hover:text-white transition-colors {{if eq .ActivePage "transcripts"}}sidebar-link-active{{end}}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Transcripts
</a>
<a href="/audit" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded text-gray-300 hover:bg-discord-hover hover:text-white transition-colors {{if eq .ActivePage "audit"}}sidebar-link-active{{end}}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
Audit Log
</a>
{{if .Admin}}{{if .Admin.IsSuperadmin}}
<a href="/sessions" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded text-gray-300 hover:bg-discord-hover hover:text-white transition-colors {{if eq .ActivePage "sessions"}}sidebar-link-active{{end}}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
Sessions
</a>
{{end}}{{end}}
</nav>
<!-- Admin + logout -->
<div class="p-3 border-t border-discord-border">
{{if .Admin}}
<div class="flex items-center gap-2 mb-2">
{{if .Admin.DiscordAvatar}}
<img src="https://cdn.discordapp.com/avatars/{{.Admin.DiscordID}}/{{.Admin.DiscordAvatar}}.png?size=32"
class="w-8 h-8 rounded-full" alt="" onerror="this.style.display='none'">
{{end}}
<div class="min-w-0">
<p class="text-sm font-medium text-white truncate">{{.Admin.DiscordUsername}}</p>
{{if .Admin.IsSuperadmin}}<p class="text-xs text-discord-accent">Superadmin</p>{{end}}
</div>
</div>
{{end}}
<form method="POST" action="/auth/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="w-full text-left flex items-center gap-2 px-2 py-1.5 rounded text-gray-400 hover:text-red-400 hover:bg-discord-hover transition-colors text-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
Déconnexion
</button>
</form>
</div>
</aside>
<!-- Main content -->
<div class="flex-1 flex flex-col min-w-0 h-screen overflow-hidden">
<!-- Header -->
<header class="h-14 flex items-center justify-between px-6 border-b border-discord-border bg-discord-card flex-shrink-0">
<h2 class="font-semibold text-white">{{block "header" .}}Dashboard{{end}}</h2>
<div class="flex items-center gap-3">
{{if .BotOnline}}
<span class="flex items-center gap-1.5 text-sm text-green-400">
<span class="w-2 h-2 bg-green-400 rounded-full"></span>Bot en ligne
</span>
{{else}}
<span class="flex items-center gap-1.5 text-sm text-red-400">
<span class="w-2 h-2 bg-red-400 rounded-full"></span>Bot hors ligne
</span>
{{end}}
</div>
</header>
<!-- Page content -->
<main class="flex-1 overflow-y-auto p-6">
{{if .Flash}}
<div id="flash" class="mb-4 px-4 py-3 rounded bg-green-900/50 border border-green-700 text-green-300 text-sm toast-enter">
{{.Flash}}
</div>
{{end}}
{{if .Error}}
<div id="error-msg" class="mb-4 px-4 py-3 rounded bg-red-900/50 border border-red-700 text-red-300 text-sm toast-enter">
{{.Error}}
</div>
{{end}}
{{block "content" .}}{{end}}
</main>
</div>
<!-- Toast container for HTMX OOB swaps -->
<div id="toast-container" class="fixed bottom-4 right-4 space-y-2 z-50"></div>
</body>
</html>
{{end}}
+20
View File
@@ -0,0 +1,20 @@
{{define "content"}}
<div class="text-center">
<h2 class="text-xl font-semibold text-white mb-2">Connexion</h2>
<p class="text-gray-400 text-sm mb-8">Connecte-toi avec ton compte Discord pour accéder au panel.</p>
{{if .Error}}
<div class="mb-4 px-4 py-3 rounded bg-red-900/50 border border-red-700 text-red-300 text-sm text-left">
{{.Error}}
</div>
{{end}}
<a href="{{.OAuthURL}}"
class="flex items-center justify-center gap-3 w-full px-4 py-3 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors">
<svg class="w-5 h-5" viewBox="0 0 127.14 96.36" fill="currentColor">
<path d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/>
</svg>
Se connecter avec Discord
</a>
</div>
{{end}}
+431
View File
@@ -0,0 +1,431 @@
{{define "title"}}{{if .IsNew}}Nouveau panel{{else}}Éditer — {{.Panel.Name}}{{end}}{{end}}
{{define "header"}}{{if .IsNew}}Créer un panel{{else}}Éditer {{.Panel.Name}}{{end}}{{end}}
{{define "content"}}
<form id="panelForm" method="POST" action="{{if .IsNew}}/panels/new{{else}}/panels/{{.Panel.ID}}/edit{{end}}">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{if and .IsNew .Guilds}}
<div class="bg-discord-card rounded-lg border border-discord-border p-4 mb-4 flex items-center gap-4">
<label class="text-sm text-gray-300 flex-shrink-0">Serveur Discord :</label>
<select id="guildSelector" class="px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white text-sm focus:outline-none focus:border-indigo-500"
onchange="location.href='/panels/new?guild_id='+this.value">
{{range .Guilds}}
<option value="{{.ID}}" {{if eq .ID $.Panel.GuildID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<p class="text-xs text-gray-500">Les rôles et channels chargés correspondent au serveur sélectionné.</p>
</div>
{{end}}
<input type="hidden" name="guild_id" value="{{.Panel.GuildID}}">
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Left: form -->
<div class="xl:col-span-2 space-y-6">
<!-- Section 1: Infos panel -->
<div class="bg-discord-card rounded-lg border border-discord-border p-6">
<h2 class="font-semibold text-white mb-4">Informations du panel</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="sm:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Nom interne <span class="text-red-400">*</span></label>
<input type="text" name="name" value="{{.Panel.Name}}" required {{if not .IsNew}}readonly{{end}}
class="w-full px-3 py-2 bg-gray-800 border {{if index .Errors "name"}}border-red-600{{else}}border-gray-600{{end}} rounded-lg text-white focus:outline-none focus:border-indigo-500 {{if not .IsNew}}opacity-60 cursor-not-allowed{{end}}">
{{if index .Errors "name"}}<p class="text-red-400 text-xs mt-1">{{index .Errors "name"}}</p>{{end}}
</div>
<div class="sm:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Titre embed <span class="text-red-400">*</span></label>
<input type="text" name="embed_title" value="{{.Panel.EmbedTitle}}" required
hx-post="/panels/preview" hx-trigger="input changed delay:300ms" hx-include="#panelForm" hx-target="#preview"
class="w-full px-3 py-2 bg-gray-800 border {{if index .Errors "embed_title"}}border-red-600{{else}}border-gray-600{{end}} rounded-lg text-white focus:outline-none focus:border-indigo-500">
{{if index .Errors "embed_title"}}<p class="text-red-400 text-xs mt-1">{{index .Errors "embed_title"}}</p>{{end}}
</div>
<div class="sm:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Description embed</label>
<textarea name="embed_description" rows="3"
hx-post="/panels/preview" hx-trigger="input changed delay:300ms" hx-include="#panelForm" hx-target="#preview"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500 resize-none">{{.Panel.EmbedDescription.String}}</textarea>
</div>
<div>
<label class="block text-sm text-gray-300 mb-1">Couleur embed</label>
<div class="flex items-center gap-3">
<input type="color" name="embed_color" value="{{.Panel.EmbedColor}}" id="embedColor"
hx-post="/panels/preview" hx-trigger="input changed delay:200ms" hx-include="#panelForm" hx-target="#preview"
class="h-9 w-16 rounded cursor-pointer bg-gray-800 border border-gray-600">
<span id="embedColorText" class="text-sm text-gray-400">{{.Panel.EmbedColor}}</span>
</div>
</div>
<div class="sm:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Image URL (grande image en bas de l'embed)</label>
<input type="url" name="embed_image" value="{{.Panel.EmbedImage}}"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500"
placeholder="https://...">
</div>
<div>
<label class="block text-sm text-gray-300 mb-1">Channel Discord</label>
{{if .TextChans}}
<select name="channel_id" class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500">
<option value="">— Sélectionner —</option>
{{range .TextChans}}
<option value="{{.ID}}" {{if eq .ID $.Panel.ChannelID.String}}selected{{end}}>#{{.Name}}</option>
{{end}}
</select>
{{else}}
<input type="text" name="channel_id" value="{{.Panel.ChannelID.String}}"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500"
placeholder="ID du channel cible">
{{end}}
</div>
</div>
</div>
<!-- Section 2: Types de tickets -->
<div class="bg-discord-card rounded-lg border border-discord-border p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-white">Types de tickets</h2>
<button type="button" onclick="addType()"
class="px-3 py-1.5 text-sm bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg transition-colors">
+ Ajouter un type
</button>
</div>
<div id="typesList" class="space-y-4">
{{range $i, $t := .Panel.Types}}
{{template "type_block" dict "Type" $t "Index" $i "CSRFToken" $.CSRFToken "Roles" $.Roles "TextChans" $.TextChans "Categories" $.Categories}}
{{end}}
</div>
{{if eq (len .Panel.Types) 0}}
<p id="noTypesMsg" class="text-gray-500 text-sm text-center py-4">Aucun type. Cliquez sur "+ Ajouter un type".</p>
{{end}}
</div>
</div>
<!-- Right: preview + actions -->
<div class="space-y-6">
<div class="bg-discord-card rounded-lg border border-discord-border p-5">
<h3 class="font-semibold text-white mb-3">Aperçu</h3>
<div id="preview">
<div class="rounded-lg overflow-hidden border-l-4 bg-[#2b2d31] p-4" style="border-left-color:{{.Panel.EmbedColor}}">
<p class="font-semibold text-white mb-1">{{if .Panel.EmbedTitle}}{{.Panel.EmbedTitle}}{{else}}Titre du panel{{end}}</p>
{{if .Panel.EmbedDescription.Valid}}
<p class="text-gray-300 text-sm">{{.Panel.EmbedDescription.String}}</p>
{{end}}
{{if .Panel.Types}}
<div class="flex flex-wrap gap-2 mt-3">
{{range .Panel.Types}}
<span class="{{buttonClass .ButtonColor}} text-white text-sm px-3 py-1.5 rounded">{{.ButtonEmoji}} {{.ButtonLabel}}</span>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
<div class="bg-discord-card rounded-lg border border-discord-border p-5 space-y-3">
<button type="submit" name="action" value="save"
class="w-full px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors">
Sauvegarder
</button>
<button type="submit" name="action" value="save_send"
class="w-full px-4 py-2.5 bg-green-700 hover:bg-green-600 text-white font-medium rounded-lg transition-colors">
Sauvegarder et envoyer sur Discord
</button>
<a href="/panels" class="block text-center px-4 py-2.5 text-gray-400 hover:text-white border border-gray-700 hover:border-gray-500 rounded-lg transition-colors text-sm">
Annuler
</a>
</div>
</div>
</div>
</form>
<!-- Emoji picker -->
<div id="emojiPicker" class="hidden fixed z-50 bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-72">
<div class="flex gap-1 p-2 border-b border-gray-700 overflow-x-auto">
<button type="button" onclick="showEmojiCat('smileys')" class="emoji-cat-btn px-2 py-1 text-sm rounded hover:bg-gray-700" data-cat="smileys">😀</button>
<button type="button" onclick="showEmojiCat('gestures')" class="emoji-cat-btn px-2 py-1 text-sm rounded hover:bg-gray-700" data-cat="gestures">👍</button>
<button type="button" onclick="showEmojiCat('activities')" class="emoji-cat-btn px-2 py-1 text-sm rounded hover:bg-gray-700" data-cat="activities">🎮</button>
<button type="button" onclick="showEmojiCat('objects')" class="emoji-cat-btn px-2 py-1 text-sm rounded hover:bg-gray-700" data-cat="objects">📝</button>
<button type="button" onclick="showEmojiCat('symbols')" class="emoji-cat-btn px-2 py-1 text-sm rounded hover:bg-gray-700" data-cat="symbols"></button>
</div>
<div id="emojiGrid" class="p-2 grid grid-cols-8 gap-0.5 max-h-48 overflow-y-auto"></div>
</div>
<!-- Type block template for JS -->
<template id="typeTpl">
<div class="type-block border border-gray-700 rounded-lg p-4 space-y-3 cursor-move" data-index="__IDX__">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-300 flex items-center gap-2">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"/></svg>
Type <span class="type-num"></span>
</span>
<button type="button" onclick="removeType(this)" class="text-red-400 hover:text-red-300 text-sm">Supprimer</button>
</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-xs text-gray-400 mb-1">Nom interne</label>
<input type="text" name="types[__IDX__][name]" required class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500" placeholder="ex: support"></div>
<div><label class="block text-xs text-gray-400 mb-1">Label bouton</label>
<input type="text" name="types[__IDX__][button_label]"
hx-post="/panels/preview" hx-trigger="input changed delay:300ms" hx-include="#panelForm" hx-target="#preview"
class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div><label class="block text-xs text-gray-400 mb-1">Couleur bouton</label>
<select name="types[__IDX__][button_color]"
hx-post="/panels/preview" hx-trigger="change" hx-include="#panelForm" hx-target="#preview"
class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="primary">🔵 Bleu (primary)</option>
<option value="secondary">⚪ Gris (secondary)</option>
<option value="success">🟢 Vert (success)</option>
<option value="danger">🔴 Rouge (danger)</option>
</select></div>
<div><label class="block text-xs text-gray-400 mb-1">Emoji bouton</label>
<div class="flex gap-1">
<input type="text" name="types[__IDX__][button_emoji]" readonly
hx-post="/panels/preview" hx-trigger="input changed delay:200ms" hx-include="#panelForm" hx-target="#preview"
class="flex-1 px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none cursor-pointer" placeholder="—" onclick="openEmojiPicker(this)">
<button type="button" onclick="openEmojiPicker(this.previousElementSibling)" class="px-2 py-1.5 bg-gray-700 hover:bg-gray-600 border border-gray-600 rounded text-sm">😀</button>
</div></div>
<div><label class="block text-xs text-gray-400 mb-1">Titre embed ticket</label>
<input type="text" name="types[__IDX__][embed_title]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div><label class="block text-xs text-gray-400 mb-1">Couleur embed</label>
<input type="color" name="types[__IDX__][embed_color]" value="#5865f2" class="h-9 w-full rounded cursor-pointer bg-gray-800 border border-gray-600"></div>
<div class="col-span-2"><label class="block text-xs text-gray-400 mb-1">Texte embed bienvenue</label>
<textarea name="types[__IDX__][embed_text]" rows="2" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500 resize-none"></textarea></div>
<div class="col-span-2"><label class="block text-xs text-gray-400 mb-1">Image URL (optionnel)</label>
<input type="url" name="types[__IDX__][image_url]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500" placeholder="https://..."></div>
__ROLES_HTML__
__CATEGORIES_HTML__
__TEXTCHANS_HTML__
<div><label class="block text-xs text-gray-400 mb-1">Règle de fermeture</label>
<select name="types[__IDX__][close_rule]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="staff_only">Staff uniquement</option>
<option value="author_if_unclaimed">Auteur si non claimé</option>
</select></div>
<div class="col-span-2"><label class="block text-xs text-gray-400 mb-1">Reup claim (minutes, 0 = global)</label>
<input type="number" name="types[__IDX__][claim_reup_minutes]" value="0" min="0" max="1440"
class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div class="flex items-center gap-2">
<input type="hidden" name="types[__IDX__][claim_mode]" value="0">
<input type="checkbox" name="types[__IDX__][claim_mode]" value="1" class="rounded" id="cm__IDX__">
<label for="cm__IDX__" class="text-xs text-gray-400">Système de claim activé</label></div>
<div class="flex items-center gap-2">
<input type="hidden" name="types[__IDX__][modal_enabled]" value="1">
<input type="checkbox" name="types[__IDX__][modal_enabled]" value="1" checked class="rounded" id="me__IDX__">
<label for="me__IDX__" class="text-xs text-gray-400">Modal activé</label></div>
</div>
</div>
</template>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<script>
let typeCount = {{len .Panel.Types}};
// Emoji data
const emojiData = {
smileys: ['😀','😃','😄','😁','😆','😅','😂','🤣','😊','😇','🙂','😉','😌','😍','🥰','😘','😗','😙','😚','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','🤐','🤨','😐','😑','😶','😏','😒','🙄','😬','🤥','😌','😔','😪','🤤','😴','😷','🤒','🤕','🤢','🤧','🥵','🥶','🥴','😵','🤯','🤠','🥳','😎','🤓','🧐','😕','😟','🙁','☹️','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠','🤬','😈','👿','💀','☠️','💩','🤡','👹','👺','👻','👽','👾','🤖'],
gestures: ['👋','🤚','🖐','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','🖕','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','👐','🤲','🤝','🙏','✍️','💅','🤳','💪','🦾','🦿','🦵','🦶','👂','🦻','👃','🫀','🫁','🧠','🦷','🦴','👀','👁','👅','👄'],
activities: ['⚽','🏀','🏈','⚾','🥎','🎾','🏐','🏉','🥏','🎱','🪀','🏓','🏸','🏒','🥍','🏏','🪃','🥅','⛳','🪁','🏹','🎣','🤿','🥊','🥋','🎽','🛹','🛷','⛸','🥌','🎿','⛷','🏂','🏋','🤼','🤸','⛹','🤺','🤾','🏌','🏇','🧘','🏄','🏊','🤽','🚣','🧗','🚵','🚴','🎖','🏆','🥇','🥈','🥉','🏅','🎪','🤹','🎭','🎨','🎬','🎤','🎧','🎼','🎵','🎶','🎷','🪗','🎸','🎹','🎺','🎻','🥁','🪘','🎙','🎚','🎛','📻','🎮','🕹','🎲','🎯','🎳','🎰','🎴','🀄','🎭','🎠','🎡','🎢','🎪'],
objects: ['📱','💻','🖥','🖨','⌨️','🖱','🖲','💽','💾','💿','📀','📷','📸','📹','🎥','📽','🎞','📞','☎️','📟','📠','📺','📻','🧭','⏱','⏲','⏰','🕰','⌚','⏳','📡','🔋','🔌','💡','🔦','🕯','🗑','🛢','💰','💴','💵','💶','💷','💸','💳','🪙','💹','📈','📉','📊','📋','📌','📍','✂️','🗃','🗂','🗄','🗑','🔧','🔨','⚒','🛠','⛏','🔩','🪛','🔑','🗝','🔐','🔏','🔒','🔓','🚪','🪑','🛋','🚽','🪠','🚿','🛁','🛒','🚬','⚰️','🪦','⚱️','🧿','💎','🔮','🧸','🪆','🖼','🪅','🎊','🎉','🎁','🎀','🎗','🎟','🎫','🎖','📝','📓','📔','📒','📕','📗','📘','📙','📚','📖','🔖','🏷','💉','🩸','💊','🩹','🩺','🔬','🔭','🩻','🧬','🦠','🧫','🧪','🌡','🧹','🧺','🧻','🪣','🧼','🫧','🪥','🧽','🧯','🛒'],
symbols: ['✅','❌','❎','🔰','⛔','🚫','📵','🔕','🚷','🚯','🚳','🚱','🔞','📵','🔇','🔈','🔉','🔊','📢','📣','🔔','🔕','🔋','🪫','🔌','💡','🔦','🕯','🪔','🧯','💰','💱','💲','♾','🔁','🔂','▶️','⏩','⏭','⏯','◀️','⏪','⏮','🔀','🔃','🎵','🎶','','','✖️','➗','🟰','♾','💲','💱','🏧','🔱','⚜️','🔰','♻️','✅','🆗','✔️','❎','🆘','❓','❔','❕','❗','⁉️','‼️','🔅','🔆','🔱','⚜️','💠','🌀','⭕','✖️','❌','✖','➰','➿','〽️','✳️','✴️','❇️','💯','☑️','🔘','🔲','🔳','▪️','▫️','◾','◽','◼️','◻️','🟥','🟧','🟨','🟩','🟦','🟪','⬛','⬜','🟫','🔶','🔷','🔸','🔹','🔺','🔻','💠','🔘','🔳','🔲']
};
let currentEmojiInput = null;
let currentCat = 'smileys';
function openEmojiPicker(input) {
currentEmojiInput = input;
const picker = document.getElementById('emojiPicker');
const rect = input.getBoundingClientRect();
picker.style.top = (window.scrollY + rect.bottom + 4) + 'px';
picker.style.left = rect.left + 'px';
picker.classList.remove('hidden');
showEmojiCat(currentCat);
}
function showEmojiCat(cat) {
currentCat = cat;
const grid = document.getElementById('emojiGrid');
grid.innerHTML = emojiData[cat].map(e =>
`<button type="button" onclick="pickEmoji('${e}')" class="text-xl p-1 hover:bg-gray-700 rounded transition-colors" title="${e}">${e}</button>`
).join('');
}
function pickEmoji(e) {
if (currentEmojiInput) {
currentEmojiInput.value = e;
currentEmojiInput.dispatchEvent(new Event('input', {bubbles: true}));
}
document.getElementById('emojiPicker').classList.add('hidden');
}
document.addEventListener('click', function(ev) {
const picker = document.getElementById('emojiPicker');
if (!picker.classList.contains('hidden') && !picker.contains(ev.target) &&
!ev.target.closest('[onclick*="openEmojiPicker"]') && !ev.target.closest('[onclick*="EmojiPicker"]')) {
picker.classList.add('hidden');
}
});
// Sync color picker
document.getElementById('embedColor').addEventListener('input', function() {
document.getElementById('embedColorText').textContent = this.value;
});
// Roles HTML for JS-added type blocks
const rolesHtml = `{{range .Roles}}<option value="{{.ID}}">{{.Name}}</option>{{end}}`;
const categoriesHtml = `{{range .Categories}}<option value="{{.ID}}">📁 {{.Name}}</option>{{end}}`;
const textChansHtml = `{{range .TextChans}}<option value="{{.ID}}"># {{.Name}}</option>{{end}}`;
function makeSelect(name, label, options, value = '') {
if (!options) return `<div><label class="block text-xs text-gray-400 mb-1">${label}</label><input type="text" name="${name}" value="${value}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>`;
return `<div><label class="block text-xs text-gray-400 mb-1">${label}</label><select name="${name}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"><option value="">— Sélectionner —</option>${options}</select></div>`;
}
// SortableJS
new Sortable(document.getElementById('typesList'), {
animation: 150, handle: '.type-block',
onEnd: function() {
renumberTypes();
{{if not .IsNew}}
const ids = [...document.querySelectorAll('.type-block')].map(el => el.dataset.typeId).filter(Boolean);
if (ids.length) {
fetch('/panels/{{.Panel.ID}}/types/reorder', {
method: 'POST',
headers: {'X-CSRF-Token': '{{.CSRFToken}}', 'Content-Type': 'application/x-www-form-urlencoded'},
body: ids.map(id => 'ids[]=' + id).join('&')
});
}
{{end}}
}
});
function addType() {
document.getElementById('noTypesMsg')?.remove();
let html = document.getElementById('typeTpl').innerHTML.replaceAll('__IDX__', typeCount);
html = html.replace('__ROLES_HTML__', makeSelect(`types[${typeCount}][staff_role_id]`, 'Staff Role', rolesHtml));
html = html.replace('__CATEGORIES_HTML__', makeSelect(`types[${typeCount}][category_id]`, 'Catégorie', categoriesHtml));
html = html.replace('__TEXTCHANS_HTML__',
makeSelect(`types[${typeCount}][claim_channel_id]`, 'Claim Channel', textChansHtml) +
makeSelect(`types[${typeCount}][log_channel_id]`, 'Log Channel', textChansHtml));
const div = document.createElement('div');
div.innerHTML = html;
document.getElementById('typesList').appendChild(div.firstElementChild);
typeCount++;
renumberTypes();
htmx.process(document.getElementById('typesList'));
}
function removeType(btn) {
btn.closest('.type-block').remove();
renumberTypes();
}
function renumberTypes() {
document.querySelectorAll('.type-num').forEach((el, i) => el.textContent = i + 1);
}
</script>
{{end}}
{{define "type_block"}}
<div class="type-block border border-gray-700 rounded-lg p-4 space-y-3 cursor-move" data-index="{{.Index}}" data-type-id="{{.Type.ID}}">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-300 flex items-center gap-2">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"/></svg>
Type <span class="type-num">{{add .Index 1}}</span>
</span>
<button type="button" onclick="removeType(this)" class="text-red-400 hover:text-red-300 text-sm">Supprimer</button>
</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-xs text-gray-400 mb-1">Nom interne</label>
<input type="text" name="types[{{.Index}}][name]" value="{{.Type.Name}}" required class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div><label class="block text-xs text-gray-400 mb-1">Label bouton</label>
<input type="text" name="types[{{.Index}}][button_label]" value="{{.Type.ButtonLabel}}"
hx-post="/panels/preview" hx-trigger="input changed delay:300ms" hx-include="#panelForm" hx-target="#preview"
class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div><label class="block text-xs text-gray-400 mb-1">Couleur bouton</label>
<select name="types[{{.Index}}][button_color]"
hx-post="/panels/preview" hx-trigger="change" hx-include="#panelForm" hx-target="#preview"
class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="primary" {{if eq .Type.ButtonColor "primary"}}selected{{end}}>🔵 Bleu (primary)</option>
<option value="secondary" {{if eq .Type.ButtonColor "secondary"}}selected{{end}}>⚪ Gris (secondary)</option>
<option value="success" {{if eq .Type.ButtonColor "success"}}selected{{end}}>🟢 Vert (success)</option>
<option value="danger" {{if eq .Type.ButtonColor "danger"}}selected{{end}}>🔴 Rouge (danger)</option>
</select></div>
<div><label class="block text-xs text-gray-400 mb-1">Emoji bouton</label>
<div class="flex gap-1">
<input type="text" name="types[{{.Index}}][button_emoji]" value="{{.Type.ButtonEmoji}}" readonly
hx-post="/panels/preview" hx-trigger="input changed delay:200ms" hx-include="#panelForm" hx-target="#preview"
class="flex-1 px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm cursor-pointer" placeholder="—" onclick="openEmojiPicker(this)">
<button type="button" onclick="openEmojiPicker(this.previousElementSibling)" class="px-2 py-1.5 bg-gray-700 hover:bg-gray-600 border border-gray-600 rounded text-sm">😀</button>
</div></div>
<div><label class="block text-xs text-gray-400 mb-1">Titre embed ticket</label>
<input type="text" name="types[{{.Index}}][embed_title]" value="{{.Type.EmbedTitle}}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div><label class="block text-xs text-gray-400 mb-1">Couleur embed</label>
<input type="color" name="types[{{.Index}}][embed_color]" value="{{.Type.EmbedColor}}" class="h-9 w-full rounded cursor-pointer bg-gray-800 border border-gray-600"></div>
<div class="col-span-2"><label class="block text-xs text-gray-400 mb-1">Texte embed bienvenue</label>
<textarea name="types[{{.Index}}][embed_text]" rows="2" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500 resize-none">{{.Type.EmbedText}}</textarea></div>
<div class="col-span-2"><label class="block text-xs text-gray-400 mb-1">Image URL (optionnel)</label>
<input type="url" name="types[{{.Index}}][image_url]" value="{{.Type.ImageURL}}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500" placeholder="https://..."></div>
<div>
<label class="block text-xs text-gray-400 mb-1">Staff Role</label>
{{if .Roles}}
<select name="types[{{.Index}}][staff_role_id]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="">— Sélectionner —</option>
{{range .Roles}}<option value="{{.ID}}" {{if eq .ID $.Type.StaffRoleID}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
{{else}}
<input type="text" name="types[{{.Index}}][staff_role_id]" value="{{.Type.StaffRoleID}}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
{{end}}
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Catégorie</label>
{{if .Categories}}
<select name="types[{{.Index}}][category_id]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="">— Sélectionner —</option>
{{range .Categories}}<option value="{{.ID}}" {{if eq .ID $.Type.CategoryID}}selected{{end}}>📁 {{.Name}}</option>{{end}}
</select>
{{else}}
<input type="text" name="types[{{.Index}}][category_id]" value="{{.Type.CategoryID}}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
{{end}}
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Claim Channel</label>
{{if .TextChans}}
<select name="types[{{.Index}}][claim_channel_id]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="">— Sélectionner —</option>
{{range .TextChans}}<option value="{{.ID}}" {{if eq .ID $.Type.ClaimChannelID}}selected{{end}}># {{.Name}}</option>{{end}}
</select>
{{else}}
<input type="text" name="types[{{.Index}}][claim_channel_id]" value="{{.Type.ClaimChannelID}}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
{{end}}
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Log Channel</label>
{{if .TextChans}}
<select name="types[{{.Index}}][log_channel_id]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="">— Sélectionner —</option>
{{range .TextChans}}<option value="{{.ID}}" {{if eq .ID $.Type.LogChannelID}}selected{{end}}># {{.Name}}</option>{{end}}
</select>
{{else}}
<input type="text" name="types[{{.Index}}][log_channel_id]" value="{{.Type.LogChannelID}}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
{{end}}
</div>
<div><label class="block text-xs text-gray-400 mb-1">Max tickets / user</label>
<input type="number" name="types[{{.Index}}][max_per_user]" value="{{.Type.MaxPerUser}}" min="1" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div><label class="block text-xs text-gray-400 mb-1">Règle de close</label>
<select name="types[{{.Index}}][close_rule]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="staff_only" {{if eq .Type.CloseRule "staff_only"}}selected{{end}}>Staff uniquement</option>
<option value="author_if_unclaimed" {{if eq .Type.CloseRule "author_if_unclaimed"}}selected{{end}}>Auteur si non claimé</option>
</select></div>
<div class="flex items-center gap-2 pt-1">
<input type="hidden" name="types[{{.Index}}][claim_mode]" value="0">
<input type="checkbox" name="types[{{.Index}}][claim_mode]" value="1" {{if .Type.ClaimMode}}checked{{end}} class="rounded" id="cm{{.Index}}">
<label for="cm{{.Index}}" class="text-xs text-gray-400">Système de claim activé</label></div>
<div class="col-span-2"><label class="block text-xs text-gray-400 mb-1">Reup claim (minutes, 0 = global)</label>
<input type="number" name="types[{{.Index}}][claim_reup_minutes]" value="{{.Type.ClaimReupMinutes}}" min="0" max="1440"
class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div class="flex items-center gap-2 pt-1">
<input type="hidden" name="types[{{.Index}}][modal_enabled]" value="0">
<input type="checkbox" name="types[{{.Index}}][modal_enabled]" value="1" {{if .Type.ModalEnabled}}checked{{end}} class="rounded" id="me{{.Index}}">
<label for="me{{.Index}}" class="text-xs text-gray-400">Modal activé</label></div>
</div>
</div>
{{end}}
+91
View File
@@ -0,0 +1,91 @@
{{define "title"}}Panels{{end}}
{{define "header"}}Panels{{end}}
{{define "content"}}
<div class="flex items-center justify-between mb-6">
<p class="text-gray-400 text-sm">{{len .Panels}} panel{{if gt (len .Panels) 1}}s{{end}} configuré{{if gt (len .Panels) 1}}s{{end}}</p>
<a href="/panels/new" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors">
+ Créer un panel
</a>
</div>
{{if .Panels}}
<div class="grid gap-4">
{{range .Panels}}
<div class="bg-discord-card rounded-lg border border-discord-border p-5 flex items-center gap-4">
<!-- Color swatch -->
<div class="w-1 self-stretch rounded-full flex-shrink-0" style="background-color:{{.EmbedColor}}"></div>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-white">{{.Name}}</span>
<span class="text-xs text-gray-500 bg-gray-800 px-2 py-0.5 rounded">{{len .Types}} type{{if gt (len .Types) 1}}s{{end}}</span>
{{if .MessageID.Valid}}<span class="text-xs text-green-400 bg-green-900/30 px-2 py-0.5 rounded">Envoyé</span>{{end}}
{{if .GuildID}}
{{$gname := index $.GuildNames .GuildID}}
<span class="text-xs text-indigo-300 bg-indigo-900/30 px-2 py-0.5 rounded">{{if $gname}}{{$gname}}{{else}}{{.GuildID}}{{end}}</span>
{{end}}
</div>
<p class="text-sm text-gray-400 truncate">{{.EmbedTitle}}</p>
{{if .ChannelID.Valid}}<p class="text-xs text-gray-600 mt-0.5">Channel : {{.ChannelID.String}}</p>{{end}}
</div>
<!-- Actions -->
<div class="flex items-center gap-2 flex-shrink-0">
<a href="/panels/{{.ID}}/edit" class="px-3 py-1.5 text-sm text-indigo-400 hover:text-indigo-300 border border-indigo-700/50 hover:border-indigo-500 rounded-lg transition-colors">
Éditer
</a>
<form method="POST" action="/panels/{{.ID}}/duplicate">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200 border border-gray-700 hover:border-gray-500 rounded-lg transition-colors">
Dupliquer
</button>
</form>
<button onclick="confirmDelete({{.ID}}, '{{.Name}}')"
class="px-3 py-1.5 text-sm text-red-400 hover:text-red-300 border border-red-900/50 hover:border-red-700 rounded-lg transition-colors">
Supprimer
</button>
</div>
</div>
{{end}}
</div>
{{else}}
<div class="text-center py-20">
<p class="text-gray-500 mb-4">Aucun panel configuré.</p>
<a href="/panels/new" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors">
Créer votre premier panel
</a>
</div>
{{end}}
<!-- Delete confirmation modal -->
<div id="deleteModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-discord-card border border-discord-border rounded-xl p-6 w-full max-w-sm mx-4">
<h3 class="text-white font-semibold mb-2">Supprimer le panel</h3>
<p class="text-gray-400 text-sm mb-6">
Supprimer <strong id="deleteModalName" class="text-white"></strong> ? Cette action est irréversible.
</p>
<div class="flex gap-3 justify-end">
<button onclick="document.getElementById('deleteModal').classList.add('hidden')"
class="px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700 rounded-lg transition-colors">
Annuler
</button>
<form id="deleteForm" method="POST">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="px-4 py-2 text-sm bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors">
Supprimer
</button>
</form>
</div>
</div>
</div>
<script>
function confirmDelete(id, name) {
document.getElementById('deleteModalName').textContent = name;
document.getElementById('deleteForm').action = '/panels/' + id + '/delete';
document.getElementById('deleteModal').classList.remove('hidden');
}
document.getElementById('deleteModal').addEventListener('click', function(e) {
if (e.target === this) this.classList.add('hidden');
});
</script>
{{end}}
@@ -0,0 +1,30 @@
{{define "content"}}
<h2 class="text-xl font-semibold text-white mb-1">Créer un mot de passe</h2>
<p class="text-gray-400 text-sm mb-6">
Choisissez un mot de passe sécurisé (12 caractères minimum). Il vous sera demandé à chaque connexion.
</p>
{{if .Error}}
<div class="mb-4 px-4 py-3 rounded bg-red-900/50 border border-red-700 text-red-300 text-sm">
{{.Error}}
</div>
{{end}}
<form method="POST" action="/auth/password-setup">
<div class="mb-4">
<label class="block text-sm text-gray-300 mb-1">Mot de passe</label>
<input type="password" name="password" minlength="12" required autofocus
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500"
placeholder="12 caractères minimum">
</div>
<div class="mb-6">
<label class="block text-sm text-gray-300 mb-1">Confirmer le mot de passe</label>
<input type="password" name="confirm" required
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500">
</div>
<button type="submit"
class="w-full px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors">
Définir le mot de passe
</button>
</form>
{{end}}
+90
View File
@@ -0,0 +1,90 @@
{{define "title"}}Sessions{{end}}
{{define "header"}}Sessions actives{{end}}
{{define "content"}}
<div class="flex items-center justify-between mb-6">
<p class="text-gray-400 text-sm">{{len .Sessions}} session{{if gt (len .Sessions) 1}}s{{end}} active{{if gt (len .Sessions) 1}}s{{end}}</p>
<button onclick="document.getElementById('revokeAllModal').classList.remove('hidden')"
class="px-4 py-2 bg-red-700 hover:bg-red-600 text-white text-sm font-medium rounded-lg transition-colors">
Révoquer toutes les autres
</button>
</div>
<div class="bg-discord-card rounded-lg border border-discord-border overflow-hidden">
{{if .Sessions}}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase border-b border-discord-border">
<th class="px-4 py-2 text-left">Admin</th>
<th class="px-4 py-2 text-left">IP</th>
<th class="px-4 py-2 text-left">User Agent</th>
<th class="px-4 py-2 text-left">Dernière activité</th>
<th class="px-4 py-2 text-left">Créée le</th>
<th class="px-4 py-2 text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-discord-border">
{{range .Sessions}}
<tr class="hover:bg-white/5 transition-colors {{if .IsCurrent}}bg-indigo-900/20{{end}}">
<td class="px-4 py-2 text-white">
{{.DiscordUsername}}
{{if .IsCurrent}}<span class="ml-1 text-xs text-indigo-400">(vous)</span>{{end}}
</td>
<td class="px-4 py-2 text-gray-400 font-mono text-xs">{{.IPAddress}}</td>
<td class="px-4 py-2 text-gray-500 text-xs max-w-[200px] truncate" title="{{.UserAgent}}">{{.UserAgent}}</td>
<td class="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">{{.LastActivity.Format "02/01/2006 15:04:05"}}</td>
<td class="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">{{.CreatedAt.Format "02/01/2006 15:04:05"}}</td>
<td class="px-4 py-2 text-right">
{{if not .IsCurrent}}
<form method="POST" action="/sessions/{{.ID}}/revoke">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit"
class="px-3 py-1 text-xs text-red-400 hover:text-red-300 border border-red-900/50 hover:border-red-700 rounded transition-colors">
Révoquer
</button>
</form>
{{else}}
<span class="text-xs text-gray-600">Session courante</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="px-5 py-12 text-center text-gray-500 text-sm">
Aucune session active.
</div>
{{end}}
</div>
<!-- Revoke All Modal -->
<div id="revokeAllModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-discord-card border border-discord-border rounded-xl p-6 w-full max-w-sm mx-4">
<h3 class="text-white font-semibold mb-2">Révoquer toutes les autres sessions</h3>
<p class="text-gray-400 text-sm mb-6">
Cette action déconnectera toutes les sessions actives sauf la vôtre. Cette action est irréversible.
</p>
<div class="flex gap-3 justify-end">
<button onclick="document.getElementById('revokeAllModal').classList.add('hidden')"
class="px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700 rounded-lg transition-colors">
Annuler
</button>
<form method="POST" action="/sessions/revoke-all">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="px-4 py-2 text-sm bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors">
Révoquer tout
</button>
</form>
</div>
</div>
</div>
<script>
document.getElementById('revokeAllModal').addEventListener('click', function(e) {
if (e.target === this) this.classList.add('hidden');
});
</script>
{{end}}
+35
View File
@@ -0,0 +1,35 @@
{{define "content"}}
<h2 class="text-xl font-semibold text-white mb-1">Configuration TOTP</h2>
<p class="text-gray-400 text-sm mb-6">
Scanne le QR code avec Google Authenticator ou une app compatible, puis saisis le code pour valider.
</p>
{{if .Error}}
<div class="mb-4 px-4 py-3 rounded bg-red-900/50 border border-red-700 text-red-300 text-sm">
{{.Error}}
</div>
{{end}}
<div class="flex justify-center mb-4">
<img src="{{.QRCodeBase64}}" alt="QR Code TOTP" class="w-48 h-48 rounded-lg border border-gray-600">
</div>
<div class="mb-6">
<p class="text-xs text-gray-400 mb-1">Secret manuel :</p>
<code class="block bg-gray-800 px-3 py-2 rounded text-sm text-gray-200 font-mono break-all select-all">{{.Secret}}</code>
</div>
<form method="POST" action="/auth/totp-setup">
<input type="hidden" name="secret" value="{{.Secret}}">
<div class="mb-4">
<label class="block text-sm text-gray-300 mb-1">Code de vérification</label>
<input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white text-center text-xl tracking-widest focus:outline-none focus:border-indigo-500"
placeholder="000000" required autofocus>
</div>
<button type="submit"
class="w-full px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors">
Valider et continuer
</button>
</form>
{{end}}
+41
View File
@@ -0,0 +1,41 @@
{{define "content"}}
<h2 class="text-xl font-semibold text-white mb-1">Vérification</h2>
{{if .Admin}}
<div class="flex items-center gap-3 mb-6 p-3 bg-gray-800 rounded-lg">
{{if .Admin.DiscordAvatar}}
<img src="https://cdn.discordapp.com/avatars/{{.Admin.DiscordID}}/{{.Admin.DiscordAvatar}}.png?size=64"
class="w-10 h-10 rounded-full" alt="">
{{end}}
<div>
<p class="text-sm text-gray-400">Connecté en tant que</p>
<p class="font-medium text-white">{{.Admin.DiscordUsername}}</p>
</div>
</div>
{{end}}
{{if .Error}}
<div class="mb-4 px-4 py-3 rounded bg-red-900/50 border border-red-700 text-red-300 text-sm">
{{.Error}}
</div>
{{end}}
<form method="POST" action="/auth/verify">
<div class="mb-4">
<label class="block text-sm text-gray-300 mb-1">Mot de passe</label>
<input type="password" name="password" autocomplete="current-password"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500"
required autofocus>
</div>
<div class="mb-6">
<label class="block text-sm text-gray-300 mb-1">Code TOTP</label>
<input type="text" name="totp" inputmode="numeric" autocomplete="one-time-code" maxlength="6"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white text-center text-xl tracking-widest focus:outline-none focus:border-indigo-500"
placeholder="000000" required>
</div>
<button type="submit"
class="w-full px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors">
Se connecter
</button>
</form>
{{end}}
+170
View File
@@ -0,0 +1,170 @@
{{define "title"}}Transcripts{{end}}
{{define "header"}}Transcripts{{end}}
{{define "content"}}
<!-- Filters -->
<form method="GET" action="/transcripts" class="bg-discord-card rounded-lg border border-discord-border p-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 mb-3">
<div>
<label class="block text-xs text-gray-400 mb-1">Recherche</label>
<input type="text" name="search" value="{{.FSearch}}" placeholder="Titre, user ID…"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Statut</label>
<select name="status" class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
<option value="">Tous</option>
<option value="open" {{if eq .FStatus "open"}}selected{{end}}>Ouvert</option>
<option value="claimed" {{if eq .FStatus "claimed"}}selected{{end}}>Claimé</option>
<option value="closed" {{if eq .FStatus "closed"}}selected{{end}}>Fermé</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Type</label>
<select name="type" class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
<option value="">Tous les types</option>
{{range .Types}}
<option value="{{.}}" {{if eq . $.FType}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Staff</label>
<select name="staff" class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
<option value="">Tous les staffs</option>
{{range .StaffList}}
<option value="{{.}}" {{if eq . $.FStaff}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
{{if .Guilds}}
<div>
<label class="block text-xs text-gray-400 mb-1">Serveur</label>
<select name="guild" class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
<option value="">Tous les serveurs</option>
{{range .Guilds}}
<option value="{{.ID}}" {{if eq .ID $.FGuild}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
{{end}}
<div>
<label class="block text-xs text-gray-400 mb-1">Du</label>
<input type="date" name="from" value="{{.FFrom}}"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Au</label>
<input type="date" name="to" value="{{.FTo}}"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
</div>
</div>
<div class="flex items-center gap-2">
<button type="submit"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors">
Filtrer
</button>
<a href="/transcripts" class="px-4 py-2 text-sm text-gray-400 hover:text-white border border-discord-border rounded-lg transition-colors">
Réinitialiser
</a>
<span class="text-xs text-gray-500 ml-auto">{{.Total}} résultat{{if gt .Total 1}}s{{end}}</span>
</div>
</form>
<!-- Tickets table -->
<div class="bg-discord-card rounded-lg border border-discord-border overflow-hidden">
{{if .Tickets}}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase border-b border-discord-border">
<th class="px-4 py-2 text-left">#</th>
<th class="px-4 py-2 text-left">Serveur</th>
<th class="px-4 py-2 text-left">Type</th>
<th class="px-4 py-2 text-left">Utilisateur</th>
<th class="px-4 py-2 text-left">Staff</th>
<th class="px-4 py-2 text-left">Titre</th>
<th class="px-4 py-2 text-left">Statut</th>
<th class="px-4 py-2 text-left">Ouvert le</th>
<th class="px-4 py-2 text-left">Fermé le</th>
<th class="px-4 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-discord-border">
{{range .Tickets}}
<tr class="hover:bg-white/5 transition-colors">
<td class="px-4 py-2 text-indigo-400 font-mono">{{.TicketNumber}}</td>
<td class="px-4 py-2 text-xs text-indigo-300">
{{if .GuildID}}{{$gn := index $.GuildNames .GuildID}}{{if $gn}}{{$gn}}{{else}}{{.GuildID}}{{end}}{{else}}<span class="text-gray-600"></span>{{end}}
</td>
<td class="px-4 py-2 text-gray-300">{{.Type}}</td>
<td class="px-4 py-2 text-gray-400 font-mono text-xs">{{.UserID}}</td>
<td class="px-4 py-2 text-gray-400 font-mono text-xs">
{{if .ClaimedBy.Valid}}{{.ClaimedBy.String}}{{else}}—{{end}}
</td>
<td class="px-4 py-2 text-white max-w-[200px] truncate">
{{if .TicketTitle.Valid}}{{.TicketTitle.String}}{{else}}<span class="text-gray-500"></span>{{end}}
</td>
<td class="px-4 py-2">
{{if eq .Status "open"}}
<span class="px-2 py-0.5 rounded text-xs bg-green-900/40 text-green-400 border border-green-800">Ouvert</span>
{{else if eq .Status "claimed"}}
<span class="px-2 py-0.5 rounded text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-800">Claimé</span>
{{else}}
<span class="px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400">Fermé</span>
{{end}}
</td>
<td class="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">{{.OpenedAt.Format "02/01/06 15:04"}}</td>
<td class="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">
{{if .ClosedAt.Valid}}{{.ClosedAt.Time.Format "02/01/06 15:04"}}{{else}}—{{end}}
</td>
<td class="px-4 py-2 text-right">
<div class="flex items-center gap-1 justify-end">
{{if .TranscriptPath.Valid}}
<a href="/transcripts/view/{{.ID}}" target="_blank"
class="px-2 py-1 text-xs text-indigo-400 hover:text-indigo-300 border border-indigo-700/50 hover:border-indigo-500 rounded transition-colors">
Voir
</a>
<a href="/transcripts/download/{{.ID}}"
class="px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-700 hover:border-gray-500 rounded transition-colors">
</a>
{{else}}
<span class="text-xs text-gray-600"></span>
{{end}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if gt .Pages 1}}
<div class="px-5 py-3 border-t border-discord-border flex items-center gap-2 text-sm">
{{if gt .Page 1}}
<a href="?page={{add .Page -1}}&status={{.FStatus}}&type={{.FType}}&staff={{.FStaff}}&guild={{.FGuild}}&from={{.FFrom}}&to={{.FTo}}&search={{.FSearch}}"
class="px-3 py-1 rounded border border-discord-border text-gray-300 hover:text-white hover:border-gray-500 transition-colors">← Précédent</a>
{{end}}
<span class="text-gray-400">Page {{.Page}} / {{.Pages}}</span>
{{if lt .Page .Pages}}
<a href="?page={{add .Page 1}}&status={{.FStatus}}&type={{.FType}}&staff={{.FStaff}}&guild={{.FGuild}}&from={{.FFrom}}&to={{.FTo}}&search={{.FSearch}}"
class="px-3 py-1 rounded border border-discord-border text-gray-300 hover:text-white hover:border-gray-500 transition-colors">Suivant →</a>
{{end}}
</div>
{{end}}
{{else}}
<div class="px-5 py-12 text-center text-gray-500 text-sm">
Aucun ticket trouvé pour ces critères.
</div>
{{end}}
</div>
{{end}}