214 lines
6 KiB
Svelte
214 lines
6 KiB
Svelte
|
|
<script lang="ts">
|
|||
|
|
import { invalidateAll } from '$app/navigation';
|
|||
|
|
import { getPublicUrl, debounce } from '$lib/utils';
|
|||
|
|
import { Button } from '$lib/components/ui/button';
|
|||
|
|
import { Input } from '$lib/components/ui/input';
|
|||
|
|
import { Label } from '$lib/components/ui/label';
|
|||
|
|
import { Textarea } from '$lib/components/ui/textarea';
|
|||
|
|
import * as Avatar from '$lib/components/ui/avatar';
|
|||
|
|
import * as Card from '$lib/components/ui/card';
|
|||
|
|
import { onMount, onDestroy } from 'svelte';
|
|||
|
|
import { CheckIcon } from 'lucide-svelte';
|
|||
|
|
import { toast } from 'svelte-sonner';
|
|||
|
|
import { MAX_FILE_SIZE } from '$lib/data/constants';
|
|||
|
|
|
|||
|
|
let { data } = $props();
|
|||
|
|
|
|||
|
|
let name = $state(data.user.name);
|
|||
|
|
let bio = $state(data.user.bio ?? '');
|
|||
|
|
let username = $state(data.user.username);
|
|||
|
|
|
|||
|
|
const initialUsername = data.user.username;
|
|||
|
|
let avatarFile: FileList | undefined = $state(undefined);
|
|||
|
|
|
|||
|
|
let previewUrl: string | null = $state(null);
|
|||
|
|
let currentAvatarUrl = $derived(previewUrl || getPublicUrl(data.user.image ?? null));
|
|||
|
|
|
|||
|
|
let isDirty = $derived(
|
|||
|
|
name !== data.user.name ||
|
|||
|
|
bio !== (data.user.bio ?? '') ||
|
|||
|
|
username !== data.user.username ||
|
|||
|
|
avatarFile !== undefined
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let fileInput: HTMLInputElement;
|
|||
|
|
|
|||
|
|
let loading = $state(false);
|
|||
|
|
let usernameAvailable: boolean | null = $state(null);
|
|||
|
|
let checkingUsername = $state(false);
|
|||
|
|
|
|||
|
|
function beforeUnloadHandler(e: BeforeUnloadEvent) {
|
|||
|
|
if (isDirty) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMount(() => {
|
|||
|
|
window.addEventListener('beforeunload', beforeUnloadHandler);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
onDestroy(() => {
|
|||
|
|
window.removeEventListener('beforeunload', beforeUnloadHandler);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function handleAvatarClick() {
|
|||
|
|
fileInput.click();
|
|||
|
|
}
|
|||
|
|
function handleAvatarChange(e: Event) {
|
|||
|
|
const f = (e.target as HTMLInputElement).files?.[0];
|
|||
|
|
if (f) {
|
|||
|
|
// Check file size
|
|||
|
|
if (f.size > MAX_FILE_SIZE) {
|
|||
|
|
toast.error('Profile picture must be smaller than 1MB');
|
|||
|
|
(e.target as HTMLInputElement).value = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check file type
|
|||
|
|
if (!f.type.startsWith('image/')) {
|
|||
|
|
toast.error('Please select a valid image file');
|
|||
|
|
(e.target as HTMLInputElement).value = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
previewUrl = URL.createObjectURL(f);
|
|||
|
|
const files = (e.target as HTMLInputElement).files;
|
|||
|
|
if (files) avatarFile = files;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const checkUsername = debounce(async (val: string) => {
|
|||
|
|
if (val.length < 3) return (usernameAvailable = null);
|
|||
|
|
checkingUsername = true;
|
|||
|
|
const res = await fetch(`/api/settings/check-username?username=${val}`);
|
|||
|
|
usernameAvailable = (await res.json()).available;
|
|||
|
|
checkingUsername = false;
|
|||
|
|
}, 500);
|
|||
|
|
|
|||
|
|
$effect(() => {
|
|||
|
|
if (username !== initialUsername) checkUsername(username);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
async function handleSubmit(e: Event) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
loading = true;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const fd = new FormData();
|
|||
|
|
fd.append('name', name);
|
|||
|
|
fd.append('bio', bio);
|
|||
|
|
fd.append('username', username);
|
|||
|
|
if (avatarFile?.[0]) fd.append('avatar', avatarFile[0]);
|
|||
|
|
|
|||
|
|
const res = await fetch('/api/settings', { method: 'POST', body: fd });
|
|||
|
|
|
|||
|
|
if (res.ok) {
|
|||
|
|
await invalidateAll();
|
|||
|
|
toast.success('Settings updated successfully!', {
|
|||
|
|
action: { label: 'Refresh', onClick: () => window.location.reload() }
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
const result = await res.json();
|
|||
|
|
toast.error('Failed to update settings', {
|
|||
|
|
description: result.message || 'An error occurred while updating your settings'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
toast.error('Failed to update settings', {
|
|||
|
|
description: 'An unexpected error occurred'
|
|||
|
|
});
|
|||
|
|
} finally {
|
|||
|
|
loading = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<div class="container mx-auto max-w-2xl p-6">
|
|||
|
|
<h1 class="mb-6 text-2xl font-bold">Settings</h1>
|
|||
|
|
<Card.Root>
|
|||
|
|
<Card.Header>
|
|||
|
|
<Card.Title>Profile Settings</Card.Title>
|
|||
|
|
<Card.Description>Update your profile information</Card.Description>
|
|||
|
|
</Card.Header>
|
|||
|
|
<Card.Content>
|
|||
|
|
<div class="mb-6 flex items-center gap-4">
|
|||
|
|
<div
|
|||
|
|
class="group relative cursor-pointer"
|
|||
|
|
role="button"
|
|||
|
|
tabindex="0"
|
|||
|
|
onclick={handleAvatarClick}
|
|||
|
|
onkeydown={(e) => e.key === 'Enter' && handleAvatarClick()}
|
|||
|
|
>
|
|||
|
|
<Avatar.Root class="size-20">
|
|||
|
|
<Avatar.Image src={currentAvatarUrl} alt={name} />
|
|||
|
|
<Avatar.Fallback>?</Avatar.Fallback>
|
|||
|
|
</Avatar.Root>
|
|||
|
|
<div
|
|||
|
|
class="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
|
|||
|
|
>
|
|||
|
|
<span class="text-xs text-white">Change</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<h3 class="text-lg font-semibold">{name}</h3>
|
|||
|
|
<p class="text-muted-foreground text-sm">@{username}</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<input
|
|||
|
|
type="file"
|
|||
|
|
accept="image/*"
|
|||
|
|
class="hidden"
|
|||
|
|
bind:this={fileInput}
|
|||
|
|
onchange={handleAvatarChange}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<form onsubmit={handleSubmit} class="space-y-4">
|
|||
|
|
<div class="space-y-2">
|
|||
|
|
<Label for="name">Display Name</Label>
|
|||
|
|
<Input id="name" bind:value={name} required />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="space-y-2">
|
|||
|
|
<Label for="username">Username</Label>
|
|||
|
|
<div class="relative">
|
|||
|
|
<span class="text-muted-foreground absolute left-3 top-4 -translate-y-1/2 transform"
|
|||
|
|
>@</span
|
|||
|
|
>
|
|||
|
|
<Input
|
|||
|
|
id="username"
|
|||
|
|
bind:value={username}
|
|||
|
|
required
|
|||
|
|
pattern={'^[a-zA-Z0-9_]{3,30}$'}
|
|||
|
|
class="pl-8"
|
|||
|
|
/>
|
|||
|
|
<div class="absolute right-3 top-1.5">
|
|||
|
|
{#if checkingUsername}
|
|||
|
|
<span class="text-muted-foreground text-sm">Checking…</span>
|
|||
|
|
{:else if username !== initialUsername}
|
|||
|
|
{#if usernameAvailable}
|
|||
|
|
<CheckIcon class="text-success" />
|
|||
|
|
{:else}
|
|||
|
|
<span class="text-destructive text-sm">Taken</span>
|
|||
|
|
{/if}
|
|||
|
|
{/if}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<p class="text-muted-foreground text-xs">
|
|||
|
|
Only letters, numbers, underscores. 3–30 characters.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="space-y-2">
|
|||
|
|
<Label for="bio">Bio</Label>
|
|||
|
|
<Textarea id="bio" bind:value={bio} rows={4} placeholder="Tell us about yourself" />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<Button type="submit" disabled={loading || !isDirty}>
|
|||
|
|
{loading ? 'Saving…' : 'Save Changes'}
|
|||
|
|
</Button>
|
|||
|
|
</form>
|
|||
|
|
</Card.Content>
|
|||
|
|
</Card.Root>
|
|||
|
|
</div>
|