feat:profile pictures
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { Badge } from '$lib/components/badge';
|
||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||||
import { Avatar, AvatarFallback } from '$lib/components/avatar';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
|
||||
import { MoreHorizontal, MessageCircle, Share } from '@lucide/svelte';
|
||||
|
||||
interface FindCardProps {
|
||||
@@ -14,6 +14,7 @@
|
||||
locationName?: string;
|
||||
user: {
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
media?: Array<{
|
||||
type: string;
|
||||
@@ -52,6 +53,9 @@
|
||||
<div class="post-header">
|
||||
<div class="user-info">
|
||||
<Avatar class="avatar">
|
||||
{#if user.profilePictureUrl}
|
||||
<AvatarImage src={user.profilePictureUrl} alt={user.username} />
|
||||
{/if}
|
||||
<AvatarFallback class="avatar-fallback">
|
||||
{getUserInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||||
import { Avatar, AvatarFallback } from '$lib/components/avatar';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
|
||||
|
||||
interface Find {
|
||||
id: string;
|
||||
@@ -16,6 +16,7 @@
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
media?: Array<{
|
||||
type: string;
|
||||
@@ -112,6 +113,9 @@
|
||||
<SheetHeader class="sheet-header">
|
||||
<div class="user-section">
|
||||
<Avatar class="user-avatar">
|
||||
{#if find.user.profilePictureUrl}
|
||||
<AvatarImage src={find.user.profilePictureUrl} alt={find.user.username} />
|
||||
{/if}
|
||||
<AvatarFallback class="avatar-fallback">
|
||||
{find.user.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
locationName?: string;
|
||||
user: {
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
likeCount?: number;
|
||||
isLiked?: boolean;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
|
||||
let { user }: { user: User } = $props();
|
||||
@@ -13,7 +14,11 @@
|
||||
<div class="header-content">
|
||||
<h1 class="app-title">Serengo</h1>
|
||||
<div class="profile-container">
|
||||
<ProfilePanel username={user.username} id={user.id} />
|
||||
<ProfilePanel
|
||||
username={user.username}
|
||||
id={user.id}
|
||||
profilePictureUrl={user.profilePictureUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -7,19 +7,31 @@
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from './dropdown-menu';
|
||||
import { Avatar, AvatarFallback } from './avatar';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
|
||||
import { Skeleton } from './skeleton';
|
||||
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
id: string;
|
||||
profilePictureUrl?: string | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
let { username, id, loading = false }: Props = $props();
|
||||
let { username, id, profilePictureUrl, loading = false }: Props = $props();
|
||||
|
||||
let showProfilePictureSheet = $state(false);
|
||||
|
||||
// Get the first letter of username for avatar
|
||||
const initial = username.charAt(0).toUpperCase();
|
||||
|
||||
function openProfilePictureSheet() {
|
||||
showProfilePictureSheet = true;
|
||||
}
|
||||
|
||||
function closeProfilePictureSheet() {
|
||||
showProfilePictureSheet = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu>
|
||||
@@ -28,6 +40,9 @@
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
{:else}
|
||||
<Avatar class="profile-avatar">
|
||||
{#if profilePictureUrl}
|
||||
<AvatarImage src={profilePictureUrl} alt={username} />
|
||||
{/if}
|
||||
<AvatarFallback class="profile-avatar-fallback">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
@@ -65,6 +80,12 @@
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem class="profile-picture-item" onclick={openProfilePictureSheet}>
|
||||
Profile Picture
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div class="user-info-item">
|
||||
<span class="info-label">Username</span>
|
||||
<span class="info-value">{username}</span>
|
||||
@@ -86,6 +107,15 @@
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{#if showProfilePictureSheet}
|
||||
<ProfilePictureSheet
|
||||
userId={id}
|
||||
{username}
|
||||
{profilePictureUrl}
|
||||
onClose={closeProfilePictureSheet}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.profile-trigger) {
|
||||
background: none;
|
||||
@@ -156,6 +186,16 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
:global(.profile-picture-item) {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:global(.profile-picture-item:hover) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
:global(.logout-item) {
|
||||
padding: 0;
|
||||
margin: 4px;
|
||||
|
||||
273
src/lib/components/ProfilePictureSheet.svelte
Normal file
273
src/lib/components/ProfilePictureSheet.svelte
Normal file
@@ -0,0 +1,273 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
|
||||
import { Button } from './button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let { userId, username, profilePictureUrl, onClose }: Props = $props();
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let isUploading = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
let showModal = $state(true);
|
||||
|
||||
const initial = username.charAt(0).toUpperCase();
|
||||
|
||||
// Close modal when showModal changes to false
|
||||
$effect(() => {
|
||||
if (!showModal && onClose) {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) {
|
||||
const file = target.files[0];
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB max)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('File size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFile = file;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!selectedFile) return;
|
||||
|
||||
isUploading = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('userId', userId);
|
||||
|
||||
const response = await fetch('/api/profile-picture/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
selectedFile = null;
|
||||
if (fileInput) fileInput.value = '';
|
||||
await invalidateAll();
|
||||
showModal = false;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
alert('Failed to upload profile picture');
|
||||
} finally {
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!profilePictureUrl) return;
|
||||
|
||||
isDeleting = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/profile-picture/delete', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ userId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Delete failed');
|
||||
}
|
||||
|
||||
await invalidateAll();
|
||||
showModal = false;
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
alert('Failed to delete profile picture');
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
||||
<SheetContent side="bottom" class="profile-picture-sheet">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Profile Picture</SheetTitle>
|
||||
<SheetDescription>Upload, edit, or delete your profile picture</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div class="profile-picture-content">
|
||||
<div class="current-avatar">
|
||||
<Avatar class="large-avatar">
|
||||
{#if profilePictureUrl}
|
||||
<AvatarImage src={profilePictureUrl} alt={username} />
|
||||
{/if}
|
||||
<AvatarFallback class="large-avatar-fallback">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<div class="upload-section">
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onchange={handleFileSelect}
|
||||
class="file-input"
|
||||
/>
|
||||
|
||||
{#if selectedFile}
|
||||
<div class="selected-file">
|
||||
<p class="file-name">{selectedFile.name}</p>
|
||||
<p class="file-size">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="action-buttons">
|
||||
{#if selectedFile}
|
||||
<Button onclick={handleUpload} disabled={isUploading} class="upload-button">
|
||||
{isUploading ? 'Uploading...' : 'Upload Picture'}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if profilePictureUrl}
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
class="delete-button"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete Picture'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<style>
|
||||
:global(.profile-picture-sheet) {
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.profile-picture-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.current-avatar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.large-avatar) {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
:global(.large-avatar-fallback) {
|
||||
background: black;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 48px;
|
||||
border: 4px solid #fff;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.selected-file {
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
margin: 0 0 4px 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:global(.upload-button) {
|
||||
background: black;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.upload-button:hover) {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
:global(.delete-button) {
|
||||
padding: 12px 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
:global(.profile-picture-sheet) {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:global(.upload-button),
|
||||
:global(.delete-button) {
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user