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,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user