From 6afd8f913430754d1ff0399eddc6718a60676da8 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:57:44 +0200 Subject: [PATCH 1/3] Support nullable embed colours Allow panel, gallery, support-hours, custom embed, and guild setting colours to be cleared and preserved as null across the Go API and React UI. Add shared colour conversion and validation helpers, update previews and selectors to handle missing colours, and enable local backend module replacements. --- .../http/endpoints/api/admin/gallery/list.go | 2 +- .../endpoints/api/gallery/galleryimport.go | 2 +- .../http/endpoints/api/gallery/submit_form.go | 2 +- .../http/endpoints/api/gallery/submit_tag.go | 2 +- .../app/http/endpoints/api/gallery/types.go | 4 +- .../http/endpoints/api/panel/multipanelget.go | 2 +- .../api/panel/multipanelmessagedata.go | 3 +- .../http/endpoints/api/panel/panelcreate.go | 6 +- .../app/http/endpoints/api/panel/panellist.go | 2 +- .../endpoints/api/panel/panelmessagedata.go | 9 +- .../http/endpoints/api/panel/panelupdate.go | 2 +- .../http/endpoints/api/panel/supporthours.go | 29 +++-- .../http/endpoints/api/settings/settings.go | 12 +- .../endpoints/api/settings/updatesettings.go | 6 +- .../http/endpoints/api/ticket/closerequest.go | 2 +- backend/go.mod | 8 +- backend/utils/types/customembed.go | 8 +- backend/utils/utils.go | 20 +++ frontend/src/components/ColourSelect.tsx | 122 ++++++++++++++---- frontend/src/components/EmbedPreview.tsx | 5 +- frontend/src/components/GalleryCard.tsx | 9 +- frontend/src/components/PanelPreview.tsx | 5 +- frontend/src/components/SupportHoursForm.tsx | 23 ++-- .../src/components/discord/DiscordEmbed.tsx | 3 +- .../discord/container/Container.tsx | 6 +- .../gallery/GalleryListingPreview.tsx | 10 +- .../src/components/modals/TagEditorModal.tsx | 21 +-- frontend/src/lib/colour.ts | 18 +++ frontend/src/pages/manage/GuildSettings.tsx | 16 ++- frontend/src/pages/manage/kb/create.tsx | 7 +- frontend/src/pages/manage/kb/edit.tsx | 15 +-- .../src/pages/manage/multipanels/create.tsx | 8 +- .../src/pages/manage/multipanels/edit.tsx | 8 +- frontend/src/pages/manage/panels/create.tsx | 16 ++- frontend/src/pages/manage/panels/edit.tsx | 16 ++- .../manage/setup/components/PanelsStep.tsx | 21 +-- .../manage/setup/components/SettingsStep.tsx | 26 +++- frontend/src/pages/manage/tickets/_index.tsx | 4 +- .../src/pages/manage/transcripts/_index.tsx | 4 +- frontend/src/types.d.ts | 14 +- 40 files changed, 318 insertions(+), 180 deletions(-) diff --git a/backend/app/http/endpoints/api/admin/gallery/list.go b/backend/app/http/endpoints/api/admin/gallery/list.go index 9f1b4bec..dd71a9ad 100644 --- a/backend/app/http/endpoints/api/admin/gallery/list.go +++ b/backend/app/http/endpoints/api/admin/gallery/list.go @@ -39,7 +39,7 @@ type adminListingResponse struct { SnapshotData stdjson.RawMessage `json:"snapshot_data,omitempty"` Title string `json:"title"` Content string `json:"content"` - Colour int32 `json:"colour"` + Colour *int32 `json:"colour"` ImageUrl *string `json:"image_url"` ThumbnailUrl *string `json:"thumbnail_url"` ButtonStyle *int16 `json:"button_style"` diff --git a/backend/app/http/endpoints/api/gallery/galleryimport.go b/backend/app/http/endpoints/api/gallery/galleryimport.go index 138adf65..9231f02b 100644 --- a/backend/app/http/endpoints/api/gallery/galleryimport.go +++ b/backend/app/http/endpoints/api/gallery/galleryimport.go @@ -185,7 +185,7 @@ func ImportHandler(ctx *gin.Context) { e := embed.NewEmbed(). SetTitle(panel.Title). SetDescription(panel.Content). - SetColor(int(panel.Colour)) + SetColorPtr(utils.ConvertPtr[int](panel.Colour)) if panel.ImageUrl != nil { e.SetImage(*panel.ImageUrl) diff --git a/backend/app/http/endpoints/api/gallery/submit_form.go b/backend/app/http/endpoints/api/gallery/submit_form.go index d54a257f..41473ef5 100644 --- a/backend/app/http/endpoints/api/gallery/submit_form.go +++ b/backend/app/http/endpoints/api/gallery/submit_form.go @@ -171,7 +171,7 @@ func SubmitFormHandler(ctx *gin.Context) { // Panel fields left at zero values for non-panel listings Title: "", Content: "", - Colour: 0, + Colour: nil, ButtonLabel: "", } diff --git a/backend/app/http/endpoints/api/gallery/submit_tag.go b/backend/app/http/endpoints/api/gallery/submit_tag.go index 5ba3d519..35120dda 100644 --- a/backend/app/http/endpoints/api/gallery/submit_tag.go +++ b/backend/app/http/endpoints/api/gallery/submit_tag.go @@ -116,7 +116,7 @@ func SubmitTagHandler(ctx *gin.Context) { // Panel fields left at zero values for non-panel listings Title: "", Content: "", - Colour: 0, + Colour: nil, ButtonLabel: "", } diff --git a/backend/app/http/endpoints/api/gallery/types.go b/backend/app/http/endpoints/api/gallery/types.go index 519a2d3d..9eeb4815 100644 --- a/backend/app/http/endpoints/api/gallery/types.go +++ b/backend/app/http/endpoints/api/gallery/types.go @@ -70,7 +70,7 @@ type galleryListingPublicResponse struct { SnapshotData stdjson.RawMessage `json:"snapshot_data,omitempty"` Title string `json:"title"` Content string `json:"content"` - Colour int32 `json:"colour"` + Colour *int32 `json:"colour"` ImageUrl *string `json:"image_url,omitempty"` ThumbnailUrl *string `json:"thumbnail_url,omitempty"` ButtonStyle *int16 `json:"button_style"` @@ -99,7 +99,7 @@ type galleryListingResponse struct { SnapshotData stdjson.RawMessage `json:"snapshot_data,omitempty"` Title string `json:"title"` Content string `json:"content"` - Colour int32 `json:"colour"` + Colour *int32 `json:"colour"` ImageUrl *string `json:"image_url,omitempty"` ThumbnailUrl *string `json:"thumbnail_url,omitempty"` ButtonStyle *int16 `json:"button_style"` diff --git a/backend/app/http/endpoints/api/panel/multipanelget.go b/backend/app/http/endpoints/api/panel/multipanelget.go index ff784c5e..f791ce34 100644 --- a/backend/app/http/endpoints/api/panel/multipanelget.go +++ b/backend/app/http/endpoints/api/panel/multipanelget.go @@ -59,7 +59,7 @@ func MultiPanelGet(ctx *gin.Context) { if multiPanel.Embed != nil && multiPanel.Embed.CustomEmbed != nil { transformedEmbed = types.NewCustomEmbed(multiPanel.Embed.CustomEmbed, multiPanel.Embed.Fields) } else { - transformedEmbed = &types.CustomEmbed{Colour: 0x5865f2} + transformedEmbed = &types.CustomEmbed{Colour: utils.Ptr(types.Colour(0x5865f2))} } panels, err := dbclient.Client.MultiPanelTargets.GetPanels(ctx, multiPanelId) diff --git a/backend/app/http/endpoints/api/panel/multipanelmessagedata.go b/backend/app/http/endpoints/api/panel/multipanelmessagedata.go index cb1c5288..69938c00 100644 --- a/backend/app/http/endpoints/api/panel/multipanelmessagedata.go +++ b/backend/app/http/endpoints/api/panel/multipanelmessagedata.go @@ -43,7 +43,8 @@ func multiPanelDiscordSubPanelError(action, detail string) string { } func multiPanelIntoMessageData(panel database.MultiPanel, footer footerPolicy) multiPanelMessageData { - custom := &types.CustomEmbed{Colour: 0x5865f2} + blurple := types.Colour(0x5865f2) + custom := &types.CustomEmbed{Colour: &blurple} if panel.Embed != nil && panel.Embed.CustomEmbed != nil { custom = types.NewCustomEmbed(panel.Embed.CustomEmbed, panel.Embed.Fields) } diff --git a/backend/app/http/endpoints/api/panel/panelcreate.go b/backend/app/http/endpoints/api/panel/panelcreate.go index abbd40d6..f7ec22a8 100644 --- a/backend/app/http/endpoints/api/panel/panelcreate.go +++ b/backend/app/http/endpoints/api/panel/panelcreate.go @@ -40,7 +40,7 @@ type panelBody struct { MessageId uint64 `json:"message_id,string"` Title string `json:"title"` Content string `json:"content"` - Colour uint32 `json:"colour"` + Colour *uint32 `json:"colour" validate:"omitempty,max=16777215"` CategoryId uint64 `json:"category_id,string"` Emoji types.Emoji `json:"emote"` WelcomeMessage *types.CustomEmbed `json:"welcome_message" validate:"omitempty"` @@ -86,7 +86,7 @@ func (p *panelBody) IntoPanelMessageData(customId string, showBranding bool) pan Title: p.Title, Content: p.Content, CustomId: customId, - Colour: int(p.Colour), + Colour: utils.ConvertPtr[int](p.Colour), ImageUrl: p.ImageUrl, ThumbnailUrl: p.ThumbnailUrl, Emoji: p.getEmoji(), @@ -290,7 +290,7 @@ func CreatePanel(c *gin.Context) { GuildId: guildId, Title: data.Title, Content: data.Content, - Colour: int32(data.Colour), + Colour: utils.ConvertPtr[int32](data.Colour), TargetCategory: data.CategoryId, EmojiId: emojiId, EmojiName: emojiName, diff --git a/backend/app/http/endpoints/api/panel/panellist.go b/backend/app/http/endpoints/api/panel/panellist.go index 20d43996..4aeadfb5 100644 --- a/backend/app/http/endpoints/api/panel/panellist.go +++ b/backend/app/http/endpoints/api/panel/panellist.go @@ -18,7 +18,7 @@ func ListPanels(c *gin.Context) { PanelId int `json:"panel_id"` ChannelId uint64 `json:"channel_id,string"` Title string `json:"title"` - Colour int32 `json:"colour"` + Colour *int32 `json:"colour"` ButtonLabel string `json:"button_label"` ButtonStyle int `json:"button_style,string"` EmojiName *string `json:"emoji_name,omitempty"` diff --git a/backend/app/http/endpoints/api/panel/panelmessagedata.go b/backend/app/http/endpoints/api/panel/panelmessagedata.go index 16c4829d..e20e0604 100644 --- a/backend/app/http/endpoints/api/panel/panelmessagedata.go +++ b/backend/app/http/endpoints/api/panel/panelmessagedata.go @@ -12,13 +12,14 @@ import ( "github.com/ticketsbot-cloud/dashboard/backend/app" "github.com/ticketsbot-cloud/dashboard/backend/botcontext" "github.com/ticketsbot-cloud/dashboard/backend/config" + "github.com/ticketsbot-cloud/dashboard/backend/utils" ) type panelMessageData struct { ChannelId uint64 Title, Content, CustomId string - Colour int + Colour *int ImageUrl, ThumbnailUrl *string Emoji *emoji.Emoji ButtonStyle component.ButtonStyle @@ -47,7 +48,7 @@ func panelIntoMessageData(panel database.Panel, showBranding bool) panelMessageD Title: panel.Title, Content: panel.Content, CustomId: panel.CustomId, - Colour: int(panel.Colour), + Colour: utils.ConvertPtr[int](panel.Colour), ImageUrl: panel.ImageUrl, ThumbnailUrl: panel.ThumbnailUrl, Emoji: emote, @@ -62,7 +63,7 @@ func (p *panelMessageData) send(c *botcontext.BotContext) (uint64, error) { e := embed.NewEmbed(). SetTitle(p.Title). SetDescription(p.Content). - SetColor(p.Colour) + SetColorPtr(p.Colour) if p.ImageUrl != nil { e.SetImage(*p.ImageUrl) @@ -105,7 +106,7 @@ func (p *panelMessageData) edit(c *botcontext.BotContext, messageId uint64) erro e := embed.NewEmbed(). SetTitle(p.Title). SetDescription(p.Content). - SetColor(p.Colour) + SetColorPtr(p.Colour) if p.ImageUrl != nil { e.SetImage(*p.ImageUrl) diff --git a/backend/app/http/endpoints/api/panel/panelupdate.go b/backend/app/http/endpoints/api/panel/panelupdate.go index 97007d2f..4c768cce 100644 --- a/backend/app/http/endpoints/api/panel/panelupdate.go +++ b/backend/app/http/endpoints/api/panel/panelupdate.go @@ -279,7 +279,7 @@ func UpdatePanel(c *gin.Context) { GuildId: guildId, Title: data.Title, Content: data.Content, - Colour: int32(data.Colour), + Colour: utils.ConvertPtr[int32](data.Colour), TargetCategory: data.CategoryId, EmojiName: emojiName, EmojiId: emojiId, diff --git a/backend/app/http/endpoints/api/panel/supporthours.go b/backend/app/http/endpoints/api/panel/supporthours.go index a7ffab74..7b485df6 100644 --- a/backend/app/http/endpoints/api/panel/supporthours.go +++ b/backend/app/http/endpoints/api/panel/supporthours.go @@ -25,7 +25,7 @@ type supportHoursResponse struct { OutOfHoursBehaviour string `json:"out_of_hours_behaviour"` OutOfHoursTitle string `json:"out_of_hours_title"` OutOfHoursMessage string `json:"out_of_hours_message"` - OutOfHoursColour int `json:"out_of_hours_colour"` + OutOfHoursColour *int `json:"out_of_hours_colour"` } // supportHoursAuditData is used for audit log old/new data to include both hours and settings @@ -98,14 +98,10 @@ func GetSupportHours(c *gin.Context) { outOfHoursBehaviour := string(database.OutOfHoursBehaviourBlockCreation) var outOfHoursTitle string var outOfHoursMessage string - outOfHoursColour := 0xFC3F35 if settingsExist { outOfHoursBehaviour = string(settings.OutOfHoursBehaviour) outOfHoursTitle = settings.OutOfHoursTitle outOfHoursMessage = settings.OutOfHoursMessage - if settings.OutOfHoursColour != 0 { - outOfHoursColour = settings.OutOfHoursColour - } } response := supportHoursResponse{ @@ -114,12 +110,20 @@ func GetSupportHours(c *gin.Context) { OutOfHoursBehaviour: outOfHoursBehaviour, OutOfHoursTitle: outOfHoursTitle, OutOfHoursMessage: outOfHoursMessage, - OutOfHoursColour: outOfHoursColour, + OutOfHoursColour: outOfHoursColourResponse(settings, settingsExist), } c.JSON(http.StatusOK, response) } +func outOfHoursColourResponse(settings database.PanelSupportHoursSettings, settingsExist bool) *int { + if !settingsExist { + return utils.Ptr(0xFC3F35) + } + + return settings.OutOfHoursColour +} + // supportHoursPayload represents individual hour configuration in requests type supportHoursPayload struct { DayOfWeek int `json:"day_of_week"` @@ -135,7 +139,11 @@ type supportHoursRequestBody struct { OutOfHoursBehaviour string `json:"out_of_hours_behaviour"` OutOfHoursTitle string `json:"out_of_hours_title"` OutOfHoursMessage string `json:"out_of_hours_message"` - OutOfHoursColour int `json:"out_of_hours_colour"` + OutOfHoursColour *int `json:"out_of_hours_colour"` +} + +func isValidOutOfHoursColour(colour *int) bool { + return colour == nil || (*colour >= 0 && *colour <= 0xFFFFFF) } func SetSupportHours(c *gin.Context) { @@ -223,6 +231,11 @@ func SetSupportHours(c *gin.Context) { return } + if !isValidOutOfHoursColour(requestBody.OutOfHoursColour) { + c.JSON(http.StatusBadRequest, utils.ErrorStr("Invalid out of hours colour")) + return + } + // Fetch existing data for audit log oldHours, err := dbclient.Client.PanelSupportHours.GetByPanelId(c, panelId) if err != nil { @@ -319,7 +332,7 @@ func SetSupportHours(c *gin.Context) { if string(oldSettings.OutOfHoursBehaviour) != behaviour || oldSettings.OutOfHoursTitle != outOfHoursTitle || oldSettings.OutOfHoursMessage != outOfHoursMessage || - oldSettings.OutOfHoursColour != requestBody.OutOfHoursColour { + !utils.PtrEqual(oldSettings.OutOfHoursColour, requestBody.OutOfHoursColour) { hasChanges = true } diff --git a/backend/app/http/endpoints/api/settings/settings.go b/backend/app/http/endpoints/api/settings/settings.go index 9f779b81..92759d6a 100644 --- a/backend/app/http/endpoints/api/settings/settings.go +++ b/backend/app/http/endpoints/api/settings/settings.go @@ -20,7 +20,7 @@ type ( Language *string `json:"language"` } - ColourMap map[customisation.Colour]utils.HexColour + ColourMap map[customisation.Colour]*utils.HexColour ) func loadSettings(ctx context.Context, guildId uint64) (Settings, error) { @@ -101,20 +101,24 @@ func getColourMap(guildId uint64) (ColourMap, error) { return nil, err } + return newColourMap(raw), nil +} + +func newColourMap(raw map[int16]*int) ColourMap { colours := make(ColourMap) for id, hex := range raw { if !utils.Exists(activeColours, customisation.Colour(id)) { continue } - colours[customisation.Colour(id)] = utils.HexColour(hex) + colours[customisation.Colour(id)] = utils.ConvertPtr[utils.HexColour](hex) } for _, id := range activeColours { if _, ok := colours[id]; !ok { - colours[id] = utils.HexColour(customisation.DefaultColours[id]) + colours[id] = utils.Ptr(utils.HexColour(customisation.DefaultColours[id])) } } - return colours, nil + return colours } diff --git a/backend/app/http/endpoints/api/settings/updatesettings.go b/backend/app/http/endpoints/api/settings/updatesettings.go index ed13cce8..bd986a0f 100644 --- a/backend/app/http/endpoints/api/settings/updatesettings.go +++ b/backend/app/http/endpoints/api/settings/updatesettings.go @@ -127,7 +127,7 @@ func (s *Settings) Validate(ctx context.Context, guildId uint64, premiumTier pre for _, colourCode := range activeColours { if _, ok := s.Colours[colourCode]; !ok { - s.Colours[colourCode] = utils.HexColour(customisation.DefaultColours[colourCode]) + s.Colours[colourCode] = utils.Ptr(utils.HexColour(customisation.DefaultColours[colourCode])) } } @@ -171,9 +171,9 @@ func (s *Settings) updateLanguage(guildId uint64) error { func (s *Settings) updateColours(guildId uint64) error { // Convert ColourMap to primitives - converted := make(map[int16]int) + converted := make(map[int16]*int) for colour, hex := range s.Colours { - converted[int16(colour)] = int(hex) + converted[int16(colour)] = utils.ConvertPtr[int](hex) } return dbclient.Client.CustomColours.BatchSet(context.Background(), guildId, converted) diff --git a/backend/app/http/endpoints/api/ticket/closerequest.go b/backend/app/http/endpoints/api/ticket/closerequest.go index 4d569eb1..52b03305 100644 --- a/backend/app/http/endpoints/api/ticket/closerequest.go +++ b/backend/app/http/endpoints/api/ticket/closerequest.go @@ -162,7 +162,7 @@ func buildCloseRequestMessage(locale *i18n.Locale, requesterId uint64, reason *s msgEmbed := &embed.Embed{ Title: i18n.GetMessage(locale, i18n.TitleCloseRequest), Description: description, - Color: 0x2ECC71, + Color: utils.Ptr(0x2ECC71), } components := []component.Component{ diff --git a/backend/go.mod b/backend/go.mod index 55af17fb..549fd981 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,13 +2,13 @@ module github.com/ticketsbot-cloud/dashboard/backend go 1.26.0 -// replace github.com/TicketsBot-cloud/database => ../../database +replace github.com/TicketsBot-cloud/database => ../../database // replace github.com/TicketsBot-cloud/common => ../../common -// replace github.com/TicketsBot-cloud/worker => ../../worker +replace github.com/TicketsBot-cloud/worker => ../../worker -// replace github.com/TicketsBot-cloud/gdl => ../../gdl +replace github.com/TicketsBot-cloud/gdl => ../../gdl replace github.com/go-playground/validator/v10 => github.com/go-playground/validator/v10 v10.14.0 @@ -37,6 +37,7 @@ require ( github.com/jackc/pgtype v1.14.4 github.com/jackc/pgx/v4 v4.18.3 github.com/joho/godotenv v1.5.1 + github.com/json-iterator/go v1.1.12 github.com/mailgun/mailgun-go/v4 v4.23.0 github.com/penglongli/gin-metrics v0.1.10 github.com/pkg/errors v0.9.1 @@ -90,7 +91,6 @@ require ( github.com/jackc/puddle v1.3.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jedib0t/go-pretty/v6 v6.6.7 // indirect - github.com/json-iterator/go v1.1.12 // indirect github.com/juju/ratelimit v1.0.2 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect diff --git a/backend/utils/types/customembed.go b/backend/utils/types/customembed.go index 8b8d3b62..def893a1 100644 --- a/backend/utils/types/customembed.go +++ b/backend/utils/types/customembed.go @@ -27,7 +27,7 @@ type CustomEmbed struct { Title *string `json:"title" validate:"omitempty,min=1,max=256"` Description *string `json:"description" validate:"omitempty,min=1,max=4096"` Url *string `json:"url" validate:"omitempty,max=255"` - Colour Colour `json:"colour" validate:"gte=0,lte=16777215"` + Colour *Colour `json:"colour" validate:"omitempty,lte=16777215"` Author Author `json:"author"` ImageUrl *string `json:"image_url" validate:"omitempty,max=255"` ThumbnailUrl *string `json:"thumbnail_url" validate:"omitempty,max=255"` @@ -105,7 +105,7 @@ func NewCustomEmbed(c *database.CustomEmbed, fields []database.EmbedField) *Cust Title: c.Title, Description: c.Description, Url: c.Url, - Colour: Colour(c.Colour), + Colour: utils.ConvertPtr[Colour](c.Colour), Author: Author{ Name: c.AuthorName, IconUrl: c.AuthorIconUrl, @@ -136,7 +136,7 @@ func (c *CustomEmbed) IntoDatabaseStruct() (*database.CustomEmbed, []database.Em Title: c.Title, Description: c.Description, Url: c.Url, - Colour: uint32(c.Colour), + Colour: utils.ConvertPtr[uint32](c.Colour), AuthorName: c.Author.Name, AuthorIconUrl: c.Author.IconUrl, AuthorUrl: c.Author.Url, @@ -154,7 +154,7 @@ func (c *CustomEmbed) IntoDiscordEmbed() *embed.Embed { Description: utils.ValueOrZero(c.Description), Url: resolveAvatarPlaceholder(utils.ValueOrZero(c.Url)), Timestamp: TimeOrNil(c.Timestamp), - Color: int(c.Colour), + Color: utils.ConvertPtr[int](c.Colour), } if c.Footer.Text != nil { diff --git a/backend/utils/utils.go b/backend/utils/utils.go index 10ac66f8..0ea0bad9 100644 --- a/backend/utils/utils.go +++ b/backend/utils/utils.go @@ -12,6 +12,26 @@ func ValueOrZero[T any](v *T) T { } } +type integer interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 +} + +func ConvertPtr[U, T integer](v *T) *U { + if v == nil { + return nil + } + + return Ptr(U(*v)) +} + +func PtrEqual[T comparable](a, b *T) bool { + if a == nil || b == nil { + return a == b + } + + return *a == *b +} + func SetNilIfZero[T comparable](value **T) { if value != nil && *value != nil && **value == *new(T) { *value = nil diff --git a/frontend/src/components/ColourSelect.tsx b/frontend/src/components/ColourSelect.tsx index b104ad96..da1f7774 100644 --- a/frontend/src/components/ColourSelect.tsx +++ b/frontend/src/components/ColourSelect.tsx @@ -1,57 +1,127 @@ -import React, { useEffect, useId, useRef } from "react"; - -interface ColourSelectProps { - value: string; - onChange: (value: string) => void; +import React, { useEffect, useId, useRef, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faBan } from "@fortawesome/free-solid-svg-icons"; +import { IDLE_INPUT_CLASS } from "@/lib/field-validity"; +interface ColourSelectBaseProps { disabled?: boolean; className?: string; label?: string; // Optional label } -const ColourSelect: React.FC = ({ - value, - onChange, - disabled = false, - className = "", - label, -}) => { +interface SolidColourSelectProps extends ColourSelectBaseProps { + nullable?: false; + clearable?: never; + fallbackColour?: never; + value: string; + onChange: (value: string) => void; +} + +interface NullableColourSelectProps extends ColourSelectBaseProps { + nullable: true; + value: string | null; + onChange: (value: string | null) => void; + clearable?: boolean; + fallbackColour?: string; +} + +type ColourSelectProps = SolidColourSelectProps | NullableColourSelectProps; + +const ColourSelect: React.FC = (props) => { + const { value, disabled = false, className = "", label } = props; + const clearable = props.nullable && props.clearable; + const fallbackColour = (props.nullable && props.fallbackColour) || "#5865f2"; const inputRef = useRef(null); const debounceRef = useRef | null>(null); const inputId = useId(); + const labelId = useId(); + const noneId = useId(); + // Seeded once: tracking value would reset an untouched input to the fallback on clear. + const [initialColour] = useState(value ?? fallbackColour); // Sync external value changes into the uncontrolled input useEffect(() => { - if (inputRef.current && inputRef.current.value !== value) { + if (value !== null && inputRef.current && inputRef.current.value !== value) { inputRef.current.value = value; } }, [value]); + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + const handleChange = (e: React.ChangeEvent) => { if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => onChange(e.target.value), 100); + debounceRef.current = setTimeout(() => props.onChange(e.target.value), 100); + }; + + // Picking the input's current colour fires no change event, so commit on click. + const handleClick = (e: React.MouseEvent) => { + if (clearable && value === null) props.onChange(e.currentTarget.value); }; + const clear = (e: React.MouseEvent) => { + if (!props.nullable) return; + const hadFocus = document.activeElement === e.currentTarget; + if (debounceRef.current) clearTimeout(debounceRef.current); + props.onChange(null); + // Focusing the input after a tap opens its picker on iOS. + if (hadFocus) inputRef.current?.focus(); + }; + + const isNone = value === null; + const clearLabel = label + ? `Clear ${label}${/colour/i.test(label) ? "" : " colour"}` + : "Clear colour"; + return (
{label && ( -
); diff --git a/frontend/src/components/EmbedPreview.tsx b/frontend/src/components/EmbedPreview.tsx index 983871de..2cbe0ebf 100644 --- a/frontend/src/components/EmbedPreview.tsx +++ b/frontend/src/components/EmbedPreview.tsx @@ -3,6 +3,7 @@ import { isSafeUrl } from "@/lib/url"; import { formatEmbedTimestampForDisplay } from "@/lib/embed-timestamp"; import DiscordText from "@/components/discord/DiscordText"; import { previewAvatarUrl } from "@/lib/embed-avatar"; +import { embedBorderColour } from "@/lib/colour"; interface EmbedField { name: string; @@ -14,7 +15,7 @@ interface EmbedPreviewData { title?: string; description?: string; url?: string; - colour: number; + colour?: number | null; author?: { name?: string; icon_url?: string; url?: string }; image_url?: string; thumbnail_url?: string; @@ -29,7 +30,7 @@ interface EmbedPreviewProps { } const EmbedPreview: FC = ({ embed, raw = false }) => { - const borderColor = `#${(embed.colour || 0x5865f2).toString(16).padStart(6, "0")}`; + const borderColor = embedBorderColour(embed.colour); const footerTimestamp = formatEmbedTimestampForDisplay(embed.timestamp); const thumbnailUrl = previewAvatarUrl(embed.thumbnail_url); const imageUrl = previewAvatarUrl(embed.image_url); diff --git a/frontend/src/components/GalleryCard.tsx b/frontend/src/components/GalleryCard.tsx index 7ce0df9c..28e68fdf 100644 --- a/frontend/src/components/GalleryCard.tsx +++ b/frontend/src/components/GalleryCard.tsx @@ -6,6 +6,7 @@ import { faDownload, faStar } from "@fortawesome/free-solid-svg-icons"; import { Link } from "react-router"; import { GALLERY_TYPE_BADGES } from "@/components/gallery/GalleryListingPreview"; import { userAvatarUrl } from "@/lib/discord-cdn"; +import { embedColourHex } from "@/lib/colour"; interface GalleryCardProps { listing: GalleryListing; @@ -18,21 +19,19 @@ interface GalleryCardProps { const BODY_CLASS = "group flex flex-col flex-1 p-4 text-left transition-colors hover:bg-gray-700/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-inset"; -function getColourHex(listing: GalleryListing): string { +function getColourHex(listing: GalleryListing): string | undefined { const type = listing.listing_type || "panel"; if (type === "tag") { const snapshot = listing.snapshot_data as GalleryTagSnapshot | undefined; - const embedColour = snapshot?.embed?.colour; - const colour = embedColour ?? 0x14b8a6; - return "#" + colour.toString(16).padStart(6, "0"); + return embedColourHex(snapshot?.embed?.colour) ?? "#14b8a6"; } if (type === "form") { return "#" + (0xf97316).toString(16).padStart(6, "0"); } - return "#" + listing.colour.toString(16).padStart(6, "0"); + return embedColourHex(listing.colour) ?? undefined; } function getContentPreview(listing: GalleryListing): string { diff --git a/frontend/src/components/PanelPreview.tsx b/frontend/src/components/PanelPreview.tsx index a030b02a..1a753e02 100644 --- a/frontend/src/components/PanelPreview.tsx +++ b/frontend/src/components/PanelPreview.tsx @@ -7,6 +7,7 @@ import { BRANDING_FOOTER_ICON, BRANDING_FOOTER_TEXT } from "@/lib/constants"; import { formatEmbedTimestampForDisplay } from "@/lib/embed-timestamp"; import { isSafeUrl } from "@/lib/url"; import { previewAvatarUrl } from "@/lib/embed-avatar"; +import { embedBorderColour } from "@/lib/colour"; function resolveEmoteName(emote: Panel["emote"] | undefined): string { if (typeof emote === "string") return emote; @@ -38,7 +39,7 @@ const PanelPreview: FC = ({ type, data, brandingFooter }) =>
@@ -103,7 +104,7 @@ const PanelPreview: FC = ({ type, data, brandingFooter }) => const resolveTicketAvatar = (url: string | null | undefined) => isMultiPanelRequest ? url : previewAvatarUrl(url); - const borderColor = embedData?.colour || "#5865f2"; + const borderColor = embedData ? embedBorderColour(embedData.colour) : "#5865f2"; const footerText = embedData?.footer ? typeof embedData.footer === "string" diff --git a/frontend/src/components/SupportHoursForm.tsx b/frontend/src/components/SupportHoursForm.tsx index 4963284c..abfc7dca 100644 --- a/frontend/src/components/SupportHoursForm.tsx +++ b/frontend/src/components/SupportHoursForm.tsx @@ -4,6 +4,7 @@ import type { SupportHoursData } from "@/types"; import Button from "@/components/Button"; import Select from "@/components/Select"; import ColourSelect from "@/components/ColourSelect"; +import { embedColourHex, embedColourInt } from "@/lib/colour"; import TextInput from "@/components/TextInput"; import Textarea from "@/components/Textarea"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -23,14 +24,6 @@ function formatTime(timeString: string): string { return "09:00"; } -function colourIntToHex(colour: number): string { - return `#${colour.toString(16).padStart(6, "0")}`; -} - -function hexToColourInt(hex: string): number { - return parseInt(hex.replace("#", ""), 16); -} - interface HoursState { day_of_week: number; enabled: boolean; @@ -58,7 +51,7 @@ const SupportHoursForm: FC = ({ value, onChange }) => { >("block_creation"); const [outOfHoursTitle, setOutOfHoursTitle] = useState(""); const [outOfHoursMessage, setOutOfHoursMessage] = useState(""); - const [outOfHoursColour, setOutOfHoursColour] = useState("#FC3F35"); + const [outOfHoursColour, setOutOfHoursColour] = useState("#FC3F35"); const [currentTimeDisplay, setCurrentTimeDisplay] = useState(""); // Load initial data from value prop @@ -68,7 +61,8 @@ const SupportHoursForm: FC = ({ value, onChange }) => { if (value.out_of_hours_behaviour) setOutOfHoursBehaviour(value.out_of_hours_behaviour); if (value.out_of_hours_title) setOutOfHoursTitle(value.out_of_hours_title); if (value.out_of_hours_message) setOutOfHoursMessage(value.out_of_hours_message); - if (value.out_of_hours_colour) setOutOfHoursColour(colourIntToHex(value.out_of_hours_colour)); + if (value.out_of_hours_colour !== undefined) + setOutOfHoursColour(embedColourHex(value.out_of_hours_colour)); if (value.hours && value.hours.length > 0) { setHours((prev) => { @@ -128,7 +122,7 @@ const SupportHoursForm: FC = ({ value, onChange }) => { newBehaviour: "block_creation" | "allow_with_warning", newTitle: string, newMessage: string, - newColour: string, + newColour: string | null, ) => { const enabledHours = newHours.filter((h) => h.enabled); if (enabledHours.length === 0) { @@ -146,7 +140,7 @@ const SupportHoursForm: FC = ({ value, onChange }) => { out_of_hours_behaviour: newBehaviour, out_of_hours_title: newTitle, out_of_hours_message: newMessage, - out_of_hours_colour: hexToColourInt(newColour), + out_of_hours_colour: embedColourInt(newColour), }); }, [onChange], @@ -226,7 +220,7 @@ const SupportHoursForm: FC = ({ value, onChange }) => { emitChange(hours, timezone, outOfHoursBehaviour, outOfHoursTitle, clamped, outOfHoursColour); }; - const handleColourChange = (val: string) => { + const handleColourChange = (val: string | null) => { setOutOfHoursColour(val); emitChange(hours, timezone, outOfHoursBehaviour, outOfHoursTitle, outOfHoursMessage, val); }; @@ -405,8 +399,11 @@ const SupportHoursForm: FC = ({ value, onChange }) => {

diff --git a/frontend/src/components/discord/DiscordEmbed.tsx b/frontend/src/components/discord/DiscordEmbed.tsx index 86979495..ff70c967 100644 --- a/frontend/src/components/discord/DiscordEmbed.tsx +++ b/frontend/src/components/discord/DiscordEmbed.tsx @@ -19,7 +19,8 @@ const DiscordEmbedComponent: FC = ({ embed, entities, classNa
{embed.author && ( diff --git a/frontend/src/components/discord/container/Container.tsx b/frontend/src/components/discord/container/Container.tsx index 49d371ae..bc1cf977 100644 --- a/frontend/src/components/discord/container/Container.tsx +++ b/frontend/src/components/discord/container/Container.tsx @@ -1,4 +1,5 @@ import type { GuildChannel, GuildRole, User } from "@/types"; +import { intToColour } from "@/lib/colour"; import DiscordContent from "../DiscordContent"; import ActionRow from "./ActionRow"; import Button from "./Button"; @@ -13,7 +14,7 @@ interface ContainerProps { }; component?: { spoiler?: boolean; - accent_color?: string; + accent_color?: number | null; type?: number; emoji?: { name: string }; label?: string; @@ -62,7 +63,8 @@ interface ContainerProps { } export default function Container({ component = {}, entities }: ContainerProps) { - const borderColor = component.accent_color || "transparent"; + const borderColor = + component.accent_color != null ? intToColour(component.accent_color) : "transparent"; const spoilerClass = component.spoiler ? "blur hover:blur-none" : ""; return ( diff --git a/frontend/src/components/gallery/GalleryListingPreview.tsx b/frontend/src/components/gallery/GalleryListingPreview.tsx index 4b5493c5..06850ab4 100644 --- a/frontend/src/components/gallery/GalleryListingPreview.tsx +++ b/frontend/src/components/gallery/GalleryListingPreview.tsx @@ -3,6 +3,7 @@ import DiscordText from "@/components/discord/DiscordText"; import { parseDiscordMarkdown } from "@/lib/discord-markdown"; import EmbedPreview from "@/components/EmbedPreview"; import DiscordButton from "@/components/discord/container/Button"; +import { embedColourInt } from "@/lib/colour"; import type { GalleryListing, GalleryTagSnapshot, @@ -135,7 +136,7 @@ const PanelListingPreview: FC<{ listing: GalleryListing; raw: boolean }> = ({ li raw={raw} embed={{ ...welcomeMessage, - colour: parseInt(welcomeMessage.colour, 10) || listing.colour, + colour: embedColourInt(welcomeMessage.colour), }} /> @@ -164,12 +165,7 @@ const TagListingPreview: FC<{ listing: GalleryListing; raw: boolean }> = ({ list className="text-gray-300 text-sm mb-4 bg-gray-900 rounded p-3" /> )} - {snapshot.embed && ( - - )} + {snapshot.embed && } ); }; diff --git a/frontend/src/components/modals/TagEditorModal.tsx b/frontend/src/components/modals/TagEditorModal.tsx index 9ccc0d11..6bc1e464 100644 --- a/frontend/src/components/modals/TagEditorModal.tsx +++ b/frontend/src/components/modals/TagEditorModal.tsx @@ -14,6 +14,7 @@ import DateTimePicker from "@/components/DateTimePicker"; import { useKBArticles } from "@/hooks/queries/useKB"; import { parseEmbedTimestamp, serializeEmbedTimestamp } from "@/lib/embed-timestamp"; import { embedUrlError } from "@/lib/embed-url"; +import { embedColourHex, embedColourInt } from "@/lib/colour"; import type { Tag, TagEmbed } from "@/types"; import { EMBED_LIMITS } from "@/constants/embedLimits"; import { BRANDING_FOOTER_TEXT } from "@/lib/constants"; @@ -79,12 +80,7 @@ const TagEditorModal: FC = ({ ? { ...defaultEmbed, ...tag.embed, - colour: (() => { - const c = tag.embed.colour; - if (typeof c === "number") return c; - const parsed = parseInt(String(c ?? "").replace(/^#/, ""), 16); - return isNaN(parsed) ? defaultEmbed.colour : parsed; - })(), + colour: embedColourInt(tag.embed.colour), author: { ...defaultEmbed.author, ...tag.embed.author }, footer: { ...defaultEmbed.footer, ...tag.embed.footer }, fields: tag.embed.fields?.length ? [...tag.embed.fields] : [], @@ -103,12 +99,7 @@ const TagEditorModal: FC = ({ ? { ...defaultEmbed, ...tag.embed, - colour: (() => { - const c = tag.embed.colour; - if (typeof c === "number") return c; - const parsed = parseInt(String(c ?? "").replace(/^#/, ""), 16); - return isNaN(parsed) ? defaultEmbed.colour : parsed; - })(), + colour: embedColourInt(tag.embed.colour), author: { ...defaultEmbed.author, ...tag.embed.author }, footer: { ...defaultEmbed.footer, ...tag.embed.footer }, fields: tag.embed.fields?.length ? [...tag.embed.fields] : [], @@ -283,12 +274,14 @@ const TagEditorModal: FC = ({ showCount /> setEmbed((prev) => ({ ...prev, - colour: parseInt(v.replace("#", ""), 16), + colour: embedColourInt(v), })) } /> diff --git a/frontend/src/lib/colour.ts b/frontend/src/lib/colour.ts index bf9bb567..09ece227 100644 --- a/frontend/src/lib/colour.ts +++ b/frontend/src/lib/colour.ts @@ -14,3 +14,21 @@ export function roleColour(colour: number): string { export function normaliseColour(colour: string): string { return colour.startsWith("#") ? colour : `#${colour}`; } + +export const DISCORD_DEFAULT_EMBED_BORDER = "#1e1f22"; + +export function embedColourHex(colour: number | string | null | undefined): string | null { + if (colour == null || colour === "") return null; + const hex = + typeof colour === "number" ? intToColour(colour) : normaliseColour(colour).toLowerCase(); + return /^#[0-9a-f]{6}$/.test(hex) ? hex : null; +} + +export function embedColourInt(colour: number | string | null | undefined): number | null { + const hex = embedColourHex(colour); + return hex === null ? null : colourToInt(hex); +} + +export function embedBorderColour(colour: number | string | null | undefined): string { + return embedColourHex(colour) ?? DISCORD_DEFAULT_EMBED_BORDER; +} diff --git a/frontend/src/pages/manage/GuildSettings.tsx b/frontend/src/pages/manage/GuildSettings.tsx index f927847d..a9770cdc 100644 --- a/frontend/src/pages/manage/GuildSettings.tsx +++ b/frontend/src/pages/manage/GuildSettings.tsx @@ -21,7 +21,7 @@ import type { GuildSettings } from "@/types"; import PermissionWarningBanner from "@/components/PermissionWarningBanner"; import PanelSwitchBehaviourInfoModal from "@/components/modals/PanelSwitchBehaviourInfoModal"; import { PANEL_SWITCH_OPTIONS } from "@/constants/panelSwitchBehaviour"; -import { intToColour } from "@/lib/colour"; +import { embedColourHex } from "@/lib/colour"; const PanelSwitchSelect: FC<{ value: number; onChange: (v: number) => void }> = ({ value, @@ -220,7 +220,7 @@ const GuildSettings: FC = () => { ...panels.map((p) => ({ key: p.panel_id.toString(), label: p.title, - color: intToColour(p.colour), + color: embedColourHex(p.colour) ?? undefined, })), ]} /> @@ -259,13 +259,21 @@ const GuildSettings: FC = () => { >
updateField("colours", { ...form.colours, "0": v })} label="Success" /> updateField("colours", { ...form.colours, "1": v })} label="Failure" /> diff --git a/frontend/src/pages/manage/kb/create.tsx b/frontend/src/pages/manage/kb/create.tsx index fe4c6432..99428836 100644 --- a/frontend/src/pages/manage/kb/create.tsx +++ b/frontend/src/pages/manage/kb/create.tsx @@ -23,6 +23,7 @@ import type { TagEmbed } from "@/types"; import { EMBED_LIMITS } from "@/constants/embedLimits"; import { BRANDING_FOOTER_TEXT } from "@/lib/constants"; import { embedUrlError } from "@/lib/embed-url"; +import { embedColourHex, embedColourInt } from "@/lib/colour"; import EmbedCharacterTotal from "@/components/EmbedCharacterTotal"; const defaultEmbed: TagEmbed = { @@ -261,12 +262,14 @@ const CreateKBArticlePage: FC = () => { showCount /> setEmbed((prev) => ({ ...prev, - colour: parseInt(v.replace("#", ""), 16), + colour: embedColourInt(v), })) } /> diff --git a/frontend/src/pages/manage/kb/edit.tsx b/frontend/src/pages/manage/kb/edit.tsx index 68084df9..072fa573 100644 --- a/frontend/src/pages/manage/kb/edit.tsx +++ b/frontend/src/pages/manage/kb/edit.tsx @@ -24,6 +24,7 @@ import type { TagEmbed } from "@/types"; import { EMBED_LIMITS } from "@/constants/embedLimits"; import { BRANDING_FOOTER_TEXT } from "@/lib/constants"; import { embedUrlError } from "@/lib/embed-url"; +import { embedColourHex, embedColourInt } from "@/lib/colour"; import EmbedCharacterTotal from "@/components/EmbedCharacterTotal"; const defaultEmbed: TagEmbed = { @@ -73,16 +74,10 @@ const EditKBArticlePage: FC = () => { if (article.embed) { setUseEmbed(true); - const embedColour = (() => { - const c = article.embed.colour; - if (typeof c === "number") return c; - const parsed = parseInt(String(c ?? "").replace(/^#/, ""), 16); - return isNaN(parsed) ? defaultEmbed.colour : parsed; - })(); setEmbed({ ...defaultEmbed, ...article.embed, - colour: embedColour, + colour: embedColourInt(article.embed.colour), author: { ...defaultEmbed.author, ...article.embed.author }, footer: { ...defaultEmbed.footer, ...article.embed.footer }, fields: article.embed.fields?.length ? [...article.embed.fields] : [], @@ -330,12 +325,14 @@ const EditKBArticlePage: FC = () => { showCount /> setEmbed((prev) => ({ ...prev, - colour: parseInt(v.replace("#", ""), 16), + colour: embedColourInt(v), })) } /> diff --git a/frontend/src/pages/manage/multipanels/create.tsx b/frontend/src/pages/manage/multipanels/create.tsx index 4b1602af..2cae5e85 100644 --- a/frontend/src/pages/manage/multipanels/create.tsx +++ b/frontend/src/pages/manage/multipanels/create.tsx @@ -22,7 +22,7 @@ import MultiSelect from "@/components/MultiSelect"; import Select from "@/components/Select"; import TextInput from "@/components/TextInput"; import ColourSelect from "@/components/ColourSelect"; -import { intToColour } from "@/lib/colour"; +import { embedColourHex } from "@/lib/colour"; import Textarea from "@/components/Textarea"; import PanelPreview from "@/components/PanelPreview"; import DateTimePicker from "@/components/DateTimePicker"; @@ -245,7 +245,7 @@ const MultiPanelsPage: FC = () => { options={panels?.map((panel) => ({ label: panel.title, key: panel.panel_id.toString(), - color: intToColour(panel.colour), + color: embedColourHex(panel.colour) ?? undefined, }))} onChange={(e) => setMultiPanel((prev) => { @@ -373,8 +373,10 @@ const MultiPanelsPage: FC = () => { showCount /> setMultiPanel((prev) => prev ? { ...prev, embed: { ...prev.embed, colour: e } } : prev, diff --git a/frontend/src/pages/manage/multipanels/edit.tsx b/frontend/src/pages/manage/multipanels/edit.tsx index b2dcf804..2dde4e46 100644 --- a/frontend/src/pages/manage/multipanels/edit.tsx +++ b/frontend/src/pages/manage/multipanels/edit.tsx @@ -22,7 +22,7 @@ import MultiSelect from "@/components/MultiSelect"; import Select from "@/components/Select"; import TextInput from "@/components/TextInput"; import ColourSelect from "@/components/ColourSelect"; -import { intToColour } from "@/lib/colour"; +import { embedColourHex } from "@/lib/colour"; import Textarea from "@/components/Textarea"; import PanelPreview from "@/components/PanelPreview"; import DateTimePicker from "@/components/DateTimePicker"; @@ -246,7 +246,7 @@ const MultiPanelsPage: FC = () => { options={panels?.map((panel) => ({ label: panel.title, key: panel.panel_id.toString(), - color: intToColour(panel.colour), + color: embedColourHex(panel.colour) ?? undefined, }))} onChange={(e) => setMultiPanel((prev) => { @@ -374,8 +374,10 @@ const MultiPanelsPage: FC = () => { showCount /> setMultiPanel((prev) => prev ? { ...prev, embed: { ...prev.embed, colour: e } } : prev, diff --git a/frontend/src/pages/manage/panels/create.tsx b/frontend/src/pages/manage/panels/create.tsx index 9214d77c..b112d0e8 100644 --- a/frontend/src/pages/manage/panels/create.tsx +++ b/frontend/src/pages/manage/panels/create.tsx @@ -23,7 +23,7 @@ import TextInput from "@/components/TextInput"; import NumberInput from "@/components/NumberInput"; import ColourSelect from "@/components/ColourSelect"; import { BUTTON_STYLE_OPTIONS } from "@/constants/buttonStyles"; -import { roleColour } from "@/lib/colour"; +import { embedColourHex, embedColourInt, roleColour } from "@/lib/colour"; import Textarea from "@/components/Textarea"; import EmojiPicker from "@/components/EmojiPicker"; import PanelPreview from "@/components/PanelPreview"; @@ -320,12 +320,12 @@ const PanelsPage: FC = () => { showCount /> - setPanel((prev) => - prev ? { ...prev, colour: parseInt(e.replace("#", ""), 16) } : prev, - ) + setPanel((prev) => (prev ? { ...prev, colour: embedColourInt(e) } : prev)) } />
@@ -647,8 +647,12 @@ const PanelsPage: FC = () => { showCount /> setPanel((prev) => prev diff --git a/frontend/src/pages/manage/panels/edit.tsx b/frontend/src/pages/manage/panels/edit.tsx index 80f2252f..dd110235 100644 --- a/frontend/src/pages/manage/panels/edit.tsx +++ b/frontend/src/pages/manage/panels/edit.tsx @@ -24,7 +24,7 @@ import TextInput from "@/components/TextInput"; import NumberInput from "@/components/NumberInput"; import ColourSelect from "@/components/ColourSelect"; import { BUTTON_STYLE_OPTIONS } from "@/constants/buttonStyles"; -import { roleColour } from "@/lib/colour"; +import { embedColourHex, embedColourInt, roleColour } from "@/lib/colour"; import Textarea from "@/components/Textarea"; import EmojiPicker from "@/components/EmojiPicker"; import PanelPreview from "@/components/PanelPreview"; @@ -240,12 +240,12 @@ const EditPanelsPage: FC = () => { showCount /> - setPanel((prev) => - prev ? { ...prev, colour: parseInt(e.replace("#", ""), 16) } : prev, - ) + setPanel((prev) => (prev ? { ...prev, colour: embedColourInt(e) } : prev)) } />
@@ -561,8 +561,12 @@ const EditPanelsPage: FC = () => { showCount /> setPanel((prev) => prev diff --git a/frontend/src/pages/manage/setup/components/PanelsStep.tsx b/frontend/src/pages/manage/setup/components/PanelsStep.tsx index 8f0315d5..adcd0c9a 100644 --- a/frontend/src/pages/manage/setup/components/PanelsStep.tsx +++ b/frontend/src/pages/manage/setup/components/PanelsStep.tsx @@ -11,6 +11,7 @@ import CardGridSkeleton from "@/components/skeletons/CardGridSkeleton"; import Tabs from "@/components/Tabs"; import type { GalleryListing } from "@/types"; import { EMBED_LIMITS } from "@/constants/embedLimits"; +import { embedColourInt, intToColour } from "@/lib/colour"; interface PanelsStepProps { guildId: string; @@ -25,20 +26,6 @@ type TabId = "import" | "scratch"; /** Blurple (#5865F2) as a decimal integer. */ const DEFAULT_COLOUR = 5793266; -/** - * Converts a decimal colour integer to a CSS hex string (e.g. 5793266 -> "#5865F2"). - */ -function colourToHex(colour: number): string { - return "#" + colour.toString(16).padStart(6, "0"); -} - -/** - * Converts a CSS hex string to a decimal integer (e.g. "#5865F2" -> 5793266). - */ -function hexToColour(hex: string): number { - return parseInt(hex.replace("#", ""), 16) || DEFAULT_COLOUR; -} - const PanelsStep: FC = ({ guildId, channels, @@ -59,7 +46,7 @@ const PanelsStep: FC = ({ // ─── Create-from-scratch state ─────────────────────────────────────────────── const [title, setTitle] = useState("Open a Ticket"); const [content, setContent] = useState("Click the button below to open a support ticket."); - const [colour, setColour] = useState(colourToHex(DEFAULT_COLOUR)); + const [colour, setColour] = useState(intToColour(DEFAULT_COLOUR)); const [channel, setChannel] = useState(null); const [category, setCategory] = useState(null); const [teamId, setTeamId] = useState("default"); @@ -156,7 +143,7 @@ const PanelsStep: FC = ({ await apiClient.panels.create(guildId, { title: title.trim(), content, - colour: hexToColour(colour), + colour: embedColourInt(colour), channel_id: channel, category_id: category, default_team: teamId === "default", @@ -260,7 +247,7 @@ const PanelsStep: FC = ({ placeholder="Open a Ticket" maxLength={80} /> - +