mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-22 09:15:41 +03:00
feat: add ability to upload a profile picture (#244)
This commit is contained in:
116
frontend/src/lib/components/form/auto-complete-input.svelte
Normal file
116
frontend/src/lib/components/form/auto-complete-input.svelte
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
placeholder,
|
||||
suggestionLimit = 5,
|
||||
suggestions
|
||||
}: {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
suggestionLimit?: number;
|
||||
suggestions: string[];
|
||||
} = $props();
|
||||
|
||||
let filteredSuggestions: string[] = $state(suggestions.slice(0, suggestionLimit));
|
||||
let selectedIndex = $state(-1);
|
||||
let keyError: string | undefined = $state();
|
||||
|
||||
let isInputFocused = $state(false);
|
||||
|
||||
function handleSuggestionClick(suggestion: (typeof suggestions)[0]) {
|
||||
value = suggestion;
|
||||
filteredSuggestions = [];
|
||||
}
|
||||
|
||||
function handleOnInput() {
|
||||
if (value.length > 0 && !/^[A-Za-z0-9]*$/.test(value)) {
|
||||
keyError = 'Only alphanumeric characters are allowed';
|
||||
return;
|
||||
} else {
|
||||
keyError = undefined;
|
||||
}
|
||||
|
||||
filteredSuggestions = suggestions
|
||||
.filter((s) => s.includes(value.toLowerCase()))
|
||||
.slice(0, suggestionLimit);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!isOpen) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
selectedIndex = Math.min(selectedIndex + 1, filteredSuggestions.length - 1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
selectedIndex = Math.max(selectedIndex - 1, -1);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (selectedIndex >= 0) {
|
||||
handleSuggestionClick(filteredSuggestions[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
isInputFocused = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let isOpen = $derived(filteredSuggestions.length > 0 && isInputFocused);
|
||||
|
||||
$effect(() => {
|
||||
// Reset selection when suggestions change
|
||||
if (filteredSuggestions) {
|
||||
selectedIndex = -1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="grid w-full"
|
||||
role="combobox"
|
||||
onkeydown={handleKeydown}
|
||||
aria-controls="suggestion-list"
|
||||
aria-expanded={isOpen}
|
||||
tabindex="-1"
|
||||
>
|
||||
<Input
|
||||
{placeholder}
|
||||
bind:value
|
||||
oninput={handleOnInput}
|
||||
onfocus={() => (isInputFocused = true)}
|
||||
onblur={() => (isInputFocused = false)}
|
||||
/>
|
||||
{#if keyError}
|
||||
<p class="mt-1 text-sm text-red-500">{keyError}</p>
|
||||
{/if}
|
||||
<Popover.Root
|
||||
open={isOpen}
|
||||
disableFocusTrap
|
||||
openFocus={() => {}}
|
||||
closeOnOutsideClick={false}
|
||||
closeOnEscape={false}
|
||||
>
|
||||
<Popover.Trigger tabindex={-1} class="h-0 w-full" aria-hidden />
|
||||
<Popover.Content class="p-0" sideOffset={5} sameWidth>
|
||||
{#each filteredSuggestions as suggestion, index}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onmousedown={() => handleSuggestionClick(suggestion)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') handleSuggestionClick(suggestion);
|
||||
}}
|
||||
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 {selectedIndex ===
|
||||
index
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''}"
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
{/each}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
39
frontend/src/lib/components/form/checkbox-with-label.svelte
Normal file
39
frontend/src/lib/components/form/checkbox-with-label.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
let {
|
||||
id,
|
||||
checked = $bindable(),
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
onCheckedChange
|
||||
}: {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="items-top flex space-x-2">
|
||||
<Checkbox
|
||||
{id}
|
||||
{disabled}
|
||||
onCheckedChange={(v) => onCheckedChange && onCheckedChange(v == true)}
|
||||
bind:checked
|
||||
/>
|
||||
<div class="grid gap-1.5 leading-none">
|
||||
<Label for={id} class="mb-0 text-sm font-medium leading-none">
|
||||
{label}
|
||||
</Label>
|
||||
{#if description}
|
||||
<p class="text-muted-foreground text-[0.8rem]">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
75
frontend/src/lib/components/form/custom-claims-input.svelte
Normal file
75
frontend/src/lib/components/form/custom-claims-input.svelte
Normal file
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||
import type { CustomClaim } from '$lib/types/custom-claim.type';
|
||||
import { LucideMinus, LucidePlus } from 'lucide-svelte';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import AutoCompleteInput from './auto-complete-input.svelte';
|
||||
|
||||
let {
|
||||
customClaims = $bindable(),
|
||||
error = $bindable(null),
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
customClaims: CustomClaim[];
|
||||
error?: string | null;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const limit = 20;
|
||||
|
||||
const customClaimService = new CustomClaimService();
|
||||
|
||||
let suggestions: string[] = $state([]);
|
||||
let filteredSuggestions: string[] = $derived(
|
||||
suggestions.filter(
|
||||
(suggestion) => !customClaims.some((customClaim) => customClaim.key === suggestion)
|
||||
)
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
customClaimService.getSuggestions().then((data) => (suggestions = data));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div {...restProps}>
|
||||
<FormInput>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
{#each customClaims as _, i}
|
||||
<div class="flex gap-x-2">
|
||||
<AutoCompleteInput
|
||||
placeholder="Key"
|
||||
suggestions={filteredSuggestions}
|
||||
bind:value={customClaims[i].key}
|
||||
/>
|
||||
<Input placeholder="Value" bind:value={customClaims[i].value} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Remove custom claim"
|
||||
on:click={() => (customClaims = customClaims.filter((_, index) => index !== i))}
|
||||
>
|
||||
<LucideMinus class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</FormInput>
|
||||
{#if error}
|
||||
<p class="mt-1 text-sm text-red-500">{error}</p>
|
||||
{/if}
|
||||
{#if customClaims.length < limit}
|
||||
<Button
|
||||
class="mt-2"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
on:click={() => (customClaims = [...customClaims, { key: '', value: '' }])}
|
||||
>
|
||||
<LucidePlus class="mr-1 h-4 w-4" />
|
||||
{customClaims.length === 0 ? 'Add custom claim' : 'Add another'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
27
frontend/src/lib/components/form/file-input.svelte
Normal file
27
frontend/src/lib/components/form/file-input.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils/style';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
import type { VariantProps } from 'tailwind-variants';
|
||||
import type { buttonVariants } from '$lib/components/ui/button';
|
||||
|
||||
let {
|
||||
id,
|
||||
...restProps
|
||||
}: HTMLInputAttributes & {
|
||||
id: string;
|
||||
variant?: VariantProps<typeof buttonVariants>['variant'];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => document.getElementById(id)?.click()}
|
||||
class={cn(restProps.class)}
|
||||
>
|
||||
{#if restProps.children}
|
||||
{@render restProps.children()}
|
||||
{:else}
|
||||
Select File
|
||||
{/if}
|
||||
</button>
|
||||
<input {id} {...restProps} type="file" class="hidden" />
|
||||
56
frontend/src/lib/components/form/form-input.svelte
Normal file
56
frontend/src/lib/components/form/form-input.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import type { FormInput } from '$lib/utils/form-util';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { Input, type FormInputEvent } from '$lib/components/ui/input';
|
||||
|
||||
let {
|
||||
input = $bindable(),
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
type = 'text',
|
||||
children,
|
||||
onInput,
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
input?: FormInput<string | boolean | number>;
|
||||
label?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
|
||||
onInput?: (e: FormInputEvent) => void;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const id = label?.toLowerCase().replace(/ /g, '-');
|
||||
</script>
|
||||
|
||||
<div {...restProps}>
|
||||
{#if label}
|
||||
<Label class="mb-0" for={id}>{label}</Label>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
<div class={label || description ? 'mt-2' : ''}>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else if input}
|
||||
<Input
|
||||
{id}
|
||||
{placeholder}
|
||||
{type}
|
||||
bind:value={input.value}
|
||||
{disabled}
|
||||
on:input={(e) => onInput?.(e)}
|
||||
/>
|
||||
{/if}
|
||||
{#if input?.error}
|
||||
<p class="mt-1 text-sm text-red-500">{input.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import FileInput from '$lib/components/form/file-input.svelte';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import { LucideLoader, LucideUpload } from 'lucide-svelte';
|
||||
|
||||
let {
|
||||
userId,
|
||||
isLdapUser = false,
|
||||
callback
|
||||
}: {
|
||||
userId: string;
|
||||
isLdapUser?: boolean;
|
||||
callback: (image: File) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
let imageDataURL = $state(`/api/users/${userId}/profile-picture.png`);
|
||||
|
||||
async function onImageChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0] || null;
|
||||
if (!file) return;
|
||||
|
||||
isLoading = true;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
imageDataURL = event.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
await callback(file).catch(() => {
|
||||
imageDataURL = `/api/users/${userId}/profile-picture.png`;
|
||||
});
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex gap-5">
|
||||
<div class="flex w-full flex-col justify-between gap-5 sm:flex-row">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold">Profile Picture</h3>
|
||||
{#if isLdapUser}
|
||||
<p class="text-muted-foreground mt-1 text-sm">
|
||||
The profile picture is managed by the LDAP server and cannot be changed here.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground mt-1 text-sm">
|
||||
Click on the profile picture to upload a custom one from your files.
|
||||
</p>
|
||||
<p class="text-muted-foreground mt-1 text-sm">The image should be in PNG or JPEG format.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isLdapUser}
|
||||
<Avatar.Root class="h-24 w-24">
|
||||
<Avatar.Image class="object-cover" src={imageDataURL} />
|
||||
</Avatar.Root>
|
||||
{:else}
|
||||
<FileInput
|
||||
id="profile-picture-input"
|
||||
variant="secondary"
|
||||
accept="image/png, image/jpeg"
|
||||
onchange={onImageChange}
|
||||
>
|
||||
<div class="group relative h-28 w-28 rounded-full">
|
||||
<Avatar.Root class="h-full w-full transition-opacity duration-200">
|
||||
<Avatar.Image
|
||||
class="object-cover group-hover:opacity-10 {isLoading ? 'opacity-10' : ''}"
|
||||
src={imageDataURL}
|
||||
/>
|
||||
</Avatar.Root>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
{#if isLoading}
|
||||
<LucideLoader class="h-5 w-5 animate-spin" />
|
||||
{:else}
|
||||
<LucideUpload class="h-5 w-5 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</FileInput>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user