1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-15 12:15:11 +00:00

feat: add ability to set default profile picture (#1061)

Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
Elias Schneider
2025-11-04 13:40:00 +01:00
committed by GitHub
parent e03270eb9d
commit ed2c7b2303
14 changed files with 320 additions and 81 deletions

View File

@@ -62,7 +62,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
} }
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService) svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService) svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService) svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService) svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)

View File

@@ -388,3 +388,13 @@ func (e *UserEmailNotSetError) Error() string {
func (e *UserEmailNotSetError) HttpStatusCode() int { func (e *UserEmailNotSetError) HttpStatusCode() int {
return http.StatusBadRequest return http.StatusBadRequest
} }
type ImageNotFoundError struct{}
func (e *ImageNotFoundError) Error() string {
return "Image not found"
}
func (e *ImageNotFoundError) HttpStatusCode() int {
return http.StatusNotFound
}

View File

@@ -25,10 +25,14 @@ func NewAppImagesController(
group.GET("/application-images/logo", controller.getLogoHandler) group.GET("/application-images/logo", controller.getLogoHandler)
group.GET("/application-images/background", controller.getBackgroundImageHandler) group.GET("/application-images/background", controller.getBackgroundImageHandler)
group.GET("/application-images/favicon", controller.getFaviconHandler) group.GET("/application-images/favicon", controller.getFaviconHandler)
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler) group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler) group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler) group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
} }
type AppImagesController struct { type AppImagesController struct {
@@ -78,6 +82,18 @@ func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
c.getImage(ctx, "favicon") c.getImage(ctx, "favicon")
} }
// getDefaultProfilePicture godoc
// @Summary Get default profile picture image
// @Description Get the default profile picture image for the application
// @Tags Application Images
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Default profile picture image"
// @Router /api/application-images/default-profile-picture [get]
func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) {
c.getImage(ctx, "default-profile-picture")
}
// updateLogoHandler godoc // updateLogoHandler godoc
// @Summary Update logo // @Summary Update logo
// @Description Update the application logo // @Description Update the application logo
@@ -171,3 +187,41 @@ func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour) utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
ctx.File(imagePath) ctx.File(imagePath)
} }
// updateDefaultProfilePicture godoc
// @Summary Update default profile picture image
// @Description Update the default profile picture image
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Profile picture image file"
// @Success 204 "No Content"
// @Router /api/application-images/default-profile-picture [put]
func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
_ = ctx.Error(err)
return
}
if err := c.appImagesService.UpdateImage(file, "default-profile-picture"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
// deleteDefaultProfilePicture godoc
// @Summary Delete default profile picture image
// @Description Delete the default profile picture image
// @Tags Application Images
// @Success 204 "No Content"
// @Router /api/application-images/default-profile-picture [delete]
func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) {
if err := c.appImagesService.DeleteImage("default-profile-picture"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}

View File

@@ -48,7 +48,7 @@ func (s *AppImagesService) UpdateImage(file *multipart.FileHeader, imageName str
currentExt, ok := s.extensions[imageName] currentExt, ok := s.extensions[imageName]
if !ok { if !ok {
return fmt.Errorf("unknown application image '%s'", imageName) s.extensions[imageName] = fileType
} }
imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", imageName, fileType)) imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", imageName, fileType))
@@ -69,13 +69,39 @@ func (s *AppImagesService) UpdateImage(file *multipart.FileHeader, imageName str
return nil return nil
} }
func (s *AppImagesService) DeleteImage(imageName string) error {
s.mu.Lock()
defer s.mu.Unlock()
ext, ok := s.extensions[imageName]
if !ok || ext == "" {
return &common.ImageNotFoundError{}
}
imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", imageName+"."+ext)
if err := os.Remove(imagePath); err != nil && !os.IsNotExist(err) {
return err
}
delete(s.extensions, imageName)
return nil
}
func (s *AppImagesService) IsDefaultProfilePictureSet() bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.extensions["default-profile-picture"]
return ok
}
func (s *AppImagesService) getExtension(name string) (string, error) { func (s *AppImagesService) getExtension(name string) (string, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
ext, ok := s.extensions[name] ext, ok := s.extensions[name]
if !ok || ext == "" { if !ok || ext == "" {
return "", fmt.Errorf("unknown application image '%s'", name) return "", &common.ImageNotFoundError{}
} }
return strings.ToLower(ext), nil return strings.ToLower(ext), nil

View File

@@ -10,6 +10,7 @@ import (
"log/slog" "log/slog"
"net/url" "net/url"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -33,9 +34,10 @@ type UserService struct {
emailService *EmailService emailService *EmailService
appConfigService *AppConfigService appConfigService *AppConfigService
customClaimService *CustomClaimService customClaimService *CustomClaimService
appImagesService *AppImagesService
} }
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService) *UserService { func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService) *UserService {
return &UserService{ return &UserService{
db: db, db: db,
jwtService: jwtService, jwtService: jwtService,
@@ -43,6 +45,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
emailService: emailService, emailService: emailService,
appConfigService: appConfigService, appConfigService: appConfigService,
customClaimService: customClaimService, customClaimService: customClaimService,
appImagesService: appImagesService,
} }
} }
@@ -87,39 +90,42 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
return nil, 0, &common.InvalidUUIDError{} return nil, 0, &common.InvalidUUIDError{}
} }
// First check for a custom uploaded profile picture (userID.png)
profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png"
file, err := os.Open(profilePicturePath)
if err == nil {
// Get the file size
fileInfo, err := file.Stat()
if err != nil {
file.Close()
return nil, 0, err
}
return file, fileInfo.Size(), nil
}
// If no custom picture exists, get the user's data for creating initials
user, err := s.GetUser(ctx, userID) user, err := s.GetUser(ctx, userID)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
// Check if we have a cached default picture for these initials profilePicturePath := filepath.Join(common.EnvConfig.UploadPath, "profile-pictures", userID+".png")
defaultProfilePicturesDir := common.EnvConfig.UploadPath + "/profile-pictures/defaults/"
defaultPicturePath := defaultProfilePicturesDir + user.Initials() + ".png" // Try custom profile picture
file, err = os.Open(defaultPicturePath) if file, size, err := utils.OpenFileWithSize(profilePicturePath); err == nil {
if err == nil { return file, size, nil
fileInfo, err := file.Stat() } else if !errors.Is(err, os.ErrNotExist) {
if err != nil { return nil, 0, err
file.Close() }
// Try default global profile picture
if s.appImagesService.IsDefaultProfilePictureSet() {
path, _, err := s.appImagesService.GetImage("default-profile-picture")
if err != nil {
return nil, 0, err
}
if file, size, err := utils.OpenFileWithSize(path); err == nil {
return file, size, nil
} else if !errors.Is(err, os.ErrNotExist) {
return nil, 0, err return nil, 0, err
} }
return file, fileInfo.Size(), nil
} }
// If no cached default picture exists, create one and save it for future use // Try cached default for initials
defaultProfilePicturesDir := filepath.Join(common.EnvConfig.UploadPath, "profile-pictures", "defaults")
defaultPicturePath := filepath.Join(defaultProfilePicturesDir, user.Initials()+".png")
if file, size, err := utils.OpenFileWithSize(defaultPicturePath); err == nil {
return file, size, nil
}
// Create and return generated default with initials
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.Initials()) defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.Initials())
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -128,19 +134,16 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
// Save the default picture for future use (in a goroutine to avoid blocking) // Save the default picture for future use (in a goroutine to avoid blocking)
defaultPictureBytes := defaultPicture.Bytes() defaultPictureBytes := defaultPicture.Bytes()
go func() { go func() {
// Ensure the directory exists if err := os.MkdirAll(defaultProfilePicturesDir, os.ModePerm); err != nil {
errInternal := os.MkdirAll(defaultProfilePicturesDir, os.ModePerm) slog.Error("Failed to create directory for default profile picture", slog.Any("error", err))
if errInternal != nil {
slog.Error("Failed to create directory for default profile picture", slog.Any("error", errInternal))
return return
} }
errInternal = utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath) if err := utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath); err != nil {
if errInternal != nil { slog.Error("Failed to cache default profile picture", slog.String("initials", user.Initials()), slog.Any("error", err))
slog.Error("Failed to cache default profile picture for initials", slog.String("initials", user.Initials()), slog.Any("error", errInternal))
} }
}() }()
return io.NopCloser(bytes.NewReader(defaultPictureBytes)), int64(defaultPicture.Len()), nil return io.NopCloser(bytes.NewReader(defaultPictureBytes)), int64(len(defaultPictureBytes)), nil
} }
func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model.UserGroup, error) { func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model.UserGroup, error) {

View File

@@ -239,3 +239,17 @@ func IsWritableDir(dir string) (bool, error) {
return true, nil return true, nil
} }
// OpenFileWithSize opens a file and returns its size
func OpenFileWithSize(path string) (io.ReadCloser, int64, error) {
f, err := os.Open(path)
if err != nil {
return nil, 0, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return nil, 0, err
}
return f, info.Size(), nil
}

