mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-06 09:13:19 +03: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:
@@ -62,7 +62,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
|
||||
}
|
||||
|
||||
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.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
||||
|
||||
|
||||
@@ -388,3 +388,13 @@ func (e *UserEmailNotSetError) Error() string {
|
||||
func (e *UserEmailNotSetError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
type ImageNotFoundError struct{}
|
||||
|
||||
func (e *ImageNotFoundError) Error() string {
|
||||
return "Image not found"
|
||||
}
|
||||
|
||||
func (e *ImageNotFoundError) HttpStatusCode() int {
|
||||
return http.StatusNotFound
|
||||
}
|
||||
|
||||
@@ -25,10 +25,14 @@ func NewAppImagesController(
|
||||
group.GET("/application-images/logo", controller.getLogoHandler)
|
||||
group.GET("/application-images/background", controller.getBackgroundImageHandler)
|
||||
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/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
|
||||
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 {
|
||||
@@ -78,6 +82,18 @@ func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
|
||||
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
|
||||
// @Summary Update 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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (s *AppImagesService) UpdateImage(file *multipart.FileHeader, imageName str
|
||||
|
||||
currentExt, ok := s.extensions[imageName]
|
||||
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))
|
||||
@@ -69,13 +69,39 @@ func (s *AppImagesService) UpdateImage(file *multipart.FileHeader, imageName str
|
||||
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) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
ext, ok := s.extensions[name]
|
||||
if !ok || ext == "" {
|
||||
return "", fmt.Errorf("unknown application image '%s'", name)
|
||||
return "", &common.ImageNotFoundError{}
|
||||
}
|
||||
|
||||
return strings.ToLower(ext), nil
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,9 +34,10 @@ type UserService struct {
|
||||
emailService *EmailService
|
||||
appConfigService *AppConfigService
|
||||
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{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
@@ -43,6 +45,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
|
||||
emailService: emailService,
|
||||
appConfigService: appConfigService,
|
||||
customClaimService: customClaimService,
|
||||
appImagesService: appImagesService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,39 +90,42 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Check if we have a cached default picture for these initials
|
||||
defaultProfilePicturesDir := common.EnvConfig.UploadPath + "/profile-pictures/defaults/"
|
||||
defaultPicturePath := defaultProfilePicturesDir + user.Initials() + ".png"
|
||||
file, err = os.Open(defaultPicturePath)
|
||||
if err == nil {
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
return file, fileInfo.Size(), nil
|
||||
profilePicturePath := filepath.Join(common.EnvConfig.UploadPath, "profile-pictures", userID+".png")
|
||||
|
||||
// Try custom profile picture
|
||||
if file, size, err := utils.OpenFileWithSize(profilePicturePath); err == nil {
|
||||
return file, size, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// If no cached default picture exists, create one and save it for future use
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
if err != nil {
|
||||
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)
|
||||
defaultPictureBytes := defaultPicture.Bytes()
|
||||
go func() {
|
||||
// Ensure the directory exists
|
||||
errInternal := os.MkdirAll(defaultProfilePicturesDir, os.ModePerm)
|
||||
if errInternal != nil {
|
||||
slog.Error("Failed to create directory for default profile picture", slog.Any("error", errInternal))
|
||||
if err := os.MkdirAll(defaultProfilePicturesDir, os.ModePerm); err != nil {
|
||||
slog.Error("Failed to create directory for default profile picture", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
errInternal = utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath)
|
||||
if errInternal != nil {
|
||||
slog.Error("Failed to cache default profile picture for initials", slog.String("initials", user.Initials()), slog.Any("error", errInternal))
|
||||
if err := utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath); err != nil {
|
||||
slog.Error("Failed to cache default profile picture", slog.String("initials", user.Initials()), slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
|
||||
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) {
|
||||
|
||||
@@ -239,3 +239,17 @@ func IsWritableDir(dir string) (bool, error) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
"last_used": "Last Used",
|
||||
"actions": "Actions",
|
||||
"images_updated_successfully": "Images updated successfully",
|
||||
"images_updated_successfully": "Images updated successfully. It may take a few minutes to update.",
|
||||
"general": "General",
|
||||
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
|
||||
"ldap": "LDAP",
|
||||
@@ -459,7 +459,8 @@
|
||||
"view": "View",
|
||||
"toggle_columns": "Toggle columns",
|
||||
"locale": "Locale",
|
||||
"ldap_id" : "LDAP ID",
|
||||
"ldap_id": "LDAP ID",
|
||||
"reauthentication": "Re-authentication",
|
||||
"clear_filters" : "Clear Filters"
|
||||
"clear_filters": "Clear Filters",
|
||||
"default_profile_picture": "Default Profile Picture"
|
||||
}
|
||||
|
||||
@@ -55,9 +55,13 @@
|
||||
disabled,
|
||||
isLoading = false,
|
||||
autofocus = false,
|
||||
onclick,
|
||||
usePromiseLoading = false,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
}: ButtonProps & {
|
||||
usePromiseLoading?: boolean;
|
||||
} = $props();
|
||||
|
||||
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
|
||||
@@ -66,6 +70,19 @@
|
||||
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>
|
||||
|
||||
{#if href}
|
||||
@@ -91,6 +108,7 @@
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
disabled={disabled || isLoading}
|
||||
onclick={handleOnClick}
|
||||
{...restProps}
|
||||
>
|
||||
{#if isLoading}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import userStore from '$lib/stores/user-store';
|
||||
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';
|
||||
|
||||
export default class AppConfigService extends APIService {
|
||||
@@ -24,14 +31,14 @@ export default class AppConfigService extends APIService {
|
||||
|
||||
updateFavicon = async (favicon: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', favicon!);
|
||||
formData.append('file', favicon);
|
||||
|
||||
await this.api.put(`/application-images/favicon`, formData);
|
||||
}
|
||||
};
|
||||
|
||||
updateLogo = async (logo: File, light = true) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', logo!);
|
||||
formData.append('file', logo);
|
||||
|
||||
await this.api.put(`/application-images/logo`, formData, {
|
||||
params: { light }
|
||||
@@ -39,6 +46,14 @@ export default class AppConfigService extends APIService {
|
||||
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) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', backgroundImage!);
|
||||
@@ -47,6 +62,12 @@ export default class AppConfigService extends APIService {
|
||||
cachedBackgroundImage.bustCache();
|
||||
};
|
||||
|
||||
deleteDefaultProfilePicture = async () => {
|
||||
await this.api.delete('/application-images/default-profile-picture');
|
||||
cachedDefaultProfilePicture.bustCache();
|
||||
cachedProfilePicture.bustCache(get(userStore)!.id);
|
||||
};
|
||||
|
||||
sendTestEmail = async () => {
|
||||
await this.api.post('/application-configuration/test-email');
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
getUrl: () =>
|
||||
getCachedImageUrl(new URL('/api/application-images/background', window.location.origin)),
|
||||
|
||||
@@ -40,23 +40,40 @@
|
||||
}
|
||||
|
||||
async function updateImages(
|
||||
logoLight: File | null,
|
||||
logoDark: File | null,
|
||||
backgroundImage: File | null,
|
||||
favicon: File | null
|
||||
logoLight: File | undefined,
|
||||
logoDark: File | undefined,
|
||||
defaultProfilePicture: File | null | undefined,
|
||||
backgroundImage: File | undefined,
|
||||
favicon: File | undefined
|
||||
) {
|
||||
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
|
||||
|
||||
const lightLogoPromise = logoLight
|
||||
? appConfigService.updateLogo(logoLight, true)
|
||||
: Promise.resolve();
|
||||
|
||||
const darkLogoPromise = logoDark
|
||||
? appConfigService.updateLogo(logoDark, false)
|
||||
: Promise.resolve();
|
||||
|
||||
const defaultProfilePicturePromise =
|
||||
defaultProfilePicture === null
|
||||
? appConfigService.deleteDefaultProfilePicture()
|
||||
: defaultProfilePicture
|
||||
? appConfigService.updateDefaultProfilePicture(defaultProfilePicture)
|
||||
: Promise.resolve();
|
||||
|
||||
const backgroundImagePromise = backgroundImage
|
||||
? appConfigService.updateBackgroundImage(backgroundImage)
|
||||
: Promise.resolve();
|
||||
|
||||
await Promise.all([lightLogoPromise, darkLogoPromise, backgroundImagePromise, faviconPromise])
|
||||
await Promise.all([
|
||||
lightLogoPromise,
|
||||
darkLogoPromise,
|
||||
defaultProfilePicturePromise,
|
||||
backgroundImagePromise,
|
||||
faviconPromise
|
||||
])
|
||||
.then(() => toast.success(m.images_updated_successfully()))
|
||||
.catch(axiosErrorToast);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import FileInput from '$lib/components/form/file-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { LucideUpload } from '@lucide/svelte';
|
||||
import { LucideImageOff, LucideUpload, LucideX } from '@lucide/svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
@@ -13,54 +14,98 @@
|
||||
imageURL,
|
||||
accept = 'image/png, image/jpeg, image/svg+xml, image/gif, image/webp, image/avif, image/heic',
|
||||
forceColorScheme,
|
||||
isResetable = false,
|
||||
isImageSet = $bindable(true),
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
id: string;
|
||||
imageClass: string;
|
||||
label: string;
|
||||
image: File | null;
|
||||
image: File | null | undefined;
|
||||
imageURL: string;
|
||||
forceColorScheme?: 'light' | 'dark';
|
||||
accept?: string;
|
||||
isResetable?: boolean;
|
||||
isImageSet?: boolean;
|
||||
} = $props();
|
||||
|
||||
let imageDataURL = $state(imageURL);
|
||||
|
||||
$effect(() => {
|
||||
if (image) {
|
||||
isImageSet = true;
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
image = file;
|
||||
imageDataURL = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
image = null;
|
||||
imageDataURL = imageURL;
|
||||
isImageSet = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-start md:flex-row md:items-center" {...restProps}>
|
||||
<Label class="w-52" for={id}>{label}</Label>
|
||||
<FileInput {id} variant="secondary" {accept} onchange={onImageChange}>
|
||||
<div
|
||||
class={{
|
||||
'group relative flex items-center rounded': true,
|
||||
class={cn('group/image relative flex items-center rounded transition-colors', {
|
||||
'bg-[#F5F5F5]': forceColorScheme === 'light',
|
||||
'bg-[#262626]': forceColorScheme === 'dark',
|
||||
'bg-muted': !forceColorScheme
|
||||
}}
|
||||
})}
|
||||
>
|
||||
<img
|
||||
class={cn(
|
||||
'h-full w-full rounded object-cover p-3 transition-opacity duration-200 group-hover:opacity-10',
|
||||
imageClass
|
||||
)}
|
||||
src={imageDataURL}
|
||||
alt={label}
|
||||
/>
|
||||
{#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
|
||||
class={cn(
|
||||
'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
|
||||
)}
|
||||
src={imageDataURL}
|
||||
alt={label}
|
||||
onerror={() => (isImageSet = false)}
|
||||
/>
|
||||
{/if}
|
||||
<LucideUpload
|
||||
class={{
|
||||
'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,
|
||||
'text-black': forceColorScheme === 'light',
|
||||
'text-white': forceColorScheme === 'dark'
|
||||
}}
|
||||
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 duration-200',
|
||||
'group-hover/image:opacity-100 group-has-[button:hover]/image:opacity-0',
|
||||
{
|
||||
'text-black': forceColorScheme === 'light',
|
||||
'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>
|
||||
</FileInput>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
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';
|
||||
|
||||
let {
|
||||
callback
|
||||
}: {
|
||||
callback: (
|
||||
logoLight: File | null,
|
||||
logoDark: File | null,
|
||||
backgroundImage: File | null,
|
||||
favicon: File | null
|
||||
logoLight: File | undefined,
|
||||
logoDark: File | undefined,
|
||||
defaultProfilePicture: File | null | undefined,
|
||||
backgroundImage: File | undefined,
|
||||
favicon: File | undefined
|
||||
) => void;
|
||||
} = $props();
|
||||
|
||||
let logoLight = $state<File | null>(null);
|
||||
let logoDark = $state<File | null>(null);
|
||||
let backgroundImage = $state<File | null>(null);
|
||||
let favicon = $state<File | null>(null);
|
||||
let logoLight = $state<File | undefined>();
|
||||
let logoDark = $state<File | undefined>();
|
||||
let defaultProfilePicture = $state<File | null | undefined>();
|
||||
let backgroundImage = $state<File | undefined>();
|
||||
let favicon = $state<File | undefined>();
|
||||
|
||||
let defaultProfilePictureSet = $state(true);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
@@ -46,6 +54,15 @@
|
||||
imageURL={cachedApplicationLogo.getUrl(false)}
|
||||
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
|
||||
id="background-image"
|
||||
imageClass="h-[350px] max-w-[500px]"
|
||||
@@ -55,7 +72,10 @@
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -122,10 +122,13 @@ test('Update application images', async ({ page }) => {
|
||||
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('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.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
|
||||
.get('/api/application-images/favicon')
|
||||
|
||||
Reference in New Issue
Block a user