desker/apps/web/src/routes/dashboard/profile/+page.svelte

260 lines
9.3 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { user } from '$lib/stores/auth';
import { apiFetch } from '$lib/api/client';
import Avatar from '$lib/components/Avatar.svelte';
import { User, Mail, Shield, Briefcase, Save, Key, UserCircle } from 'lucide-svelte';
let name = $user?.name || '';
let avatarUrl = $user?.avatarUrl || '';
let password = '';
let confirmPassword = '';
let loading = false;
let uploadingAvatar = false;
let message = '';
let error = '';
let fileInput: HTMLInputElement;
onMount(async () => {
// Refresh user data from server
try {
const freshUser: any = await apiFetch('/users/profile');
user.set(freshUser);
name = freshUser.name || '';
avatarUrl = freshUser.avatarUrl || '';
} catch (e) {
console.error('Failed to fetch profile', e);
}
});
async function handleUpdateProfile() {
loading = true;
message = '';
error = '';
try {
const updateData: any = { name: name || null, avatarUrl: avatarUrl || null };
if (password) {
if (password !== confirmPassword) {
throw new Error('Passwords do not match');
}
if (password.length < 6) {
throw new Error('Password must be at least 6 characters');
}
updateData.password = password;
}
const updatedUser: any = await apiFetch('/users/profile', {
method: 'PATCH',
body: JSON.stringify(updateData)
});
user.set(updatedUser);
password = '';
confirmPassword = '';
message = 'Profile updated successfully!';
} catch (e: any) {
error = e.message || 'Failed to update profile';
} finally {
loading = false;
}
}
async function handleAvatarUpload(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const file = input.files[0];
const formData = new FormData();
formData.append('file', file);
uploadingAvatar = true;
error = '';
message = '';
try {
const updatedUser: any = await apiFetch('/assets/user/avatar', {
method: 'POST',
body: formData
});
user.set(updatedUser);
avatarUrl = updatedUser.avatarUrl || '';
message = 'Avatar uploaded successfully!';
} catch (e: any) {
error = e.message || 'Failed to upload avatar';
} finally {
uploadingAvatar = false;
// Reset input
if (fileInput) fileInput.value = '';
}
}
</script>
<div class="max-w-4xl mx-auto p-6">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">My Profile</h1>
<p class="text-gray-500 mt-2">Manage your account information and preferences.</p>
</div>
{#if message}
<div class="mb-6 p-4 bg-green-50 border border-green-200 text-green-700 rounded-lg flex items-center gap-3">
<div class="bg-green-100 p-1 rounded-full">
<Save size={16} />
</div>
{message}
</div>
{/if}
{#if error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg flex items-center gap-3">
<div class="bg-red-100 p-1 rounded-full">
<Shield size={16} />
</div>
{error}
</div>
{/if}
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Profile Sidebar -->
<div class="md:col-span-1">
<div class="bg-white rounded-xl shadow-sm border p-6 flex flex-col items-center text-center">
<div class="relative group">
<Avatar user={$user} size="lg" className="mb-4 ring-4 ring-blue-50" />
<button
on:click={() => fileInput.click()}
disabled={uploadingAvatar}
class="absolute inset-0 mb-4 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity cursor-pointer disabled:cursor-not-allowed"
>
{#if uploadingAvatar}
<div class="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
{:else}
<UserCircle size={32} class="text-white" />
{/if}
</button>
<input
type="file"
bind:this={fileInput}
on:change={handleAvatarUpload}
accept="image/jpeg,image/png,image/webp"
class="hidden"
/>
</div>
<h2 class="text-xl font-bold text-gray-900">{$user?.name || 'No Name Set'}</h2>
<p class="text-gray-500 text-sm mb-4">{$user?.email}</p>
<div class="w-full border-t pt-4 mt-2 space-y-3">
<div class="flex items-center gap-3 text-sm text-gray-600">
<Shield size={16} class="text-gray-400" />
<span class="capitalize">Role: {$user?.role}</span>
</div>
<div class="flex items-center gap-3 text-sm text-gray-600 text-left">
<UserCircle size={16} class="text-gray-400 shrink-0" />
<span class="truncate">ID: {$user?.id}</span>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="md:col-span-2 space-y-6">
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<div class="p-6 border-b bg-gray-50">
<h3 class="font-bold text-gray-900 flex items-center gap-2">
<User size={18} class="text-blue-600" />
Personal Information
</h3>
</div>
<form on:submit|preventDefault={handleUpdateProfile} class="p-6 space-y-4">
<div class="grid grid-cols-1 gap-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
<input
id="name"
type="text"
bind:value={name}
class="w-full p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
placeholder="Your full name"
/>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-500 mb-1">Email (Read-only)</label>
<div class="flex items-center gap-2 p-2 bg-gray-50 border rounded-lg text-gray-500">
<Mail size={16} />
<span>{$user?.email}</span>
</div>
</div>
<div>
<label for="avatarUrl" class="block text-sm font-medium text-gray-700 mb-1">Profile Picture URL</label>
<div class="flex gap-2">
<input
id="avatarUrl"
type="text"
bind:value={avatarUrl}
class="flex-1 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all font-mono text-sm"
placeholder="https://example.com/photo.jpg"
/>
<button
type="button"
on:click={() => fileInput.click()}
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-all flex items-center gap-2"
>
<UserCircle size={18} />
Upload
</button>
</div>
<p class="text-[10px] text-gray-500 mt-1">Provide a URL or upload a file. Leave empty to use Gravatar/initials.</p>
</div>
</div>
<div class="pt-6 border-t mt-6">
<h3 class="font-bold text-gray-900 flex items-center gap-2 mb-4">
<Key size={18} class="text-blue-600" />
Security
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">New Password</label>
<input
id="password"
type="password"
bind:value={password}
class="w-full p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
placeholder="••••••••"
/>
</div>
<div>
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 mb-1">Confirm New Password</label>
<input
id="confirmPassword"
type="password"
bind:value={confirmPassword}
class="w-full p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
placeholder="••••••••"
/>
</div>
</div>
<p class="text-[10px] text-gray-500 mt-1">Leave blank if you don't want to change your password.</p>
</div>
<div class="pt-6 mt-6 flex justify-end">
<button
type="submit"
disabled={loading}
class="bg-blue-600 text-white px-6 py-2 rounded-lg font-bold hover:bg-blue-700 transition-all disabled:opacity-50 flex items-center gap-2"
>
{#if loading}
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Saving...
{:else}
<Save size={18} />
Save Changes
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</div>