From 6a3903523b77b0dbf8f5ad463e633456d86b917e Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Sun, 14 Jun 2026 11:49:22 +0200 Subject: [PATCH] Added change company view color Signed-off-by: Ronni Skansing --- backend/database/company.go | 2 + backend/model/company.go | 7 ++ backend/repository/company.go | 7 ++ backend/service/company.go | 3 + backend/vo/generic.go | 61 +++++++++++ frontend/src/lib/api/api.js | 11 +- .../components/header/CompanyBanner.svelte | 102 ++++++++++++++---- frontend/src/lib/store/companyColor.js | 6 ++ frontend/src/routes/company/[id]/+page.svelte | 67 +++++++++++- 9 files changed, 239 insertions(+), 27 deletions(-) create mode 100644 frontend/src/lib/store/companyColor.js diff --git a/backend/database/company.go b/backend/database/company.go index 207784c..3b6bbfc 100644 --- a/backend/database/company.go +++ b/backend/database/company.go @@ -16,6 +16,8 @@ type Company struct { UpdatedAt *time.Time `gorm:"not null;index"` Name string `gorm:"not null;unique;index"` Comment *string `gorm:"type:text"` + // Color is an optional #RGB or #RRGGBB used to tint the company view banner and frame + Color *string `gorm:"type:text"` // backref: many-to-one Users []*User //`gorm:"foreignKey:CompanyID;"` diff --git a/backend/model/company.go b/backend/model/company.go index fbf7d8e..7c80708 100644 --- a/backend/model/company.go +++ b/backend/model/company.go @@ -16,6 +16,7 @@ type Company struct { UpdatedAt *time.Time `json:"updatedAt"` Name nullable.Nullable[vo.String64] `json:"name"` Comment nullable.Nullable[vo.OptionalString1MB] `json:"comment"` + Color nullable.Nullable[vo.OptionalHexColor] `json:"color"` } // Validate checks if the Company configuration with a valid state @@ -43,5 +44,11 @@ func (c *Company) ToDBMap() map[string]any { m["comment"] = comment.String() } } + if c.Color.IsSpecified() { + m["color"] = nil + if color, err := c.Color.Get(); err == nil { + m["color"] = color.String() + } + } return m } diff --git a/backend/repository/company.go b/backend/repository/company.go index 9e6eb52..9064d6b 100644 --- a/backend/repository/company.go +++ b/backend/repository/company.go @@ -152,11 +152,18 @@ func ToCompany(row *database.Company) *model.Company { if row.Comment != nil { comment = nullable.NewNullableWithValue(*vo.NewUnsafeOptionalString1MB(*row.Comment)) } + var color nullable.Nullable[vo.OptionalHexColor] + if row.Color != nil { + if c, err := vo.NewOptionalHexColor(*row.Color); err == nil { + color = nullable.NewNullableWithValue(*c) + } + } return &model.Company{ ID: id, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Name: name, Comment: comment, + Color: color, } } diff --git a/backend/service/company.go b/backend/service/company.go index 53579d4..59da881 100644 --- a/backend/service/company.go +++ b/backend/service/company.go @@ -230,6 +230,9 @@ func (s *Company) UpdateByID( if v, err := company.Comment.Get(); err == nil { current.Comment.Set(v) } + if company.Color.IsSpecified() { + current.Color = company.Color + } // validate if err := company.Validate(); err != nil { s.Logger.Errorw("failed to validate company", "error", err) diff --git a/backend/vo/generic.go b/backend/vo/generic.go index 330779c..bbfabc6 100644 --- a/backend/vo/generic.go +++ b/backend/vo/generic.go @@ -3,6 +3,7 @@ package vo import ( "encoding/json" "fmt" + "regexp" "strconv" "strings" @@ -177,6 +178,66 @@ func (s *OptionalString64) UnmarshalJSON(data []byte) error { return nil } +// hexColorPattern matches a #RGB or #RRGGBB color +var hexColorPattern = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) + +// OptionalHexColor is an empty string or a #RGB / #RRGGBB hex color. +// it is constrained so the value is safe to render into a style attribute +type OptionalHexColor struct { + inner string +} + +// NewOptionalHexColor creates a new optional hex color +func NewOptionalHexColor(s string) (*OptionalHexColor, error) { + s = strings.TrimSpace(s) + if s != "" && !hexColorPattern.MatchString(s) { + return nil, errors.New("invalid hex color") + } + return &OptionalHexColor{ + inner: strings.ToLower(s), + }, nil +} + +// NewOptionalHexColorMust creates a new optional hex color and panics if it fails +func NewOptionalHexColorMust(s string) *OptionalHexColor { + a, err := NewOptionalHexColor(s) + if err != nil { + panic(err) + } + return a +} + +// NewEmptyOptionalHexColor creates a new empty optional hex color +func NewEmptyOptionalHexColor() *OptionalHexColor { + return &OptionalHexColor{ + inner: "", + } +} + +// String returns the string representation of the hex color +func (s OptionalHexColor) String() string { + return s.inner +} + +// MarshalJSON implements the json.Marshaler interface +func (s OptionalHexColor) MarshalJSON() ([]byte, error) { + return json.Marshal(s.inner) +} + +// UnmarshalJSON unmarshals the json into a hex color +func (s *OptionalHexColor) UnmarshalJSON(data []byte) error { + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + ss, err := NewOptionalHexColor(str) + if err != nil { + return unwrapError(err) + } + s.inner = ss.inner + return nil +} + // OptionalString127 is a trimmed string with a min of 0 and a max of 127 type OptionalString127 struct { inner string diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 59a4d81..8709d29 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -1243,13 +1243,18 @@ export class API { * @param {string} id * @param {string} name * @param {string} comment + * @param {string} [color] #RGB or #RRGGBB, empty string clears it * @returns {Promise} */ - update: async (id, name, comment) => { - return await postJSON(this.getPath(`/company/${id}`), { + update: async (id, name, comment, color) => { + const body = { name: name, comment: comment - }); + }; + if (color !== undefined) { + body.color = color; + } + return await postJSON(this.getPath(`/company/${id}`), body); }, /** diff --git a/frontend/src/lib/components/header/CompanyBanner.svelte b/frontend/src/lib/components/header/CompanyBanner.svelte index d48fa29..a156e83 100644 --- a/frontend/src/lib/components/header/CompanyBanner.svelte +++ b/frontend/src/lib/components/header/CompanyBanner.svelte @@ -1,8 +1,13 @@ {#if isCompanyView || hasContextMismatch} -
+
{#if hasContextMismatch}
{#if isCompanyView && isResourceGlobal} - Viewing as - {context.companyName} - - + Viewing as + {context.companyName} + + This {resource.resourceType || 'resource'} is global {:else if !isCompanyView && resource.resourceCompanyID} - + This {resource.resourceType || 'resource'} belongs to {resource.resourceCompanyName || 'a company'} {:else if isResourceInDifferentCompany} - Viewing as - {context.companyName} - - + Viewing as + {context.companyName} + + This {resource.resourceType || 'resource'} belongs to {resource.resourceCompanyName || 'another company'}