mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-20 03:46:29 +00:00
feat: add animated logo as default logo
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 +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 +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 +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 |
@@ -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)
|
||||
|
||||
+10
-1
@@ -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();
|
||||
|
||||
+21
-4
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user