1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-04 15:04:43 +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

@@ -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"
}

View File

@@ -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}

View File

@@ -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');
};

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 = {
getUrl: () =>
getCachedImageUrl(new URL('/api/application-images/background', window.location.origin)),

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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>