260 lines
9.3 KiB
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>
|