From 490d5a2194b4505cbbf4ca83ab44001db4d55494 Mon Sep 17 00:00:00 2001 From: nocode Date: Mon, 20 Jul 2026 15:41:03 +0200 Subject: [PATCH] feat(admin_menu): add recipient selection dropdown to announcement admin panel Add a new dropdown menu for selecting announcement recipients with three options: - "Tout le monde" (Everyone): broadcasts to all players - "Groupe" (Group): allows selecting specific user groups - "Joueurs" (Players): allows selecting individual players Implement an animated scrollable list panel that displays checkboxes for group or player selection based on the chosen scope. The panel height animates smoothly between collapsed and expanded states. Extract the frequently used outline color (255, 255, 255, 60) into a COL_LINE constant. Add a DrawArrow helper function to render dropdown indicators. Update the send button validation logic to ensure at least one recipient is selected before sending. The panel remains open after sending so the admin can keep their text and reuse it if needed. --- .../client/admin_menu/cl_menu.lua | 253 ++++++++++++++---- 1 file changed, 205 insertions(+), 48 deletions(-) diff --git a/lua/bladw_announcement/client/admin_menu/cl_menu.lua b/lua/bladw_announcement/client/admin_menu/cl_menu.lua index 3992c3f..779648c 100644 --- a/lua/bladw_announcement/client/admin_menu/cl_menu.lua +++ b/lua/bladw_announcement/client/admin_menu/cl_menu.lua @@ -14,6 +14,7 @@ local COL_RED = Color(255, 80, 80) local COL_GRAY = Color(54, 54, 54, 255) local COL_MUTED = Color(160, 160, 160) local COL_WARN = Color(255, 200, 50) +local COL_LINE = Color(255, 255, 255, 60) -- nb de caractères d'un champ (UTF-8, un accent = 1) local function EntryLen(entry) @@ -42,12 +43,27 @@ local function StyleEntry(entry, placeholder, default) entry:SetPaintBackground(false) entry.Paint = function(self, w, h) rndx.Draw(10, 0, 0, w, h, COL_PANEL, rndx.SHAPE_FIGMA) - local outline = self:IsEditing() and COL_ACCENT or Color(255, 255, 255, 60) + local outline = self:IsEditing() and COL_ACCENT or COL_LINE rndx.DrawOutlined(10, 0, 0, w, h, outline, 2, rndx.SHAPE_FIGMA) self:DrawTextEntryText(COL_TEXT, COL_ACCENT, COL_TEXT) end end +-- petite flèche bas +local function DrawArrow(w, h) + draw.NoTexture() + surface.SetDrawColor(COL_MUTED) + local ax = w - 24 + surface.DrawPoly({ + { x = ax, y = h * 0.5 - 2 }, + { x = ax + 10, y = h * 0.5 - 2 }, + { x = ax + 5, y = h * 0.5 + 4 }, + }) +end + +local LIST_H = 130 -- hauteur du panneau de cases une fois ouvert +local LIST_Y = 440 -- haut du panneau + local function OpenAAdminMenu() -- si le menu est déjà ouvert je le vire @@ -56,36 +72,32 @@ local function OpenAAdminMenu() local selectedPreset = "" local maxLen = (BladW.Announcement.Conf or {}).announcement_max_length or 120 + -- destinataires : scope + sélection multiple + local scope, scopeLabel = "all", "Tout le monde" + local selGroups, selPlayers = {}, {} + local expand = 0 -- 0 = replié, 1 = ouvert (animé) + local frame = vgui.Create("DFrame") BladW_AdminMenu = frame - frame:SetSize(500, 460) + frame:SetSize(500, 640) -- taille max -> sert au centrage, on démarre replié frame:Center() + frame:SetTall(498) frame:SetTitle("") frame:ShowCloseButton(false) frame:MakePopup() frame.Paint = function(self, w, h) - -- fond flou + sombre + contour blanc (comme la box d'annonce) rndx.Draw(20, 0, 0, w, h, nil, rndx.SHAPE_FIGMA + rndx.BLUR) rndx.Draw(20, 0, 0, w, h, COL_BG, rndx.SHAPE_FIGMA) rndx.DrawOutlined(20, 0, 0, w, h, COL_TEXT, 3, rndx.SHAPE_FIGMA) - -- logo + titre rndx.DrawMaterial(0, w * 0.5 - 30, 20, 60, 60, COL_TEXT, logo) draw.SimpleText("Envoyer une annonce", "bladw_text", w * 0.5, 94, COL_TEXT, TEXT_ALIGN_CENTER, TEXT_ALIGN_CENTER) draw.SimpleText("Presets", "bladw_text_Medium", 40, 118, COL_TEXT, TEXT_ALIGN_LEFT, TEXT_ALIGN_CENTER) + draw.SimpleText("Destinataires", "bladw_text_Medium", 40, 372, COL_TEXT, TEXT_ALIGN_LEFT, TEXT_ALIGN_CENTER) end - -- ESC ferme mon menu sans laisser le menu pause s'afficher. - -- le moteur ouvre le menu pause sur ESC avant que je puisse l'intercepter, donc je le recache direct et je ferme. - frame.Think = function(self) - if gui.IsGameUIVisible() then - gui.HideGameUI() - self:Remove() - end - end - - -- bouton pour fermer + -- bouton fermer local close = vgui.Create("DButton", frame) close:SetSize(30, 30) close:SetPos(frame:GetWide() - 42, 14) @@ -96,7 +108,7 @@ local function OpenAAdminMenu() end close.DoClick = function() frame:Remove() end - -- les champs (créés avant les boutons preset pour être captés dans leurs closures) + -- champs local titleEntry = vgui.Create("DTextEntry", frame) titleEntry:SetPos(40, 240) titleEntry:SetSize(420, 40) @@ -106,11 +118,10 @@ local function OpenAAdminMenu() msgEntry:SetPos(40, 288) msgEntry:SetSize(420, 40) StyleEntry(msgEntry, "Message de l'annonce") - -- bord rouge quand ça dépasse la limite msgEntry.Paint = function(self, w, h) local over = EntryLen(self) > maxLen rndx.Draw(10, 0, 0, w, h, COL_PANEL, rndx.SHAPE_FIGMA) - local outline = over and COL_RED or (self:IsEditing() and COL_ACCENT or Color(255, 255, 255, 60)) + local outline = over and COL_RED or (self:IsEditing() and COL_ACCENT or COL_LINE) rndx.DrawOutlined(10, 0, 0, w, h, outline, over and 3 or 2, rndx.SHAPE_FIGMA) self:DrawTextEntryText(COL_TEXT, COL_ACCENT, COL_TEXT) end @@ -124,7 +135,6 @@ local function OpenAAdminMenu() local len = EntryLen(msgEntry) local over = len > maxLen - -- retour du serveur (ex : annonce déjà en cours) sinon le warning de dépassement if frame.sendFeedback and (frame.sendFeedbackUntil or 0) > SysTime() then DrawWarning(0, h * 0.5 - 8, 16) draw.SimpleText(frame.sendFeedback, "bladw_text_Medium", 24, h * 0.5, COL_WARN, TEXT_ALIGN_LEFT, TEXT_ALIGN_CENTER) @@ -137,7 +147,171 @@ local function OpenAAdminMenu() over and COL_RED or COL_MUTED, TEXT_ALIGN_RIGHT, TEXT_ALIGN_CENTER) end - -- boutons preset : remplissent les champs et retiennent le preset choisi + -- panneau de cases (groupes ou joueurs), hauteur animée + local list = vgui.Create("DScrollPanel", frame) + list:SetPos(40, LIST_Y) + list:SetSize(420, 1) + list.Paint = function(self, w, h) + rndx.Draw(8, 0, 0, w, h, Color(0, 0, 0, 90), rndx.SHAPE_FIGMA) + end + local vbar = list:GetVBar() + vbar:SetWide(8) + vbar.Paint = function() end + vbar.btnUp.Paint = function() end + vbar.btnDown.Paint = function() end + vbar.btnGrip.Paint = function(self, w, h) + rndx.Draw(4, 0, 0, w, h, COL_LINE, rndx.SHAPE_FIGMA) + end + + local function AddRow(label, isChecked, onToggle) + local row = list:Add("DButton") + row:Dock(TOP) + row:DockMargin(4, 4, 8, 0) + row:SetTall(28) + row:SetText("") + row.Paint = function(self, w, h) + rndx.Draw(6, 0, 0, w, h, COL_PANEL, rndx.SHAPE_FIGMA) + local bs, bx = 18, 8 + local by = (h - bs) * 0.5 + rndx.DrawOutlined(4, bx, by, bs, bs, isChecked() and COL_ACCENT or COL_LINE, 2, rndx.SHAPE_FIGMA) + if isChecked() then + rndx.Draw(3, bx + 4, by + 4, bs - 8, bs - 8, COL_ACCENT, rndx.SHAPE_FIGMA) + end + draw.SimpleText(label, "bladw_text_Medium", bx + bs + 10, h * 0.5, COL_TEXT, TEXT_ALIGN_LEFT, TEXT_ALIGN_CENTER) + end + row.DoClick = function() onToggle() end + end + + local function RebuildList() + list:Clear() + + if scope == "group" then + local seen = {} + for _, p in ipairs(player.GetAll()) do + local g = p:GetUserGroup() + if g and g ~= "" and not seen[g] then + seen[g] = true + AddRow(g, function() return selGroups[g] end, function() + selGroups[g] = (not selGroups[g]) or nil + end) + end + end + elseif scope == "players" then + for _, p in ipairs(player.GetAll()) do + AddRow(p:Nick(), function() return selPlayers[p] end, function() + selPlayers[p] = (not selPlayers[p]) or nil + end) + end + end + end + + -- dropdown du scope + local scopeBtn = vgui.Create("DButton", frame) + scopeBtn:SetPos(40, 392) + scopeBtn:SetSize(420, 40) + scopeBtn:SetText("") + scopeBtn.Paint = function(self, w, h) + rndx.Draw(10, 0, 0, w, h, COL_PANEL, rndx.SHAPE_FIGMA) + rndx.DrawOutlined(10, 0, 0, w, h, self:IsHovered() and COL_ACCENT or COL_LINE, 2, rndx.SHAPE_FIGMA) + draw.SimpleText(scopeLabel, "bladw_text_Medium", 12, h * 0.5, COL_TEXT, TEXT_ALIGN_LEFT, TEXT_ALIGN_CENTER) + DrawArrow(w, h) + end + scopeBtn.DoClick = function(self) + local menu = DermaMenu() + menu:SetMinimumWidth(self:GetWide()) + menu.Paint = function(s, w, h) + rndx.Draw(8, 0, 0, w, h, Color(18, 18, 18, 252), rndx.SHAPE_FIGMA) + rndx.DrawOutlined(8, 0, 0, w, h, COL_LINE, 1, rndx.SHAPE_FIGMA) + end + + local function opt(label, id) + local o = menu:AddOption(label, function() + scope, scopeLabel = id, label + RebuildList() + end) + o:SetTall(30) + o:SetText("") + o.Paint = function(s, w, h) + if s:IsHovered() then rndx.Draw(6, 3, 1, w - 6, h - 2, COL_ACCENT, rndx.SHAPE_FIGMA) end + draw.SimpleText(label, "bladw_text_Medium", 12, h * 0.5, COL_TEXT, TEXT_ALIGN_LEFT, TEXT_ALIGN_CENTER) + end + end + + opt("Tout le monde", "all") + opt("Groupe", "group") + opt("Joueurs", "players") + + local mx, my = self:LocalToScreen(0, self:GetTall() + 2) + menu:Open(mx, my) + end + + -- bouton envoyer (position gérée par le Think selon l'ouverture) + local send = vgui.Create("DButton", frame) + send:SetPos(40, LIST_Y) + send:SetSize(420, 44) + send:SetText("") + send.Paint = function(self, w, h) + local over = EntryLen(msgEntry) > maxLen + local col = over and COL_GRAY or (self:IsHovered() and COL_ACCENT or COL_ACCENT_DIM) + rndx.Draw(12, 0, 0, w, h, col, rndx.SHAPE_FIGMA) + draw.SimpleText("Envoyer", "bladw_text", w * 0.5, h * 0.5, over and COL_MUTED or COL_TEXT, TEXT_ALIGN_CENTER, TEXT_ALIGN_CENTER) + end + send.DoClick = function() + local function fail(msg) + frame.sendFeedback = msg + frame.sendFeedbackUntil = SysTime() + 4 + surface.PlaySound("buttons/button10.wav") + end + + if EntryLen(msgEntry) > maxLen then return fail("Trop de caractères") end + if msgEntry:GetValue() == "" and selectedPreset == "" then return fail("Message vide") end + + local groups, players = {}, {} + if scope == "group" then + for g in pairs(selGroups) do groups[#groups + 1] = g end + if #groups == 0 then return fail("Choisis au moins un groupe") end + elseif scope == "players" then + for p in pairs(selPlayers) do if IsValid(p) then players[#players + 1] = p end end + if #players == 0 then return fail("Choisis au moins un joueur") end + end + + net.Start("bladWSendAnnouncement") + net.WriteString(selectedPreset) + net.WriteString(titleEntry:GetValue()) + net.WriteString(msgEntry:GetValue()) + net.WriteString(scope) + if scope == "group" then + net.WriteUInt(#groups, 8) + for _, g in ipairs(groups) do net.WriteString(g) end + elseif scope == "players" then + net.WriteUInt(#players, 8) + for _, p in ipairs(players) do net.WriteEntity(p) end + end + net.SendToServer() + end + + -- ESC ferme le menu (sans menu pause) + animation d'ouverture du panneau + frame.Think = function(self) + if gui.IsGameUIVisible() then + gui.HideGameUI() + self:Remove() + return + end + + local target = (scope == "all") and 0 or 1 + expand = Lerp(FrameTime() * 12, expand, target) + if math.abs(expand - target) < 0.002 then expand = target end + + local lh = math.Round(expand * LIST_H) + list:SetVisible(lh > 2) + list:SetTall(math.max(lh, 1)) + + local sy = LIST_Y + lh + (lh > 0 and 12 or 0) + send:SetPos(40, sy) + self:SetTall(sy + send:GetTall() + 14) + end + + -- boutons preset : remplissent les champs, retiennent le preset et règlent les destinataires local presets = vgui.Create("DIconLayout", frame) presets:SetPos(40, 134) presets:SetSize(420, 90) @@ -157,38 +331,21 @@ local function OpenAAdminMenu() selectedPreset = key titleEntry:SetText(preset.title or "") msgEntry:SetText(preset.text or "") + + for k in pairs(selGroups) do selGroups[k] = nil end + for k in pairs(selPlayers) do selPlayers[k] = nil end + local t = preset.target + if not t or t == "" or t == "all" then + scope, scopeLabel = "all", "Tout le monde" + else + scope, scopeLabel = "group", "Groupe" + selGroups[t] = true + end + RebuildList() end end - -- bouton envoyer (grisé + bloqué si ça dépasse) - local send = vgui.Create("DButton", frame) - send:SetPos(40, 372) - send:SetSize(420, 50) - send:SetText("") - send.Paint = function(self, w, h) - local over = EntryLen(msgEntry) > maxLen - local col = over and COL_GRAY or (self:IsHovered() and COL_ACCENT or COL_ACCENT_DIM) - rndx.Draw(12, 0, 0, w, h, col, rndx.SHAPE_FIGMA) - draw.SimpleText("Envoyer", "bladw_text", w * 0.5, h * 0.5, over and COL_MUTED or COL_TEXT, TEXT_ALIGN_CENTER, TEXT_ALIGN_CENTER) - end - send.DoClick = function() - if EntryLen(msgEntry) > maxLen then - surface.PlaySound("buttons/button10.wav") - return - end - if msgEntry:GetValue() == "" and selectedPreset == "" then - surface.PlaySound("buttons/button10.wav") - return - end - - -- on envoie mais on NE ferme PAS : c'est la réponse serveur qui fermera si c'est publié, - -- comme ça si c'est refusé l'admin garde son texte - net.Start("bladWSendAnnouncement") - net.WriteString(selectedPreset) - net.WriteString(titleEntry:GetValue()) - net.WriteString(msgEntry:GetValue()) - net.SendToServer() - end + RebuildList() end