feat: add animated logo as default logo

This commit is contained in:
Elias Schneider
2026-08-08 20:50:37 +02:00
parent 987d1a8b59
commit 0c9a03e519
16 changed files with 367 additions and 87 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# <div align="center"><img src="https://github.com/user-attachments/assets/5eadf059-dcf3-43f1-b52b-205ad94661a3" width="100"/> </br>Pocket ID</div>
# <div align="center"><img src="https://github.com/user-attachments/assets/d906ac59-9269-4aa0-8c0d-e9619d0de04c" width="100"/> </br>Pocket ID</div>
Pocket ID is an easy-to-use OpenID Connect Certified™ and OAuth 2.0 provider that lets users sign in to your applications with passkeys.
@@ -19,16 +19,32 @@ import (
"github.com/pocket-id/pocket-id/backend/resources"
)
// initApplicationImages copies the images from the embedded directory to the storage backend
// and returns a map containing the detected file extensions in the application-images directory.
const (
applicationImagesPath = "application-images"
deletedApplicationImagesPath = applicationImagesPath + "/.deleted"
legacyApplicationImagesInitedPath = applicationImagesPath + "/.inited"
deletableBundledApplicationImage = "background"
)
// initApplicationImages copies embedded images to storage and returns the detected file extensions
//
//nolint:gocognit
func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) (map[string]string, error) {
// Previous versions of images
// If these are found, they are deleted
legacyImageHashes := imageHashMap{
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
"background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
"logoLight.svg": {
mustDecodeHex("6d42c88cf6668f7e57c4f2a505e71ecc8a1e0a27534632aa6adec87b812d0bb0"),
},
"logoDark.svg": {
mustDecodeHex("0421a8d93714bacf54c78430f1db378fd0d29565f6de59b6a89090d44a82eb16"),
},
"background.jpg": {
mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
},
"background.webp": {
mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
},
}
sourceFiles, err := resources.FS.ReadDir("images")
@@ -36,7 +52,7 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
return nil, fmt.Errorf("failed to read directory: %w", err)
}
destinationFiles, err := fileStorage.List(ctx, "application-images")
destinationFiles, err := fileStorage.List(ctx, applicationImagesPath)
if err != nil {
if storage.IsNotExist(err) {
destinationFiles = []storage.ObjectInfo{}
@@ -46,13 +62,20 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
}
dstNameToExt := make(map[string]string, len(destinationFiles))
listedImageNames := make(map[string]struct{}, len(destinationFiles))
for _, f := range destinationFiles {
// Skip directories
// Skip bootstrap state that recursive storage backends may include in the listing
if f.Path == legacyApplicationImagesInitedPath || strings.HasPrefix(f.Path, deletedApplicationImagesPath+"/") {
continue
}
// Skip directory entries returned by storage backends
_, name := path.Split(f.Path)
if name == "" {
continue
}
nameWithoutExt, ext := utils.SplitFileName(name)
listedImageNames[nameWithoutExt] = struct{}{}
reader, _, err := fileStorage.Open(ctx, f.Path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
@@ -68,8 +91,8 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
continue
}
// Check if the file is a legacy one - if so, delete it
if legacyImageHashes.Contains(hash) {
// Remove bundled legacy images so their current versions can be restored
if legacyImageHashes.Matches(name, hash) {
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
if err := fileStorage.Delete(ctx, f.Path); err != nil {
return nil, fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
@@ -79,19 +102,25 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
dstNameToExt[nameWithoutExt] = ext
}
initedPath := path.Join("application-images", ".inited")
if _, _, err := fileStorage.Open(ctx, initedPath); err == nil {
return dstNameToExt, nil
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read .inited: %w", err)
} else {
err := fileStorage.Save(ctx, initedPath, strings.NewReader(""))
if err != nil {
return nil, fmt.Errorf("failed to store .inited: %w", err)
// Preserve an intentionally deleted background when replacing the legacy global initialization marker
legacyInited, err := storageObjectExists(ctx, fileStorage, legacyApplicationImagesInitedPath)
if err != nil {
return nil, fmt.Errorf("failed to read legacy application images marker: %w", err)
}
if legacyInited {
_, backgroundWasPresent := listedImageNames[deletableBundledApplicationImage]
if !backgroundWasPresent {
deletedPath := deletedApplicationImagePath(deletableBundledApplicationImage)
if err := fileStorage.Save(ctx, deletedPath, strings.NewReader("")); err != nil {
return nil, fmt.Errorf("failed to store deleted application image marker '%s': %w", deletableBundledApplicationImage, err)
}
}
if err := fileStorage.Delete(ctx, legacyApplicationImagesInitedPath); err != nil {
return nil, fmt.Errorf("failed to remove legacy application images marker: %w", err)
}
}
// Copy images from the images directory to the application-images directory if they don't already exist
// Copy missing bundled images unless an administrator intentionally deleted them
for _, sourceFile := range sourceFiles {
if sourceFile.IsDir() {
continue
@@ -104,13 +133,20 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
if _, exists := dstNameToExt[nameWithoutExt]; exists {
continue
}
deleted, err := storageObjectExists(ctx, fileStorage, deletedApplicationImagePath(nameWithoutExt))
if err != nil {
return nil, fmt.Errorf("failed to read deleted application image marker '%s': %w", nameWithoutExt, err)
}
if deleted {
continue
}
slog.Info("Writing new application image", slog.String("name", name))
srcFile, err := resources.FS.Open(srcFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open embedded file '%s': %w", name, err)
}
if err := fileStorage.Save(ctx, path.Join("application-images", name), srcFile); err != nil {
if err := fileStorage.Save(ctx, path.Join(applicationImagesPath, name), srcFile); err != nil {
srcFile.Close()
return nil, fmt.Errorf("failed to store application image '%s': %w", name, err)
}
@@ -121,20 +157,36 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
return dstNameToExt, nil
}
type imageHashMap map[string][]byte
type imageHashMap map[string][][]byte
func (m imageHashMap) Contains(target []byte) bool {
func (m imageHashMap) Matches(name string, target []byte) bool {
if len(target) == 0 {
return false
}
for _, h := range m {
if bytes.Equal(h, target) {
for _, hash := range m[name] {
if bytes.Equal(hash, target) {
return true
}
}
return false
}
func deletedApplicationImagePath(name string) string {
return path.Join(deletedApplicationImagesPath, name)
}
func storageObjectExists(ctx context.Context, fileStorage storage.FileStorage, objectPath string) (bool, error) {
reader, _, err := fileStorage.Open(ctx, objectPath)
if err == nil {
reader.Close()
return true, nil
}
if storage.IsNotExist(err) {
return false, nil
}
return false, err
}
func mustDecodeHex(str string) []byte {
b, err := hex.DecodeString(str)
if err != nil {
@@ -37,6 +37,7 @@ func NewAppImagesController(
group.PUT("/application-images/favicon", authMiddleware.Add(), httpserver.Handle(controller.updateFaviconHandler))
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), httpserver.Handle(controller.updateDefaultProfilePicture))
group.DELETE("/application-images/logo", authMiddleware.Add(), httpserver.Handle(controller.deleteLogoHandler))
group.DELETE("/application-images/background", authMiddleware.Add(), httpserver.Handle(controller.deleteBackgroundImageHandler))
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), httpserver.Handle(controller.deleteDefaultProfilePicture))
}
@@ -56,13 +57,7 @@ type AppImagesController struct {
// @Success 200 {file} binary "Logo image"
// @Router /api/application-images/logo [get]
func (c *AppImagesController) getLogoHandler(ctx *gin.Context) error {
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
imageName := "logoLight"
if !lightLogo {
imageName = "logoDark"
}
return c.getImage(ctx, imageName)
return c.getImage(ctx, logoImageName(ctx))
}
// getEmailLogoHandler godoc
@@ -127,13 +122,7 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) error {
return err
}
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
imageName := "logoLight"
if !lightLogo {
imageName = "logoDark"
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, imageName); err != nil {
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, logoImageName(ctx)); err != nil {
return err
}
@@ -141,6 +130,30 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) error {
return nil
}
// deleteLogoHandler godoc
// @Summary Delete logo image
// @Description Delete the custom application logo and restore the default logo
// @Tags Application Images
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 204 "No Content"
// @Router /api/application-images/logo [delete]
func (c *AppImagesController) deleteLogoHandler(ctx *gin.Context) error {
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), logoImageName(ctx)); err != nil {
return err
}
ctx.Status(http.StatusNoContent)
return nil
}
func logoImageName(ctx *gin.Context) string {
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
if lightLogo {
return "logoLight"
}
return "logoDark"
}
// updateEmailLogoHandler godoc
// @Summary Update email logo
// @Description Update the email logo for use in emails
+13 -5
View File
@@ -57,10 +57,7 @@ func (s *AppImagesService) UpdateImage(ctx context.Context, file *multipart.File
s.mu.Lock()
defer s.mu.Unlock()
currentExt, ok := s.extensions[imageName]
if !ok {
s.extensions[imageName] = fileType
}
currentExt := s.extensions[imageName]
imagePath := path.Join("application-images", imageName+"."+fileType)
fileReader, err := file.Open()
@@ -84,9 +81,12 @@ func (s *AppImagesService) UpdateImage(ctx context.Context, file *multipart.File
return err
}
}
s.extensions[imageName] = fileType
if err := s.storage.Delete(ctx, deletedApplicationImagePath(imageName)); err != nil {
return err
}
return nil
}
@@ -99,6 +99,10 @@ func (s *AppImagesService) DeleteImage(ctx context.Context, imageName string) er
return apperror.ImageNotFound()
}
if err := s.storage.Save(ctx, deletedApplicationImagePath(imageName), strings.NewReader("")); err != nil {
return err
}
imagePath := path.Join("application-images", imageName+"."+ext)
if err := s.storage.Delete(ctx, imagePath); err != nil {
return err
@@ -108,6 +112,10 @@ func (s *AppImagesService) DeleteImage(ctx context.Context, imageName string) er
return nil
}
func deletedApplicationImagePath(imageName string) string {
return path.Join("application-images", ".deleted", imageName)
}
func (s *AppImagesService) IsDefaultProfilePictureSet() bool {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -100,10 +100,18 @@ func TestAppImagesService_ErrorsAndFlags(t *testing.T) {
require.NoError(t, service.DeleteImage(context.Background(), "default-profile-picture"))
assert.False(t, service.IsDefaultProfilePictureSet())
reader, size, err := store.Open(context.Background(), deletedApplicationImagePath("default-profile-picture"))
require.NoError(t, err)
assert.Zero(t, size)
require.NoError(t, reader.Close())
err := service.DeleteImage(context.Background(), "default-profile-picture")
err = service.DeleteImage(context.Background(), "default-profile-picture")
require.Error(t, err)
assert.True(t, apperror.IsCode(err, apperror.CodeImageNotFound))
require.NoError(t, service.UpdateImage(context.Background(), newFileHeader(t, "default-profile-picture.png", []byte("new")), "default-profile-picture"))
_, _, err = store.Open(context.Background(), deletedApplicationImagePath("default-profile-picture"))
require.ErrorIs(t, err, fs.ErrNotExist)
})
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015"><path d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/><style>@media (prefers-color-scheme:dark){#a path{fill:#fff}}@media (prefers-color-scheme:light){#a path{fill:#000}}</style></svg>

Before

Width:  |  Height:  |  Size: 539 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015"><path fill="#fff" d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/></svg>

Before

Width:  |  Height:  |  Size: 427 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015"><path fill="#000" d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/></svg>

Before

Width:  |  Height:  |  Size: 427 B

+140 -9
View File
@@ -1,18 +1,149 @@
<script module lang="ts">
type LogoVariant = 'light' | 'dark';
let defaultLogoHasAnimated = false;
const customLogoAvailability: Record<LogoVariant, boolean | undefined> = {
light: undefined,
dark: undefined
};
const finalLogoPath =
'M-250.368,-706.48C-166.912,-706.48 -83.456,-706.48 0,-706.48C149.377,-706.48 270.906,-584.953 270.906,-435.576C270.906,-376.876 252.438,-321.028 217.506,-274.062C183.258,-228.019 136.385,-194.563 81.955,-177.305C76.939,-175.715 71.924,-174.124 66.908,-172.534C54.955,-231.481 43.003,-290.429 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-50.48,-230.815 -69.733,-112.167 -88.985,6.48C-142.779,6.48 -196.574,6.48 -250.368,6.48Z';
const animationValues = `
M-189.277,-455.654C-183.639,-520.099 -145.598,-577.247 -88.32,-607.319C-29.581,-638.158 40.991,-636.125 97.857,-601.956C125.29,-585.473 148.114,-562.328 164.212,-534.668C211.414,-453.567 192.341,-350.154 119.313,-291.228C92.569,-269.648 60.47,-255.723 26.44,-250.94C27.98,-283.752 29.51,-316.564 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-29.632,-316.622 -28.036,-283.781 -26.44,-250.94C-114.05,-263.255 -181.567,-334.403 -189.277,-422.534Z;
M-189.277,-455.654C-183.639,-520.099 -145.598,-577.247 -88.32,-607.319C-29.581,-638.158 40.991,-636.125 97.857,-601.956C125.29,-585.473 148.114,-562.328 164.212,-534.668C211.414,-453.567 192.341,-350.154 119.313,-291.228C92.569,-269.648 60.47,-255.723 26.44,-250.94C27.98,-283.752 29.51,-316.564 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-29.632,-316.622 -28.036,-283.781 -26.44,-250.94C-114.05,-263.255 -181.567,-334.403 -189.277,-422.534Z;
M-208.638,-523.827C-208.638,-580.969 -167.157,-631.565 -113.16,-644.659C-38.396,-662.79 43.369,-658.5 117.928,-641.978C156.432,-633.446 188.336,-602.071 196.106,-563.334C220.586,-441.292 205.573,-309.149 173.656,-196.614C158.003,-141.421 105.235,-133.862 43.22,-131.47C39.165,-204.106 35.105,-276.742 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.344,-369.823 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.343,-369.823 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-38.561,-276.801 -45.888,-204.136 -53.22,-131.47C-137.025,-137.627 -208.638,-173.118 -208.638,-262.267Z;
M-228,-592C-228,-641.7 -187.7,-682 -138,-682C-46,-682 46,-682 138,-682C187.7,-682 228,-641.7 228,-592C228,-428.67 228,-265.33 228,-102C228,-12 150,-12 60,-12C50.35,-124.46 40.7,-236.92 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-47.49,-236.98 -63.74,-124.49 -80,-12C-160,-12 -228,-12 -228,-102Z;
M-239.184,-594.24C-239.184,-649.466 -194.411,-694.24 -139.184,-694.24C-42.969,-694.24 53.238,-694.24 149.453,-694.24C204.679,-694.24 249.453,-649.466 249.453,-594.24C249.453,-430.415 249.453,-266.585 249.453,-102.76C249.453,-2.76 159.455,-2.76 63.454,-2.76C52.653,-118.299 41.852,-233.839 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.344,-369.823 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.343,-369.823 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-48.985,-233.897 -66.737,-118.328 -84.493,-2.76C-162.5,-2.76 -239.184,-2.76 -239.184,-102.76Z;
M-250.368,-596.48C-250.368,-657.233 -201.121,-706.48 -140.368,-706.48C-39.938,-706.48 60.476,-706.48 160.906,-706.48C221.659,-706.48 270.906,-657.233 270.906,-596.48C270.906,-432.16 270.906,-267.84 270.906,-103.52C270.906,6.48 168.91,6.48 66.908,6.48C54.955,-112.139 43.003,-230.757 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-50.48,-230.815 -69.733,-112.167 -88.985,6.48C-165,6.48 -250.368,6.48 -250.368,-103.52Z;
M-250.368,-596.48C-250.368,-657.233 -201.121,-706.48 -140.368,-706.48C-39.938,-706.48 60.476,-706.48 160.906,-706.48C221.659,-706.48 270.906,-657.233 270.906,-596.48C270.906,-432.16 270.906,-267.84 270.906,-103.52C270.906,6.48 168.91,6.48 66.908,6.48C54.955,-112.139 43.003,-230.757 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-50.48,-230.815 -69.733,-112.167 -88.985,6.48C-165,6.48 -250.368,6.48 -250.368,-103.52Z;
M-250.368,-651.48C-208.64,-681.856 -142.288,-706.48 -70.184,-706.48C54.72,-706.48 158.116,-640.023 215.906,-571.028C243.028,-538.647 257.527,-490.302 244.206,-435.271C219.135,-331.696 192.286,-233.857 176.43,-140.412C167.087,-85.348 120.417,-83.822 66.908,-83.027C54.955,-171.81 43.003,-260.593 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.344,-369.823 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.343,-369.823 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-50.48,-230.815 -69.733,-112.167 -88.985,6.48C-153.889,6.48 -223.471,6.48 -250.368,-48.52Z;
M-250.368,-706.48C-166.912,-706.48 -83.456,-706.48 0,-706.48C149.377,-706.48 270.906,-584.953 270.906,-435.576C270.906,-376.876 252.438,-321.028 217.506,-274.062C183.258,-228.019 136.385,-194.563 81.955,-177.305C76.939,-175.715 71.924,-174.124 66.908,-172.534C54.955,-231.481 43.003,-290.429 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-50.48,-230.815 -69.733,-112.167 -88.985,6.48C-142.779,6.48 -196.574,6.48 -250.368,6.48Z`;
const initialLogoPath = animationValues.trim().split(';', 1)[0];
</script>
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import { cachedApplicationLogo } from '$lib/utils/cached-image-util';
import { cn } from '$lib/utils/style';
import { mode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements';
let { ...props }: HTMLAttributes<HTMLImageElement> = $props();
let {
class: className,
alt,
defaultOnly = false,
animate = true,
colorScheme
}: {
class?: string;
alt?: string;
defaultOnly?: boolean;
animate?: boolean;
colorScheme?: 'light' | 'dark';
} = $props();
const isLightMode = $derived(mode.current === 'light');
const initialIsLightMode = colorScheme ? colorScheme === 'light' : mode.current === 'light';
const initialLogoVariant: LogoVariant = initialIsLightMode ? 'light' : 'dark';
let customLogoAvailabilityState = $state<Record<LogoVariant, boolean | undefined>>({
...customLogoAvailability
});
let animateDefaultLogo = $state(
customLogoAvailability[initialLogoVariant] === false && claimDefaultLogoAnimation()
);
const isLightMode = $derived(colorScheme ? colorScheme === 'light' : mode.current === 'light');
const logoVariant = $derived(isLightMode ? 'light' : 'dark');
const logoAlt = $derived(alt ?? m.logo());
const useDefaultLogo = $derived(
defaultOnly || customLogoAvailabilityState[logoVariant] === false
);
const customLogoLoaded = $derived(customLogoAvailabilityState[logoVariant] === true);
function handleImageLoad() {
customLogoAvailability[logoVariant] = true;
customLogoAvailabilityState[logoVariant] = true;
}
function handleImageError() {
animateDefaultLogo = claimDefaultLogoAnimation();
customLogoAvailability[logoVariant] = false;
customLogoAvailabilityState[logoVariant] = false;
}
function claimDefaultLogoAnimation() {
if (!animate || defaultOnly || defaultLogoHasAnimated || $appConfigStore?.disableAnimations) {
return false;
}
defaultLogoHasAnimated = true;
return true;
}
</script>
<img
{...props}
class={cn('aspect-square object-contain', props.class)}
src={cachedApplicationLogo.getUrl(isLightMode)}
alt={m.logo()}
/>
{#if useDefaultLogo}
<svg
class={cn('aspect-square', isLightMode ? 'text-black' : 'text-white', className)}
viewBox="-346.211 -706.48 712.96 712.96"
role="img"
aria-label={logoAlt}
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
opacity={animateDefaultLogo ? 0 : 1}
d={animateDefaultLogo ? initialLogoPath : finalLogoPath}
>
{#if animateDefaultLogo}
<animate
attributeName="opacity"
dur="1.5s"
begin="0s"
fill="freeze"
keyTimes="0; 0.15; 1"
values="0; 1; 1"
/>
<animateTransform
attributeName="transform"
type="rotate"
dur="1.5s"
begin="0s"
fill="freeze"
calcMode="spline"
keyTimes="0; 0.20; 0.26; 0.44; 1"
keySplines="0 0 1 1; 0 0 1 1; 0.35 0 0.35 1; 0 0 1 1"
values="-90 0 -439.094; -90 0 -439.094; -90 0 -439.094; 0 0 -439.094; 0 0 -439.094"
/>
<animate
attributeName="d"
dur="1.5s"
begin="0s"
fill="freeze"
calcMode="spline"
keyTimes="0; 0.4; 0.4681; 0.53; 0.5689; 0.63; 0.68; 0.7944; 1"
keySplines="0 0 1 1; 0.402 0 0.6982 0.4985; 0.2986 0.4514 0.6022 0.9053; 0.2862 0.178 0.6233 0.5935; 0.2988 0.5071 0.6368 1; 0 0 1 1; 0.5175 0 0.6923 0.5; 0.1712 0.5 0.4163 1"
values={animationValues}
/>
{/if}
</path>
</svg>
{:else}
<img
class={cn('aspect-square object-contain', className)}
style:visibility={customLogoLoaded ? 'visible' : 'hidden'}
src={cachedApplicationLogo.getUrl(isLightMode)}
alt={logoAlt}
onload={handleImageLoad}
onerror={handleImageError}
/>
{/if}
@@ -47,6 +47,13 @@ export default class AppConfigService extends APIService {
cachedApplicationLogo.bustCache(light);
};
deleteLogo = async (light = true) => {
await this.api.delete(`/application-images/logo`, {
params: { light }
});
cachedApplicationLogo.bustCache(light);
};
updateEmailLogo = async (emailLogo: File) => {
const formData = new FormData();
formData.append('file', emailLogo);
@@ -37,7 +37,7 @@
? 'translate-x-[108px]'
: ''}"
>
<Logo class="size-10" />
<Logo class="size-10" animate={false} />
</div>
<ConnectArrow
@@ -36,8 +36,8 @@
}
async function updateImages(
logoLight: File | undefined,
logoDark: File | undefined,
logoLight: File | null | undefined,
logoDark: File | null | undefined,
logoEmail: File | undefined,
defaultProfilePicture: File | null | undefined,
backgroundImage: File | null | undefined,
@@ -45,13 +45,19 @@
) {
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
const lightLogoPromise = logoLight
? appConfigService.updateLogo(logoLight, true)
: Promise.resolve();
const lightLogoPromise =
logoLight === null
? appConfigService.deleteLogo(true)
: logoLight
? appConfigService.updateLogo(logoLight, true)
: Promise.resolve();
const darkLogoPromise = logoDark
? appConfigService.updateLogo(logoDark, false)
: Promise.resolve();
const darkLogoPromise =
logoDark === null
? appConfigService.deleteLogo(false)
: logoDark
? appConfigService.updateLogo(logoDark, false)
: Promise.resolve();
const emailLogoPromise = logoEmail
? appConfigService.updateEmailLogo(logoEmail)
@@ -2,8 +2,10 @@
import FileInput from '$lib/components/form/file-input.svelte';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import { LucideImageOff, LucideUpload, LucideX } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
let {
@@ -12,6 +14,7 @@
label,
image = $bindable(),
imageURL,
fallback,
accept = 'image/png, image/jpeg, image/svg+xml, image/gif, image/webp, image/avif, image/heic',
forceColorScheme,
isResetable = false,
@@ -23,6 +26,7 @@
label: string;
image: File | null | undefined;
imageURL: string;
fallback?: Snippet;
forceColorScheme?: 'light' | 'dark';
accept?: string;
isResetable?: boolean;
@@ -70,7 +74,11 @@
imageClass
)}
>
<LucideImageOff class="text-muted-foreground" />
{#if fallback}
{@render fallback()}
{:else}
<LucideImageOff class="text-muted-foreground" />
{/if}
</div>
{:else}
<img
@@ -97,6 +105,7 @@
{#if isResetable && isImageSet}
<Button
size="icon"
aria-label={`${m.reset_to_default()} ${label}`}
onclick={(e) => {
e.stopPropagation();
onReset();
@@ -1,4 +1,5 @@
<script lang="ts">
import Logo from '$lib/components/logo.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
import {
@@ -13,8 +14,8 @@
callback
}: {
callback: (
logoLight: File | undefined,
logoDark: File | undefined,
logoLight: File | null | undefined,
logoDark: File | null | undefined,
logoEmail: File | undefined,
defaultProfilePicture: File | null | undefined,
backgroundImage: File | null | undefined,
@@ -22,8 +23,8 @@
) => void;
} = $props();
let logoLight = $state<File | undefined>();
let logoDark = $state<File | undefined>();
let logoLight = $state<File | null | undefined>();
let logoDark = $state<File | null | undefined>();
let logoEmail = $state<File | undefined>();
let defaultProfilePicture = $state<File | null | undefined>();
let backgroundImage = $state<File | null | undefined>();
@@ -31,8 +32,18 @@
let defaultProfilePictureSet = $state(true);
let backgroundImageSet = $state(true);
let logoLightSet = $state(true);
let logoDarkSet = $state(true);
</script>
{#snippet lightLogoFallback()}
<Logo defaultOnly colorScheme="light" class="size-full" />
{/snippet}
{#snippet darkLogoFallback()}
<Logo defaultOnly colorScheme="dark" class="size-full" />
{/snippet}
<div class="flex flex-col gap-8">
<ApplicationImage
id="favicon"
@@ -48,7 +59,10 @@
label={m.light_mode_logo()}
bind:image={logoLight}
imageURL={cachedApplicationLogo.getUrl(true)}
fallback={lightLogoFallback}
forceColorScheme="light"
isResetable
bind:isImageSet={logoLightSet}
/>
<ApplicationImage
id="logo-dark"
@@ -56,7 +70,10 @@
label={m.dark_mode_logo()}
bind:image={logoDark}
imageURL={cachedApplicationLogo.getUrl(false)}
fallback={darkLogoFallback}
forceColorScheme="dark"
isResetable
bind:isImageSet={logoDarkSet}
/>
<ApplicationImage
id="logo-email"
@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ImageBox from '$lib/components/image-box.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
@@ -8,7 +9,7 @@
import { m } from '$lib/paraglide/messages';
import userStore from '$lib/stores/user-store';
import type { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import { cachedApplicationLogo, cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { encodeClientIdParam } from '$lib/utils/client-id-util';
import {
LucideBan,
@@ -38,13 +39,17 @@
<Card.Content class=" p-0">
<div class="flex gap-3">
<div class="aspect-square h-[56px]">
<ImageBox
class="size-14"
src={client.hasLogo
? cachedOidcClientLogo.getUrl(client.id, isLightMode)
: cachedApplicationLogo.getUrl(isLightMode)}
alt={m.name_logo({ name: client.name })}
/>
{#if client.hasLogo}
<ImageBox
class="size-14"
src={cachedOidcClientLogo.getUrl(client.id, isLightMode)}
alt={m.name_logo({ name: client.name })}
/>
{:else}
<div class="bg-muted flex size-14 items-center justify-center rounded-2xl p-3">
<Logo class="size-full" alt={m.name_logo({ name: client.name })} animate={false} />
</div>
{/if}
</div>
<div class="flex w-full justify-between gap-3">
<div class="h-20">
+36 -9
View File
@@ -177,17 +177,25 @@ test('Update email configuration', async ({ page }) => {
});
test.describe('Update application images', () => {
test('should upload images', async ({ page }) => {
await page.getByLabel('Favicon').setInputFiles('resources/images/w3-schools-favicon.ico');
test('should upload images and reset custom logos', async ({ page }) => {
await page
.getByLabel('Light Mode Logo')
.setInputFiles('resources/images/pingvin-share-logo.png');
await page.getByLabel('Dark Mode Logo').setInputFiles('resources/images/cloud-logo.png');
await page.getByLabel('Email Logo').setInputFiles('resources/images/pingvin-share-logo.png');
.getByLabel('Favicon', { exact: true })
.setInputFiles('resources/images/w3-schools-favicon.ico');
await page
.getByLabel('Default Profile Picture')
.getByLabel('Light Mode Logo', { exact: true })
.setInputFiles('resources/images/pingvin-share-logo.png');
await page.getByLabel('Background Image').setInputFiles('resources/images/clouds.jpg');
await page
.getByLabel('Dark Mode Logo', { exact: true })
.setInputFiles('resources/images/cloud-logo.png');
await page
.getByLabel('Email Logo', { exact: true })
.setInputFiles('resources/images/pingvin-share-logo.png');
await page
.getByLabel('Default Profile Picture', { exact: true })
.setInputFiles('resources/images/pingvin-share-logo.png');
await page
.getByLabel('Background Image', { exact: true })
.setInputFiles('resources/images/clouds.jpg');
await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
@@ -209,10 +217,29 @@ test.describe('Update application images', () => {
await page.request
.get('/api/application-images/background')
.then((res) => expect.soft(res.status()).toBe(200));
await page
.getByRole('button', { name: 'Reset to default Light Mode Logo', exact: true })
.click();
await page
.getByRole('button', { name: 'Reset to default Dark Mode Logo', exact: true })
.click();
await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Images updated successfully. It may take a few minutes to update.'
);
await page.request
.get('/api/application-images/logo?light=true')
.then((res) => expect.soft(res.status()).toBe(404));
await page.request
.get('/api/application-images/logo?light=false')
.then((res) => expect.soft(res.status()).toBe(404));
});
test('should only allow png/jpeg for email logo', async ({ page }) => {
const emailLogoInput = page.getByLabel('Email Logo');
const emailLogoInput = page.getByLabel('Email Logo', { exact: true });
await emailLogoInput.setInputFiles('resources/images/cloud-logo.svg');
await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click();