View File

@@ -155,7 +155,7 @@
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.", "are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.",
"last_used": "Last Used", "last_used": "Last Used",
"actions": "Actions", "actions": "Actions",
"images_updated_successfully": "Images updated successfully", "images_updated_successfully": "Images updated successfully. It may take a few minutes to update.",
"general": "General", "general": "General",
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.", "configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP", "ldap": "LDAP",
@@ -461,5 +461,6 @@
"locale": "Locale", "locale": "Locale",
"ldap_id": "LDAP ID", "ldap_id": "LDAP ID",
"reauthentication": "Re-authentication", "reauthentication": "Re-authentication",
"clear_filters" : "Clear Filters" "clear_filters": "Clear Filters",
"default_profile_picture": "Default Profile Picture"
} }

View File

@@ -55,9 +55,13 @@
disabled, disabled,
isLoading = false, isLoading = false,
autofocus = false, autofocus = false,
onclick,
usePromiseLoading = false,
children, children,
...restProps ...restProps
}: ButtonProps = $props(); }: ButtonProps & {
usePromiseLoading?: boolean;
} = $props();
onMount(async () => { onMount(async () => {
// Using autofocus can be bad for a11y, but in the case of Pocket ID is only used responsibly in places where there are not many choices, and on buttons only where there's descriptive text // Using autofocus can be bad for a11y, but in the case of Pocket ID is only used responsibly in places where there are not many choices, and on buttons only where there's descriptive text
@@ -66,6 +70,19 @@
setTimeout(() => ref?.focus(), 100); setTimeout(() => ref?.focus(), 100);
} }
}); });
async function handleOnClick(event: any) {
if (usePromiseLoading && onclick) {
isLoading = true;
try {
await onclick(event);
} finally {
isLoading = false;
}
} else {
onclick?.(event);
}
}
</script> </script>
{#if href} {#if href}
@@ -91,6 +108,7 @@
class={cn(buttonVariants({ variant, size }), className)} class={cn(buttonVariants({ variant, size }), className)}
{type} {type}
disabled={disabled || isLoading} disabled={disabled || isLoading}
onclick={handleOnClick}
{...restProps} {...restProps}
> >
{#if isLoading} {#if isLoading}

View File

@@ -1,5 +1,12 @@
import userStore from '$lib/stores/user-store';
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration'; import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
import { cachedApplicationLogo, cachedBackgroundImage } from '$lib/utils/cached-image-util'; import {
cachedApplicationLogo,
cachedBackgroundImage,
cachedDefaultProfilePicture,
cachedProfilePicture
} from '$lib/utils/cached-image-util';
import { get } from 'svelte/store';
import APIService from './api-service'; import APIService from './api-service';
export default class AppConfigService extends APIService { export default class AppConfigService extends APIService {
@@ -24,14 +31,14 @@ export default class AppConfigService extends APIService {
updateFavicon = async (favicon: File) => { updateFavicon = async (favicon: File) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', favicon!); formData.append('file', favicon);
await this.api.put(`/application-images/favicon`, formData); await this.api.put(`/application-images/favicon`, formData);
} };
updateLogo = async (logo: File, light = true) => { updateLogo = async (logo: File, light = true) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', logo!); formData.append('file', logo);
await this.api.put(`/application-images/logo`, formData, { await this.api.put(`/application-images/logo`, formData, {
params: { light } params: { light }
@@ -39,6 +46,14 @@ export default class AppConfigService extends APIService {
cachedApplicationLogo.bustCache(light); cachedApplicationLogo.bustCache(light);
}; };
updateDefaultProfilePicture = async (defaultProfilePicture: File) => {
const formData = new FormData();
formData.append('file', defaultProfilePicture);
await this.api.put(`/application-images/default-profile-picture`, formData);
cachedDefaultProfilePicture.bustCache();
};
updateBackgroundImage = async (backgroundImage: File) => { updateBackgroundImage = async (backgroundImage: File) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', backgroundImage!); formData.append('file', backgroundImage!);
@@ -47,6 +62,12 @@ export default class AppConfigService extends APIService {
cachedBackgroundImage.bustCache(); cachedBackgroundImage.bustCache();
}; };
deleteDefaultProfilePicture = async () => {
await this.api.delete('/application-images/default-profile-picture');
cachedDefaultProfilePicture.bustCache();
cachedProfilePicture.bustCache(get(userStore)!.id);
};
sendTestEmail = async () => { sendTestEmail = async () => {
await this.api.post('/application-configuration/test-email'); await this.api.post('/application-configuration/test-email');
}; };

View File

@@ -20,6 +20,13 @@ export const cachedApplicationLogo: CachableImage = {
} }
}; };
export const cachedDefaultProfilePicture: CachableImage = {
getUrl: () =>
getCachedImageUrl(new URL('/api/application-images/default-profile-picture', window.location.origin)),
bustCache: () =>
bustImageCache(new URL('/api/application-images/default-profile-picture', window.location.origin))
};
export const cachedBackgroundImage: CachableImage = { export const cachedBackgroundImage: CachableImage = {
getUrl: () => getUrl: () =>
getCachedImageUrl(new URL('/api/application-images/background', window.location.origin)), getCachedImageUrl(new URL('/api/application-images/background', window.location.origin)),

View File

@@ -40,23 +40,40 @@
} }
async function updateImages( async function updateImages(
logoLight: File | null, logoLight: File | undefined,
logoDark: File | null, logoDark: File | undefined,
backgroundImage: File | null, defaultProfilePicture: File | null | undefined,
favicon: File | null backgroundImage: File | undefined,
favicon: File | undefined
) { ) {
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve(); const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
const lightLogoPromise = logoLight const lightLogoPromise = logoLight
? appConfigService.updateLogo(logoLight, true) ? appConfigService.updateLogo(logoLight, true)
: Promise.resolve(); : Promise.resolve();
const darkLogoPromise = logoDark const darkLogoPromise = logoDark
? appConfigService.updateLogo(logoDark, false) ? appConfigService.updateLogo(logoDark, false)
: Promise.resolve(); : Promise.resolve();
const defaultProfilePicturePromise =
defaultProfilePicture === null
? appConfigService.deleteDefaultProfilePicture()
: defaultProfilePicture
? appConfigService.updateDefaultProfilePicture(defaultProfilePicture)
: Promise.resolve();
const backgroundImagePromise = backgroundImage const backgroundImagePromise = backgroundImage
? appConfigService.updateBackgroundImage(backgroundImage) ? appConfigService.updateBackgroundImage(backgroundImage)
: Promise.resolve(); : Promise.resolve();
await Promise.all([lightLogoPromise, darkLogoPromise, backgroundImagePromise, faviconPromise]) await Promise.all([
lightLogoPromise,
darkLogoPromise,
defaultProfilePicturePromise,
backgroundImagePromise,
faviconPromise
])
.then(() => toast.success(m.images_updated_successfully())) .then(() => toast.success(m.images_updated_successfully()))
.catch(axiosErrorToast); .catch(axiosErrorToast);
} }

View File

@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import FileInput from '$lib/components/form/file-input.svelte'; import FileInput from '$lib/components/form/file-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { cn } from '$lib/utils/style'; import { cn } from '$lib/utils/style';
import { LucideUpload } from '@lucide/svelte'; import { LucideImageOff, LucideUpload, LucideX } from '@lucide/svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
let { let {
@@ -13,54 +14,98 @@
imageURL, imageURL,
accept = 'image/png, image/jpeg, image/svg+xml, image/gif, image/webp, image/avif, image/heic', accept = 'image/png, image/jpeg, image/svg+xml, image/gif, image/webp, image/avif, image/heic',
forceColorScheme, forceColorScheme,
isResetable = false,
isImageSet = $bindable(true),
...restProps ...restProps
}: HTMLAttributes<HTMLDivElement> & { }: HTMLAttributes<HTMLDivElement> & {
id: string; id: string;
imageClass: string; imageClass: string;
label: string; label: string;
image: File | null; image: File | null | undefined;
imageURL: string; imageURL: string;
forceColorScheme?: 'light' | 'dark'; forceColorScheme?: 'light' | 'dark';
accept?: string; accept?: string;
isResetable?: boolean;
isImageSet?: boolean;
} = $props(); } = $props();
let imageDataURL = $state(imageURL); let imageDataURL = $state(imageURL);
$effect(() => {
if (image) {
isImageSet = true;
}
});
function onImageChange(e: Event) { function onImageChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] || null; const file = (e.target as HTMLInputElement).files?.[0] || undefined;
if (!file) return; if (!file) return;
image = file; image = file;
imageDataURL = URL.createObjectURL(file); imageDataURL = URL.createObjectURL(file);
} }
function onReset() {
image = null;
imageDataURL = imageURL;
isImageSet = false;
}
</script> </script>
<div class="flex flex-col items-start md:flex-row md:items-center" {...restProps}> <div class="flex flex-col items-start md:flex-row md:items-center" {...restProps}>
<Label class="w-52" for={id}>{label}</Label> <Label class="w-52" for={id}>{label}</Label>
<FileInput {id} variant="secondary" {accept} onchange={onImageChange}> <FileInput {id} variant="secondary" {accept} onchange={onImageChange}>
<div <div
class={{ class={cn('group/image relative flex items-center rounded transition-colors', {
'group relative flex items-center rounded': true,
'bg-[#F5F5F5]': forceColorScheme === 'light', 'bg-[#F5F5F5]': forceColorScheme === 'light',
'bg-[#262626]': forceColorScheme === 'dark', 'bg-[#262626]': forceColorScheme === 'dark',
'bg-muted': !forceColorScheme 'bg-muted': !forceColorScheme
}} })}
> >
{#if !isImageSet}
<div
class={cn(
'flex h-full w-full items-center justify-center p-3 transition-opacity duration-200',
'group-hover/image:opacity-10 group-has-[button:hover]/image:opacity-100',
imageClass
)}
>
<LucideImageOff class="text-muted-foreground" />
</div>
{:else}
<img <img
class={cn( class={cn(
'h-full w-full rounded object-cover p-3 transition-opacity duration-200 group-hover:opacity-10', 'h-full w-full rounded object-cover p-3 transition-opacity duration-200',
'group-hover/image:opacity-10 group-has-[button:hover]/image:opacity-100',
imageClass imageClass
)} )}
src={imageDataURL} src={imageDataURL}
alt={label} alt={label}
onerror={() => (isImageSet = false)}
/> />
{/if}
<LucideUpload <LucideUpload
class={{ class={cn(
'absolute top-1/2 left-1/2 size-5 -translate-x-1/2 -translate-y-1/2 transform font-medium opacity-0 transition-opacity group-hover:opacity-100': true, 'absolute top-1/2 left-1/2 size-5 -translate-x-1/2 -translate-y-1/2 transform font-medium opacity-0 transition-opacity duration-200',
'group-hover/image:opacity-100 group-has-[button:hover]/image:opacity-0',
{
'text-black': forceColorScheme === 'light', 'text-black': forceColorScheme === 'light',
'text-white': forceColorScheme === 'dark' 'text-white': forceColorScheme === 'dark'
}} }
)}
/> />
{#if isResetable && isImageSet}
<Button
size="icon"
onclick={(e) => {
e.stopPropagation();
onReset();
}}
class="absolute -top-2 -right-2 size-6 rounded-full shadow-md"
>
<LucideX class="size-3" />
</Button>
{/if}
</div> </div>
</FileInput> </FileInput>
</div> </div>

View File

@@ -1,24 +1,32 @@
<script lang="ts"> <script lang="ts">
import Button from '$lib/components/ui/button/button.svelte'; import Button from '$lib/components/ui/button/button.svelte';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import { cachedApplicationLogo, cachedBackgroundImage } from '$lib/utils/cached-image-util'; import {
cachedApplicationLogo,
cachedBackgroundImage,
cachedDefaultProfilePicture
} from '$lib/utils/cached-image-util';
import ApplicationImage from './application-image.svelte'; import ApplicationImage from './application-image.svelte';
let { let {
callback callback
}: { }: {
callback: ( callback: (
logoLight: File | null, logoLight: File | undefined,
logoDark: File | null, logoDark: File | undefined,
backgroundImage: File | null, defaultProfilePicture: File | null | undefined,
favicon: File | null backgroundImage: File | undefined,
favicon: File | undefined
) => void; ) => void;
} = $props(); } = $props();
let logoLight = $state<File | null>(null); let logoLight = $state<File | undefined>();
let logoDark = $state<File | null>(null); let logoDark = $state<File | undefined>();
let backgroundImage = $state<File | null>(null); let defaultProfilePicture = $state<File | null | undefined>();
let favicon = $state<File | null>(null); let backgroundImage = $state<File | undefined>();
let favicon = $state<File | undefined>();
let defaultProfilePictureSet = $state(true);
</script> </script>
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">
@@ -46,6 +54,15 @@
imageURL={cachedApplicationLogo.getUrl(false)} imageURL={cachedApplicationLogo.getUrl(false)}
forceColorScheme="dark" forceColorScheme="dark"
/> />
<ApplicationImage
id="default-profile-picture"
imageClass="size-24"
label={m.default_profile_picture()}
isResetable
bind:image={defaultProfilePicture}
imageURL={cachedDefaultProfilePicture.getUrl()}
isImageSet={defaultProfilePictureSet}
/>
<ApplicationImage <ApplicationImage
id="background-image" id="background-image"
imageClass="h-[350px] max-w-[500px]" imageClass="h-[350px] max-w-[500px]"
@@ -55,7 +72,10 @@
/> />
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<Button class="mt-5" onclick={() => callback(logoLight, logoDark, backgroundImage, favicon)} <Button
class="mt-5"
usePromiseLoading
onclick={() => callback(logoLight, logoDark, defaultProfilePicture, backgroundImage, favicon)}
>{m.save()}</Button >{m.save()}</Button
> >
</div> </div>

View File

@@ -122,10 +122,13 @@ test('Update application images', async ({ page }) => {
await page.getByLabel('Favicon').setInputFiles('assets/w3-schools-favicon.ico'); await page.getByLabel('Favicon').setInputFiles('assets/w3-schools-favicon.ico');
await page.getByLabel('Light Mode Logo').setInputFiles('assets/pingvin-share-logo.png'); await page.getByLabel('Light Mode Logo').setInputFiles('assets/pingvin-share-logo.png');
await page.getByLabel('Dark Mode Logo').setInputFiles('assets/nextcloud-logo.png'); await page.getByLabel('Dark Mode Logo').setInputFiles('assets/nextcloud-logo.png');
await page.getByLabel('Default Profile Picture').setInputFiles('assets/pingvin-share-logo.png');
await page.getByLabel('Background Image').setInputFiles('assets/clouds.jpg'); await page.getByLabel('Background Image').setInputFiles('assets/clouds.jpg');
await page.getByRole('button', { name: 'Save' }).last().click(); await page.getByRole('button', { name: 'Save' }).last().click();
await expect(page.locator('[data-type="success"]')).toHaveText('Images updated successfully'); await expect(page.locator('[data-type="success"]')).toHaveText(
'Images updated successfully. It may take a few minutes to update.'
);
await page.request await page.request
.get('/api/application-images/favicon') .get('/api/application-images/favicon')