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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user