This repository has been archived on 2025-08-19. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
coinstorge/website/src/routes/settings/+page.svelte
Face 16ad425bb5 feat: add username availability check API endpoint
feat: create user image retrieval API endpoint

feat: enhance coin page with dynamic data fetching and improved UI

feat: implement coin creation form with validation and submission logic

feat: add user settings page with profile update functionality
2025-05-23 16:26:02 +03:00

213 lines
6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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. 330 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>