mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-29 20:19:04 +01:00
feat!: add v3 design tokens, fonts and color themes
This commit is contained in:
@@ -10,6 +10,7 @@ func (m *AppConfigModel) DecodeMsgpack(dec *msgpack.Decoder) error {
|
||||
*config
|
||||
LegacyNotificationEnabled AppConfigValue `msgpack:"EmailLoginNotificationEnabled"`
|
||||
LegacyEmailVerificationEnabled AppConfigValue `msgpack:"EmailVerificationEnabled"`
|
||||
LegacyAccentColor AppConfigValue `msgpack:"AccentColor"`
|
||||
}{config: (*config)(m)}
|
||||
if err := dec.Decode(&value); err != nil {
|
||||
return err
|
||||
@@ -20,6 +21,9 @@ func (m *AppConfigModel) DecodeMsgpack(dec *msgpack.Decoder) error {
|
||||
if m.EmailVerification == "" && value.LegacyEmailVerificationEnabled != "" {
|
||||
m.EmailVerification = legacyEmailVerification(value.LegacyEmailVerificationEnabled)
|
||||
}
|
||||
if m.ColorTheme == "" && value.LegacyAccentColor != "" {
|
||||
m.ColorTheme = legacyColorTheme(value.LegacyAccentColor)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -36,3 +40,21 @@ func legacyEmailVerification(enabled AppConfigValue) AppConfigValue {
|
||||
}
|
||||
return EmailVerificationDisabled
|
||||
}
|
||||
|
||||
// legacyColorTheme maps an accent color from before color themes to the theme built around it, or to a custom theme with that color
|
||||
func legacyColorTheme(accent AppConfigValue) AppConfigValue {
|
||||
switch accent {
|
||||
case "", "default":
|
||||
return "mono"
|
||||
case "oklch(0.6 0.2 240)":
|
||||
return "mist"
|
||||
case "oklch(0.65 0.2 150)":
|
||||
return "sage"
|
||||
case "oklch(0.6 0.15 180)":
|
||||
return "midnight"
|
||||
case "oklch(0.6 0.24 300)":
|
||||
return "lavender"
|
||||
default:
|
||||
return "custom:" + accent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) {
|
||||
if legacyCfg["emailVerification"] == "" {
|
||||
dest.EmailVerification = legacyEmailVerification(AppConfigValue(legacyCfg["emailVerificationEnabled"]))
|
||||
}
|
||||
if legacyCfg["colorTheme"] == "" {
|
||||
dest.ColorTheme = legacyColorTheme(AppConfigValue(legacyCfg["accentColor"]))
|
||||
}
|
||||
|
||||
rt := reflect.ValueOf(dest).Elem().Type()
|
||||
rv := reflect.ValueOf(dest).Elem()
|
||||
|
||||
@@ -32,7 +32,7 @@ type AppConfigModel struct {
|
||||
SessionDuration AppConfigValue `json:"sessionDuration" env:"SESSION_DURATION" type:"int"` // In minutes
|
||||
HomePageURL AppConfigValue `json:"homePageUrl" env:"HOME_PAGE_URL" public:"true"`
|
||||
EmailsVerified AppConfigValue `json:"emailsVerified" env:"EMAILS_VERIFIED" type:"bool"`
|
||||
AccentColor AppConfigValue `json:"accentColor" env:"ACCENT_COLOR" public:"true"`
|
||||
ColorTheme AppConfigValue `json:"colorTheme" env:"COLOR_THEME" public:"true"` // A theme id, or custom:<css color> with an optional :dark suffix for a dark sidebar
|
||||
DisableAnimations AppConfigValue `json:"disableAnimations" env:"DISABLE_ANIMATIONS" type:"bool" public:"true"`
|
||||
AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" env:"ALLOW_OWN_ACCOUNT_EDIT" type:"bool" public:"true"`
|
||||
AllowUserSignups AppConfigValue `json:"allowUserSignups" env:"ALLOW_USER_SIGNUPS" public:"true"`
|
||||
@@ -144,7 +144,7 @@ func getDefaultConfig() *AppConfigModel {
|
||||
AllowUserSignups: "disabled",
|
||||
SignupDefaultUserGroupIDs: "[]",
|
||||
SignupDefaultCustomClaims: "[]",
|
||||
AccentColor: "default",
|
||||
ColorTheme: "mono",
|
||||
// Email
|
||||
RequireUserEmail: "true",
|
||||
SmtpHost: "",
|
||||
|
||||
@@ -345,4 +345,33 @@ func TestAppConfigModel_DecodeMsgpack(t *testing.T) {
|
||||
assert.Equal(t, AppConfigValue(LoginNotificationAlways), m.EmailLoginNotificationMode)
|
||||
assert.Equal(t, AppConfigValue(EmailVerificationRequired), m.EmailVerification)
|
||||
})
|
||||
|
||||
t.Run("maps the legacy accent color to a color theme", func(t *testing.T) {
|
||||
tests := map[string]AppConfigValue{
|
||||
"default": "mono",
|
||||
"oklch(0.6 0.2 240)": "mist",
|
||||
"oklch(0.63 0.2 15)": "custom:oklch(0.63 0.2 15)",
|
||||
}
|
||||
for accent, expected := range tests {
|
||||
data, err := msgpack.Marshal(map[string]string{"AccentColor": accent})
|
||||
require.NoError(t, err)
|
||||
|
||||
var m AppConfigModel
|
||||
err = msgpack.Unmarshal(data, &m)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, expected, m.ColorTheme)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an explicit color theme wins over the legacy accent color", func(t *testing.T) {
|
||||
data, err := msgpack.Marshal(map[string]string{"ColorTheme": "sage", "AccentColor": "default"})
|
||||
require.NoError(t, err)
|
||||
|
||||
var m AppConfigModel
|
||||
err = msgpack.Unmarshal(data, &m)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, AppConfigValue("sage"), m.ColorTheme)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -258,6 +258,14 @@ func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Remove in next major version (v4)
|
||||
// Preserve the old accent color setting unless an explicit color theme replaces it
|
||||
if _, ok := os.LookupEnv("COLOR_THEME"); !ok {
|
||||
if accent, exists := os.LookupEnv("ACCENT_COLOR"); exists {
|
||||
dest.ColorTheme = legacyColorTheme(AppConfigValue(accent))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the resolved configuration before exposing values to the rest of the application
|
||||
err := validateEnvConfig(dest)
|
||||
if err != nil {
|
||||
|
||||
@@ -23,7 +23,7 @@ type AppConfigUpdateDto struct {
|
||||
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
|
||||
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json_string_array"`
|
||||
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json_custom_claims"`
|
||||
AccentColor string `json:"accentColor"`
|
||||
ColorTheme string `json:"colorTheme"`
|
||||
RequireUserEmail string `json:"requireUserEmail" binding:"required,boolean_string"`
|
||||
SmtpHost string `json:"smtpHost"`
|
||||
SmtpPort string `json:"smtpPort"`
|
||||
|
||||
@@ -62,7 +62,7 @@ type FileCleanupJobs struct {
|
||||
fileStorage storage.FileStorage
|
||||
}
|
||||
|
||||
// ClearUnusedDefaultProfilePictures deletes default profile pictures that don't match any user's initials
|
||||
// ClearUnusedDefaultProfilePictures deletes default profile pictures that don't match any user's initials or were rendered by an earlier version
|
||||
func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context) error {
|
||||
var users []model.User
|
||||
err := j.db.
|
||||
@@ -73,10 +73,10 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
|
||||
return fmt.Errorf("failed to fetch users: %w", err)
|
||||
}
|
||||
|
||||
// Create a map to track which initials are in use
|
||||
initialsInUse := make(map[string]struct{})
|
||||
// Create a map to track which pictures are in use
|
||||
filesInUse := make(map[string]struct{})
|
||||
for _, user := range users {
|
||||
initialsInUse[profilepicture.Initials(user.FirstName, user.LastName, user.Username)] = struct{}{}
|
||||
filesInUse[profilepicture.DefaultPictureFile(profilepicture.Initials(user.FirstName, user.LastName, user.Username))] = struct{}{}
|
||||
}
|
||||
|
||||
defaultPicturesDir := path.Join("profile-pictures", "defaults")
|
||||
@@ -91,10 +91,8 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
|
||||
if filename == "" {
|
||||
continue
|
||||
}
|
||||
initials := strings.TrimSuffix(filename, ".png")
|
||||
|
||||
// If these initials aren't used by any user, delete the file
|
||||
if _, ok := initialsInUse[initials]; !ok {
|
||||
// If no user needs this picture, delete the file
|
||||
if _, ok := filesInUse[filename]; !ok {
|
||||
filePath := path.Join(defaultPicturesDir, filename)
|
||||
err = j.fileStorage.Delete(ctx, filePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -140,7 +140,7 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
|
||||
|
||||
// Try cached default for initials
|
||||
initials := profilepicture.Initials(user.FirstName, user.LastName, user.Username)
|
||||
defaultPicturePath := path.Join("profile-pictures", "defaults", initials+".png")
|
||||
defaultPicturePath := path.Join("profile-pictures", "defaults", profilepicture.DefaultPictureFile(initials))
|
||||
file, size, err = s.fileStorage.Open(ctx, defaultPicturePath)
|
||||
if err == nil {
|
||||
return file, size, nil
|
||||
|
||||
@@ -88,7 +88,7 @@ func centerSquare(img image.Image) image.Image {
|
||||
}
|
||||
|
||||
var loadInitialsFont = sync.OnceValues(func() (*opentype.Font, error) {
|
||||
fontBytes, err := resources.FS.ReadFile("fonts/Gloock.ttf")
|
||||
fontBytes, err := resources.FS.ReadFile("fonts/FunnelDisplay-SemiBold.ttf")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read font file: %w", err)
|
||||
}
|
||||
@@ -130,6 +130,12 @@ func canRenderInitials(initials string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// DefaultPictureFile returns the cache file name of the default picture for the initials
|
||||
// The suffix changes whenever the rendering changes, so the cleanup job deletes pictures cached by an earlier version
|
||||
func DefaultPictureFile(initials string) string {
|
||||
return initials + "-v3.png"
|
||||
}
|
||||
|
||||
// CreateDefaultProfilePicture creates a profile picture with the initials
|
||||
func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) {
|
||||
// Create a blank image with a white background
|
||||
@@ -142,7 +148,8 @@ func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) {
|
||||
}
|
||||
|
||||
// Create a font.Face with a specific size
|
||||
fontSize := 160.0
|
||||
// Clients often crop the picture to a circle, so the initials keep clear of its edge
|
||||
fontSize := 120.0
|
||||
face, err := opentype.NewFace(fontFace, &opentype.FaceOptions{
|
||||
Size: fontSize,
|
||||
DPI: 72,
|
||||
|
||||
Reference in New Issue
Block a user