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
+433
View File
@@ -0,0 +1,433 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// BotSender is the subset of BotService used by PanelsHandler.
type BotSender interface {
SendPanel(panelID int64) error
DeletePanelMessage(panelID int64) error
}
// GuildRole is a Discord role passed to the panel form.
type GuildRole struct {
ID string
Name string
Color string
}
// GuildChannel is a Discord channel/category passed to the panel form.
type GuildChannel struct {
ID string
Name string
Type int // 0=text, 2=voice, 4=category
}
// GuildInfo is a minimal guild descriptor for the guild selector.
type GuildInfo struct {
ID string
Name string
}
// DiscordInfo can fetch guild roles and channels for the form dropdowns.
type DiscordInfo interface {
GetGuildList() []GuildInfo
GetGuildRoles(guildID string) ([]GuildRole, error)
GetGuildChannels(guildID string) ([]GuildChannel, error)
}
// PanelsHandler serves all /panels/* routes.
type PanelsHandler struct {
PanelRepo *db.PanelConfigRepo
AuditLog *db.AuditLogRepo
BotService BotSender
DiscordInfo DiscordInfo
Renderer *Renderer
}
// HandleList serves GET /panels.
func (h *PanelsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
base := h.Renderer.base(r, "panels")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
panels, _ := h.PanelRepo.List(ctx)
guildNames := map[string]string{}
if h.DiscordInfo != nil {
for _, g := range h.DiscordInfo.GetGuildList() {
guildNames[g.ID] = g.Name
}
}
h.Renderer.Page(w, "panels_list", struct {
baseData
Panels []*db.PanelConfig
GuildNames map[string]string
}{baseData: base, Panels: panels, GuildNames: guildNames})
}
// HandleNew serves GET /panels/new.
func (h *PanelsHandler) HandleNew(w http.ResponseWriter, r *http.Request) {
base := h.Renderer.base(r, "panels")
guildID := r.URL.Query().Get("guild_id")
if guildID == "" && h.DiscordInfo != nil {
if guilds := h.DiscordInfo.GetGuildList(); len(guilds) > 0 {
guildID = guilds[0].ID
}
}
h.renderForm(w, base, &db.PanelConfig{EmbedColor: "#5865f2", GuildID: guildID}, true, nil)
}
// HandleCreate serves POST /panels/new.
func (h *PanelsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
base := h.Renderer.base(r, "panels")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
p, types, errs := parsePanel(r)
if len(errs) > 0 {
p.Types = types
h.renderForm(w, base, p, true, errs)
return
}
if err := h.PanelRepo.Create(ctx, p); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
h.renderForm(w, base, p, true, map[string]string{"name": "Ce nom est déjà utilisé."})
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if len(types) > 0 {
if err := h.PanelRepo.ReplaceTypes(ctx, p.ID, types); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
if r.FormValue("action") == "save_send" && h.BotService != nil {
_ = h.BotService.SendPanel(p.ID)
}
http.Redirect(w, r, "/panels", http.StatusSeeOther)
}
// HandleEdit serves GET /panels/{id}/edit.
func (h *PanelsHandler) HandleEdit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
p, err := h.PanelRepo.GetByID(ctx, id)
if err != nil || p == nil {
http.NotFound(w, r)
return
}
base := h.Renderer.base(r, "panels")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
h.renderForm(w, base, p, false, nil)
}
// HandleUpdate serves POST /panels/{id}/edit.
func (h *PanelsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
base := h.Renderer.base(r, "panels")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
p, types, errs := parsePanel(r)
p.ID = id
if len(errs) > 0 {
p.Types = types
h.renderForm(w, base, p, false, errs)
return
}
if err := h.PanelRepo.Update(ctx, p); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := h.PanelRepo.ReplaceTypes(ctx, id, types); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if r.FormValue("action") == "save_send" && h.BotService != nil {
_ = h.BotService.SendPanel(id)
}
http.Redirect(w, r, "/panels", http.StatusSeeOther)
}
// HandleDelete serves POST /panels/{id}/delete.
func (h *PanelsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err := h.PanelRepo.Delete(ctx, id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/panels", http.StatusSeeOther)
}
// HandleDuplicate serves POST /panels/{id}/duplicate.
func (h *PanelsHandler) HandleDuplicate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
src, err := h.PanelRepo.GetByID(ctx, id)
if err != nil || src == nil {
http.NotFound(w, r)
return
}
copyP := &db.PanelConfig{
Name: src.Name + "-copie",
EmbedTitle: src.EmbedTitle,
EmbedDescription: src.EmbedDescription,
EmbedColor: src.EmbedColor,
GuildID: src.GuildID,
}
if err := h.PanelRepo.Create(ctx, copyP); err != nil {
http.Error(w, "duplicate failed: "+err.Error(), http.StatusInternalServerError)
return
}
// Copy types (without IDs so they get fresh ones)
var newTypes []*db.PanelType
for _, t := range src.Types {
newTypes = append(newTypes, &db.PanelType{
Name: t.Name, ButtonLabel: t.ButtonLabel, ButtonColor: t.ButtonColor,
ButtonEmoji: t.ButtonEmoji, EmbedColor: t.EmbedColor, EmbedTitle: t.EmbedTitle,
EmbedText: t.EmbedText, StaffRoleID: t.StaffRoleID, CategoryID: t.CategoryID,
ClaimChannelID: t.ClaimChannelID, LogChannelID: t.LogChannelID,
MaxPerUser: t.MaxPerUser, ClaimMode: t.ClaimMode, CloseRule: t.CloseRule,
ModalEnabled: t.ModalEnabled, SortOrder: t.SortOrder,
})
}
if len(newTypes) > 0 {
_ = h.PanelRepo.ReplaceTypes(ctx, copyP.ID, newTypes)
}
http.Redirect(w, r, fmt.Sprintf("/panels/%d/edit", copyP.ID), http.StatusSeeOther)
}
// HandlePreview serves POST /panels/preview — returns an HTML fragment.
func (h *PanelsHandler) HandlePreview(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
color := r.FormValue("embed_color")
if color == "" {
color = "#5865f2"
}
title := r.FormValue("embed_title")
if title == "" {
title = "Titre du panel"
}
desc := r.FormValue("embed_description")
type btn struct{ Label, Color, Emoji string }
var buttons []btn
for i := 0; i < 25; i++ {
label := r.FormValue(fmt.Sprintf("types[%d][button_label]", i))
if label == "" {
break
}
buttons = append(buttons, btn{
Label: label,
Color: r.FormValue(fmt.Sprintf("types[%d][button_color]", i)),
Emoji: r.FormValue(fmt.Sprintf("types[%d][button_emoji]", i)),
})
}
btnColorMap := map[string]string{
"primary": "bg-indigo-600", "secondary": "bg-gray-600",
"success": "bg-green-600", "danger": "bg-red-600",
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="rounded-lg overflow-hidden border-l-4 bg-[#2b2d31] p-4" style="border-left-color:%s">`, color)
fmt.Fprintf(w, `<p class="font-semibold text-white mb-1">%s</p>`, title)
if desc != "" {
fmt.Fprintf(w, `<p class="text-gray-300 text-sm mb-3">%s</p>`, desc)
}
if len(buttons) > 0 {
fmt.Fprint(w, `<div class="flex flex-wrap gap-2 mt-2">`)
for _, b := range buttons {
cls := btnColorMap[b.Color]
if cls == "" {
cls = "bg-indigo-600"
}
fmt.Fprintf(w, `<span class="%s text-white text-sm px-3 py-1.5 rounded">%s %s</span>`, cls, b.Emoji, b.Label)
}
fmt.Fprint(w, `</div>`)
}
fmt.Fprint(w, `</div>`)
}
// HandleReorderTypes serves POST /panels/{id}/types/reorder.
func (h *PanelsHandler) HandleReorderTypes(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var ids []int64
for _, s := range r.Form["ids[]"] {
n, _ := strconv.ParseInt(s, 10, 64)
ids = append(ids, n)
}
if err := h.PanelRepo.ReorderTypes(ctx, id, ids); err != nil {
http.Error(w, "reorder failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PanelsHandler) renderForm(w http.ResponseWriter, base baseData, p *db.PanelConfig, isNew bool, errs map[string]string) {
if errs == nil {
errs = map[string]string{}
}
var roles []GuildRole
var textChans, categories []GuildChannel
var guilds []GuildInfo
if h.DiscordInfo != nil {
guilds = h.DiscordInfo.GetGuildList()
gid := p.GuildID
if gid == "" && len(guilds) > 0 {
gid = guilds[0].ID
p.GuildID = gid
}
if gid != "" {
roles, _ = h.DiscordInfo.GetGuildRoles(gid)
chans, _ := h.DiscordInfo.GetGuildChannels(gid)
for _, c := range chans {
switch c.Type {
case 0:
textChans = append(textChans, c)
case 4:
categories = append(categories, c)
}
}
}
}
h.Renderer.Page(w, "panel_form", struct {
baseData
Panel *db.PanelConfig
IsNew bool
Errors map[string]string
Guilds []GuildInfo
Roles []GuildRole
TextChans []GuildChannel
Categories []GuildChannel
}{
baseData: base,
Panel: p,
IsNew: isNew,
Errors: errs,
Guilds: guilds,
Roles: roles,
TextChans: textChans,
Categories: categories,
})
}
// parsePanel extracts a PanelConfig + types from the posted form.
func parsePanel(r *http.Request) (*db.PanelConfig, []*db.PanelType, map[string]string) {
errs := map[string]string{}
p := &db.PanelConfig{
Name: strings.TrimSpace(r.FormValue("name")),
EmbedTitle: strings.TrimSpace(r.FormValue("embed_title")),
EmbedDescription: db.NullStr(strings.TrimSpace(r.FormValue("embed_description"))),
EmbedColor: strings.TrimSpace(r.FormValue("embed_color")),
EmbedImage: strings.TrimSpace(r.FormValue("embed_image")),
EmbedThumbnail: strings.TrimSpace(r.FormValue("embed_thumbnail")),
GuildID: strings.TrimSpace(r.FormValue("guild_id")),
ChannelID: db.NullStr(strings.TrimSpace(r.FormValue("channel_id"))),
}
if p.Name == "" {
errs["name"] = "Le nom est requis."
}
if p.EmbedTitle == "" {
errs["embed_title"] = "Le titre est requis."
}
if p.EmbedColor == "" {
p.EmbedColor = "#5865f2"
}
var types []*db.PanelType
for i := 0; i < 25; i++ {
prefix := fmt.Sprintf("types[%d]", i)
name := strings.TrimSpace(r.FormValue(prefix + "[name]"))
if name == "" {
break
}
claimMode := 0
if r.FormValue(prefix+"[claim_mode]") == "1" {
claimMode = 1
}
claimReupMinutes, _ := strconv.Atoi(r.FormValue(prefix + "[claim_reup_minutes]"))
modalEnabled := r.FormValue(prefix+"[modal_enabled]") != "0"
closeRule := r.FormValue(prefix + "[close_rule]")
if closeRule == "" {
closeRule = "staff_only"
}
maxPU, _ := strconv.Atoi(r.FormValue(prefix + "[max_per_user]"))
if maxPU <= 0 {
maxPU = 1
}
embedColor := strings.TrimSpace(r.FormValue(prefix + "[embed_color]"))
if embedColor == "" {
embedColor = "#5865f2"
}
buttonColor := strings.TrimSpace(r.FormValue(prefix + "[button_color]"))
if buttonColor == "" {
buttonColor = "primary"
}
types = append(types, &db.PanelType{
Name: name,
ButtonLabel: strings.TrimSpace(r.FormValue(prefix + "[button_label]")),
ButtonColor: buttonColor,
ButtonEmoji: strings.TrimSpace(r.FormValue(prefix + "[button_emoji]")),
EmbedColor: embedColor,
EmbedTitle: strings.TrimSpace(r.FormValue(prefix + "[embed_title]")),
EmbedText: strings.TrimSpace(r.FormValue(prefix + "[embed_text]")),
ThumbnailURL: strings.TrimSpace(r.FormValue(prefix + "[thumbnail_url]")),
ImageURL: strings.TrimSpace(r.FormValue(prefix + "[image_url]")),
StaffRoleID: strings.TrimSpace(r.FormValue(prefix + "[staff_role_id]")),
CategoryID: strings.TrimSpace(r.FormValue(prefix + "[category_id]")),
ClaimChannelID: strings.TrimSpace(r.FormValue(prefix + "[claim_channel_id]")),
LogChannelID: strings.TrimSpace(r.FormValue(prefix + "[log_channel_id]")),
MaxPerUser: maxPU,
ClaimMode: claimMode,
ClaimReupMinutes: claimReupMinutes,
CloseRule: closeRule,
ModalEnabled: modalEnabled,
SortOrder: i,
})
}
p.Types = types
return p, types, errs
}