feat:profile pictures

This commit is contained in:
2025-10-16 18:08:03 +02:00
parent bee03a57ec
commit e54c4fb98e
14 changed files with 559 additions and 11 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -9,6 +9,7 @@
locationName?: string;
user: {
username: string;
profilePictureUrl?: string | null;
};
likeCount?: number;
isLiked?: boolean;

View File

@@ -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>

View File

@@ -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;

View 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>

View File

@@ -3,6 +3,7 @@ export { default as Input } from './components/Input.svelte';
export { default as Button } from './components/Button.svelte';
export { default as ErrorMessage } from './components/ErrorMessage.svelte';
export { default as ProfilePanel } from './components/ProfilePanel.svelte';
export { default as ProfilePictureSheet } from './components/ProfilePictureSheet.svelte';
export { default as Header } from './components/Header.svelte';
export { default as Modal } from './components/Modal.svelte';
export { default as Map } from './components/Map.svelte';

View File

@@ -4,6 +4,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
import { getSignedR2Url } from '$lib/server/r2';
const DAY_IN_MS = 1000 * 60 * 60 * 24;
@@ -31,7 +32,11 @@ export async function validateSessionToken(token: string) {
const [result] = await db
.select({
// Adjust user table here to tweak returned data
user: { id: table.user.id, username: table.user.username },
user: {
id: table.user.id,
username: table.user.username,
profilePictureUrl: table.user.profilePictureUrl
},
session: table.session
})
.from(table.session)
@@ -58,7 +63,25 @@ export async function validateSessionToken(token: string) {
.where(eq(table.session.id, session.id));
}
return { session, user };
// Generate signed URL for profile picture if it exists
let profilePictureUrl = user.profilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
// It's a path, generate signed URL
try {
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); // 24 hours
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
}
return {
session,
user: {
...user,
profilePictureUrl
}
};
}
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
@@ -95,7 +118,8 @@ export async function createUser(googleId: string, name: string) {
username: name,
googleId,
passwordHash: null,
age: null
age: null,
profilePictureUrl: null
};
await db.insert(table.user).values(user);
return user;

View File

@@ -5,7 +5,8 @@ export const user = pgTable('user', {
age: integer('age'),
username: text('username').notNull().unique(),
passwordHash: text('password_hash'),
googleId: text('google_id').unique()
googleId: text('google_id').unique(),
profilePictureUrl: text('profile_picture_url')
});
export const session = pgTable('session', {

View File

@@ -123,3 +123,85 @@ export async function processAndUploadVideo(
fallbackThumbnailUrl: thumbnailUrl
};
}
export async function processAndUploadProfilePicture(
file: File,
userId: string
): Promise<{
url: string;
thumbnailUrl: string;
fallbackUrl?: string;
fallbackThumbnailUrl?: string;
}> {
const buffer = Buffer.from(await file.arrayBuffer());
// Generate unique filename
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 15);
const filename = `users/${userId}/profile-${timestamp}-${randomId}`;
// Process full-size image in WebP format (with JPEG fallback)
const processedWebP = await sharp(buffer)
.resize(400, 400, {
fit: 'cover',
position: 'centre'
})
.webp({ quality: 85, effort: 4 })
.toBuffer();
// Generate JPEG fallback for older browsers
const processedJPEG = await sharp(buffer)
.resize(400, 400, {
fit: 'cover',
position: 'centre'
})
.jpeg({ quality: 85, progressive: true })
.toBuffer();
// Generate smaller thumbnail in WebP format
const thumbnailWebP = await sharp(buffer)
.resize(150, 150, {
fit: 'cover',
position: 'centre'
})
.webp({ quality: 80, effort: 4 })
.toBuffer();
// Generate JPEG thumbnail fallback
const thumbnailJPEG = await sharp(buffer)
.resize(150, 150, {
fit: 'cover',
position: 'centre'
})
.jpeg({ quality: 80 })
.toBuffer();
// Upload all variants to R2
const webpFile = new File([new Uint8Array(processedWebP)], `${filename}.webp`, {
type: 'image/webp'
});
const jpegFile = new File([new Uint8Array(processedJPEG)], `${filename}.jpg`, {
type: 'image/jpeg'
});
const thumbWebPFile = new File([new Uint8Array(thumbnailWebP)], `${filename}-thumb.webp`, {
type: 'image/webp'
});
const thumbJPEGFile = new File([new Uint8Array(thumbnailJPEG)], `${filename}-thumb.jpg`, {
type: 'image/jpeg'
});
const [webpPath, jpegPath, thumbWebPPath, thumbJPEGPath] = await Promise.all([
uploadToR2(webpFile, `${filename}.webp`, 'image/webp'),
uploadToR2(jpegFile, `${filename}.jpg`, 'image/jpeg'),
uploadToR2(thumbWebPFile, `${filename}-thumb.webp`, 'image/webp'),
uploadToR2(thumbJPEGFile, `${filename}-thumb.jpg`, 'image/jpeg')
]);
// Return WebP URLs as primary, JPEG as fallback (client can choose based on browser support)
return {
url: webpPath,
thumbnailUrl: thumbWebPPath,
fallbackUrl: jpegPath,
fallbackThumbnailUrl: thumbJPEGPath
};
}