package server import ( "errors" "strconv" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "scrabble/backend/internal/adminconsole" "scrabble/backend/internal/ads" ) // bannersBack is the campaign-list path the banner console actions return to. const bannersBack = "/_gm/banners" // localTimeLayout is the datetime-local input/round-trip form of a validity-window // bound; the operator's entry is interpreted as UTC. const localTimeLayout = "2006-01-02T15:04" // Neutral banner theme tokens, mirrored from ui/src/app.css (--ad-bg, // --text-muted, --accent for the light and dark themes). They seed the console's // colour pickers and the preview fallback when a campaign has no override, so the // operator always edits against the real neutral colours. const ( tokenLightBg, tokenLightFg, tokenLightLink = "#e3e7ee", "#6b7280", "#2f6df6" tokenDarkBg, tokenDarkFg, tokenDarkLink = "#272f3c", "#9aa3b2", "#5b8cff" ) // consoleBanners lists the advertising campaigns (default first), each with its // weight, validity window, enabled flag, message count and live-now status. func (s *Server) consoleBanners(c *gin.Context) { campaigns, err := s.ads.ListCampaigns(c.Request.Context()) if err != nil { s.consoleError(c, err) return } now := time.Now().UTC() var view adminconsole.BannersView for _, cm := range campaigns { view.Items = append(view.Items, adminconsole.BannerCampaignRow{ ID: cm.ID.String(), Name: cm.Name, Weight: cm.Weight, IsDefault: cm.IsDefault, Enabled: cm.Enabled, Window: windowLabel(cm), Messages: len(cm.Messages), ActiveNow: cm.ActiveAt(now), }) } s.renderConsole(c, "banners", "banners", "Banners", view) } // consoleBannerDetail renders one campaign's edit form and its messages. func (s *Server) consoleBannerDetail(c *gin.Context) { id, ok := s.consoleUUID(c, bannersBack) if !ok { return } cm, err := s.ads.Campaign(c.Request.Context(), id) if err != nil { s.bannerError(c, err, bannersBack) return } view := adminconsole.BannerDetailView{ ID: cm.ID.String(), Name: cm.Name, Weight: cm.Weight, IsDefault: cm.IsDefault, Enabled: cm.Enabled, StartsAt: localInput(cm.StartsAt), EndsAt: localInput(cm.EndsAt), Urgent: cm.Urgent, OverrideAllOn: cm.OverrideAll != nil, OverrideDarkOn: cm.OverrideDark != nil, } view.AllBg, view.AllFg, view.AllLink = seedColors(cm.OverrideAll, tokenLightBg, tokenLightFg, tokenLightLink) view.DarkBg, view.DarkFg, view.DarkLink = seedColors(cm.OverrideDark, tokenDarkBg, tokenDarkFg, tokenDarkLink) for i, m := range cm.Messages { view.Messages = append(view.Messages, adminconsole.BannerMessageRow{ ID: m.ID.String(), BodyEn: m.BodyEn, BodyRu: m.BodyRu, First: i == 0, Last: i == len(cm.Messages)-1, }) } s.renderConsole(c, "banner_detail", "banners", cm.Name, view) } // consoleCreateBanner creates a new time-limited campaign. func (s *Server) consoleCreateBanner(c *gin.Context) { starts, ends, err := parseWindow(c) if err != nil { s.renderConsoleMessage(c, "Invalid window", err.Error(), bannersBack) return } id, err := s.ads.CreateCampaign(c.Request.Context(), ads.Campaign{ Name: trimForm(c, "name"), Weight: atoiForm(c, "weight"), Enabled: c.PostForm("enabled") != "", StartsAt: starts, EndsAt: ends, }) if err != nil { s.bannerError(c, err, bannersBack) return } s.renderConsoleMessage(c, "Created", "campaign created", "/_gm/banners/"+id.String()) } // consoleUpdateBanner saves a campaign's editable fields. For the default // campaign the service ignores everything but the name. func (s *Server) consoleUpdateBanner(c *gin.Context) { id, ok := s.consoleUUID(c, bannersBack) if !ok { return } back := "/_gm/banners/" + id.String() starts, ends, err := parseWindow(c) if err != nil { s.renderConsoleMessage(c, "Invalid window", err.Error(), back) return } err = s.ads.UpdateCampaign(c.Request.Context(), ads.Campaign{ ID: id, Name: trimForm(c, "name"), Weight: atoiForm(c, "weight"), Enabled: c.PostForm("enabled") != "", StartsAt: starts, EndsAt: ends, Urgent: c.PostForm("urgent") != "", OverrideAll: colorSetForm(c, "override_all_on", "override_bg", "override_fg", "override_link"), OverrideDark: colorSetForm(c, "override_dark_on", "override_bg_dark", "override_fg_dark", "override_link_dark"), }) if err != nil { s.bannerError(c, err, back) return } s.renderConsoleMessage(c, "Saved", "campaign updated", back) } // consoleDeleteBanner removes a campaign (the service refuses the default). func (s *Server) consoleDeleteBanner(c *gin.Context) { id, ok := s.consoleUUID(c, bannersBack) if !ok { return } if err := s.ads.DeleteCampaign(c.Request.Context(), id); err != nil { s.bannerError(c, err, "/_gm/banners/"+id.String()) return } s.renderConsoleMessage(c, "Deleted", "campaign deleted", bannersBack) } // consoleAddBannerMessage appends a bilingual message to a campaign. func (s *Server) consoleAddBannerMessage(c *gin.Context) { id, ok := s.consoleUUID(c, bannersBack) if !ok { return } back := "/_gm/banners/" + id.String() if _, err := s.ads.AddMessage(c.Request.Context(), id, trimForm(c, "body_en"), trimForm(c, "body_ru")); err != nil { s.bannerError(c, err, back) return } s.renderConsoleMessage(c, "Added", "message added", back) } // consoleEditBannerMessage rewrites a message's bilingual bodies. func (s *Server) consoleEditBannerMessage(c *gin.Context) { cid, mid, ok := s.bannerMessageIDs(c) if !ok { return } back := "/_gm/banners/" + cid.String() if err := s.ads.EditMessage(c.Request.Context(), cid, mid, trimForm(c, "body_en"), trimForm(c, "body_ru")); err != nil { s.bannerError(c, err, back) return } s.renderConsoleMessage(c, "Saved", "message updated", back) } // consoleDeleteBannerMessage removes a message (the service keeps the default's // last one). func (s *Server) consoleDeleteBannerMessage(c *gin.Context) { cid, mid, ok := s.bannerMessageIDs(c) if !ok { return } back := "/_gm/banners/" + cid.String() if err := s.ads.DeleteMessage(c.Request.Context(), cid, mid); err != nil { s.bannerError(c, err, back) return } s.renderConsoleMessage(c, "Deleted", "message deleted", back) } // consoleMoveBannerMessage reorders a message up or down within its campaign. func (s *Server) consoleMoveBannerMessage(c *gin.Context) { cid, mid, ok := s.bannerMessageIDs(c) if !ok { return } back := "/_gm/banners/" + cid.String() dir := 1 if trimForm(c, "dir") == "up" { dir = -1 } if err := s.ads.MoveMessage(c.Request.Context(), cid, mid, dir); err != nil { s.bannerError(c, err, back) return } s.renderConsoleMessage(c, "Moved", "message reordered", back) } // consoleBannerSettings renders the global display-timings form. func (s *Server) consoleBannerSettings(c *gin.Context) { t, err := s.ads.Settings(c.Request.Context()) if err != nil { s.consoleError(c, err) return } s.renderConsole(c, "banner_settings", "banners", "Banner settings", adminconsole.BannerSettingsView{ HoldMs: t.HoldMs, EdgePauseMs: t.EdgePauseMs, ScrollPxPerSec: t.ScrollPxPerSec, FadeOutMs: t.FadeOutMs, GapMs: t.GapMs, FadeInMs: t.FadeInMs, }) } // consoleUpdateBannerSettings saves the global display timings (clamped by the // service into their safe ranges). func (s *Server) consoleUpdateBannerSettings(c *gin.Context) { err := s.ads.UpdateSettings(c.Request.Context(), ads.Timings{ HoldMs: atoiForm(c, "hold_ms"), EdgePauseMs: atoiForm(c, "edge_pause_ms"), ScrollPxPerSec: atoiForm(c, "scroll_px_per_sec"), FadeOutMs: atoiForm(c, "fade_out_ms"), GapMs: atoiForm(c, "gap_ms"), FadeInMs: atoiForm(c, "fade_in_ms"), }) if err != nil { s.consoleError(c, err) return } s.renderConsoleMessage(c, "Saved", "display timings updated", "/_gm/banner-settings") } // bannerMessageIDs parses the campaign :id and message :mid path parameters, // rendering an invalid-id message and returning false when either is not a UUID. func (s *Server) bannerMessageIDs(c *gin.Context) (campaignID, messageID uuid.UUID, ok bool) { cid, err := uuid.Parse(c.Param("id")) if err != nil { s.renderConsoleMessage(c, "Invalid id", "the campaign id in the URL is not valid", bannersBack) return uuid.Nil, uuid.Nil, false } mid, err := uuid.Parse(c.Param("mid")) if err != nil { s.renderConsoleMessage(c, "Invalid id", "the message id in the URL is not valid", "/_gm/banners/"+cid.String()) return uuid.Nil, uuid.Nil, false } return cid, mid, true } // bannerError maps an ads validation/immutability/not-found error to a friendly // console message, and anything else to the generic error page. func (s *Server) bannerError(c *gin.Context, err error, back string) { switch { case errors.Is(err, ads.ErrValidation), errors.Is(err, ads.ErrDefaultImmutable): s.renderConsoleMessage(c, "Not saved", err.Error(), back) case errors.Is(err, ads.ErrNotFound): s.renderConsoleMessage(c, "Not found", "no such campaign or message", back) default: s.consoleError(c, err) } } // parseWindow reads the optional starts_at/ends_at datetime-local form fields, // interpreting each as UTC; an empty field is an open-ended bound (nil). func parseWindow(c *gin.Context) (starts, ends *time.Time, err error) { if starts, err = parseLocalTime(trimForm(c, "starts_at")); err != nil { return nil, nil, errors.New("the start time is not a valid date/time") } if ends, err = parseLocalTime(trimForm(c, "ends_at")); err != nil { return nil, nil, errors.New("the end time is not a valid date/time") } return starts, ends, nil } // parseLocalTime parses a datetime-local value as UTC, or returns nil for an // empty string. func parseLocalTime(s string) (*time.Time, error) { if s == "" { return nil, nil } t, err := time.Parse(localTimeLayout, s) if err != nil { return nil, err } t = t.UTC() return &t, nil } // localInput formats a validity-window bound for a datetime-local input, or "" // when open-ended. func localInput(t *time.Time) string { if t == nil { return "" } return t.UTC().Format(localTimeLayout) } // windowLabel builds the human-readable validity window for the campaign list. func windowLabel(c ads.Campaign) string { if c.IsDefault { return "perpetual" } switch { case c.StartsAt == nil && c.EndsAt == nil: return "always" case c.StartsAt == nil: return "until " + fmtTimePtr(c.EndsAt) case c.EndsAt == nil: return "from " + fmtTimePtr(c.StartsAt) default: return fmtTimePtr(c.StartsAt) + " – " + fmtTimePtr(c.EndsAt) } } // atoiForm returns the integer value of a posted form field, or 0 when missing // or non-numeric (the ads service clamps timings and validates weights). func atoiForm(c *gin.Context, name string) int { n, _ := strconv.Atoi(trimForm(c, name)) return n } // seedColors returns the three colour-input seed values for a campaign's optional // override: the stored colours when the set is present, otherwise the given // neutral theme tokens (so the native picker starts sensibly and the preview // shows the real fallback). func seedColors(cs *ads.ColorSet, tokenBg, tokenFg, tokenLink string) (bg, fg, link string) { if cs == nil { return tokenBg, tokenFg, tokenLink } return cs.Bg, cs.Fg, cs.Link } // colorSetForm reads an optional colour override from the form: the on flag gates // it (an unchecked set is absent, nil), otherwise the three posted colours are // returned for the ads service to validate. The colour inputs are disabled in the // browser while the set is off, so they are not posted then. func colorSetForm(c *gin.Context, on, bg, fg, link string) *ads.ColorSet { if c.PostForm(on) == "" { return nil } return &ads.ColorSet{ Bg: trimForm(c, bg), Fg: trimForm(c, fg), Link: trimForm(c, link), } }