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.
This commit is contained in:
2026-07-20 15:41:03 +02:00
parent 5f35c70286
commit 490d5a2194
@@ -14,6 +14,7 @@ local COL_RED = Color(255, 80, 80)
local COL_GRAY = Color(54, 54, 54, 255) local COL_GRAY = Color(54, 54, 54, 255)
local COL_MUTED = Color(160, 160, 160) local COL_MUTED = Color(160, 160, 160)
local COL_WARN = Color(255, 200, 50) 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) -- nb de caractères d'un champ (UTF-8, un accent = 1)
local function EntryLen(entry) local function EntryLen(entry)
@@ -42,12 +43,27 @@ local function StyleEntry(entry, placeholder, default)
entry:SetPaintBackground(false) entry:SetPaintBackground(false)
entry.Paint = function(self, w, h) entry.Paint = function(self, w, h)
rndx.Draw(10, 0, 0, w, h, COL_PANEL, rndx.SHAPE_FIGMA) 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) rndx.DrawOutlined(10, 0, 0, w, h, outline, 2, rndx.SHAPE_FIGMA)
self:DrawTextEntryText(COL_TEXT, COL_ACCENT, COL_TEXT) self:DrawTextEntryText(COL_TEXT, COL_ACCENT, COL_TEXT)
end end
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() local function OpenAAdminMenu()
-- si le menu est déjà ouvert je le vire -- si le menu est déjà ouvert je le vire
@@ -56,36 +72,32 @@ local function OpenAAdminMenu()
local selectedPreset = "" local selectedPreset = ""
local maxLen = (BladW.Announcement.Conf or {}).announcement_max_length or 120 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") local frame = vgui.Create("DFrame")
BladW_AdminMenu = frame BladW_AdminMenu = frame
frame:SetSize(500, 460) frame:SetSize(500, 640) -- taille max -> sert au centrage, on démarre replié
frame:Center() frame:Center()
frame:SetTall(498)
frame:SetTitle("") frame:SetTitle("")
frame:ShowCloseButton(false) frame:ShowCloseButton(false)
frame:MakePopup() frame:MakePopup()
frame.Paint = function(self, w, h) 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, nil, rndx.SHAPE_FIGMA + rndx.BLUR)
rndx.Draw(20, 0, 0, w, h, COL_BG, rndx.SHAPE_FIGMA) 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) 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) 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("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("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 end
-- ESC ferme mon menu sans laisser le menu pause s'afficher. -- bouton fermer
-- 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
local close = vgui.Create("DButton", frame) local close = vgui.Create("DButton", frame)
close:SetSize(30, 30) close:SetSize(30, 30)
close:SetPos(frame:GetWide() - 42, 14) close:SetPos(frame:GetWide() - 42, 14)
@@ -96,7 +108,7 @@ local function OpenAAdminMenu()
end end
close.DoClick = function() frame:Remove() 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) local titleEntry = vgui.Create("DTextEntry", frame)
titleEntry:SetPos(40, 240) titleEntry:SetPos(40, 240)
titleEntry:SetSize(420, 40) titleEntry:SetSize(420, 40)
@@ -106,11 +118,10 @@ local function OpenAAdminMenu()
msgEntry:SetPos(40, 288) msgEntry:SetPos(40, 288)
msgEntry:SetSize(420, 40) msgEntry:SetSize(420, 40)
StyleEntry(msgEntry, "Message de l'annonce") StyleEntry(msgEntry, "Message de l'annonce")
-- bord rouge quand ça dépasse la limite
msgEntry.Paint = function(self, w, h) msgEntry.Paint = function(self, w, h)
local over = EntryLen(self) > maxLen local over = EntryLen(self) > maxLen
rndx.Draw(10, 0, 0, w, h, COL_PANEL, rndx.SHAPE_FIGMA) 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) rndx.DrawOutlined(10, 0, 0, w, h, outline, over and 3 or 2, rndx.SHAPE_FIGMA)
self:DrawTextEntryText(COL_TEXT, COL_ACCENT, COL_TEXT) self:DrawTextEntryText(COL_TEXT, COL_ACCENT, COL_TEXT)
end end
@@ -124,7 +135,6 @@ local function OpenAAdminMenu()
local len = EntryLen(msgEntry) local len = EntryLen(msgEntry)
local over = len > maxLen 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 if frame.sendFeedback and (frame.sendFeedbackUntil or 0) > SysTime() then
DrawWarning(0, h * 0.5 - 8, 16) 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) 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) over and COL_RED or COL_MUTED, TEXT_ALIGN_RIGHT, TEXT_ALIGN_CENTER)
end 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) local presets = vgui.Create("DIconLayout", frame)
presets:SetPos(40, 134) presets:SetPos(40, 134)
presets:SetSize(420, 90) presets:SetSize(420, 90)
@@ -157,38 +331,21 @@ local function OpenAAdminMenu()
selectedPreset = key selectedPreset = key
titleEntry:SetText(preset.title or "") titleEntry:SetText(preset.title or "")
msgEntry:SetText(preset.text 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
end end
-- bouton envoyer (grisé + bloqué si ça dépasse) RebuildList()
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
end end