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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
createdAt: string; // Will be converted to Date type, but is a string from api
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
likeCount?: number;
|
||||
isLikedByUser?: boolean;
|
||||
media: Array<{
|
||||
@@ -47,6 +48,7 @@
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
likeCount?: number;
|
||||
isLiked?: boolean;
|
||||
@@ -70,6 +72,7 @@
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
likeCount?: number;
|
||||
isLiked?: boolean;
|
||||
@@ -92,7 +95,8 @@
|
||||
createdAt: new Date(serverFind.createdAt), // Convert string to Date
|
||||
user: {
|
||||
id: serverFind.userId,
|
||||
username: serverFind.username
|
||||
username: serverFind.username,
|
||||
profilePictureUrl: serverFind.profilePictureUrl
|
||||
},
|
||||
likeCount: serverFind.likeCount,
|
||||
isLiked: serverFind.isLikedByUser,
|
||||
|
||||
@@ -65,6 +65,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
createdAt: find.createdAt,
|
||||
userId: find.userId,
|
||||
username: user.username,
|
||||
profilePictureUrl: user.profilePictureUrl,
|
||||
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
|
||||
isLikedByUser: sql<boolean>`CASE WHEN EXISTS(
|
||||
SELECT 1 FROM ${findLike}
|
||||
@@ -76,7 +77,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
.innerJoin(user, eq(find.userId, user.id))
|
||||
.leftJoin(findLike, eq(find.id, findLike.findId))
|
||||
.where(whereConditions)
|
||||
.groupBy(find.id, user.username)
|
||||
.groupBy(find.id, user.username, user.profilePictureUrl)
|
||||
.orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt)
|
||||
.limit(100);
|
||||
|
||||
@@ -147,8 +148,20 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
})
|
||||
);
|
||||
|
||||
// Generate signed URL for user profile picture if it exists
|
||||
let userProfilePictureUrl = findItem.profilePictureUrl;
|
||||
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
|
||||
try {
|
||||
userProfilePictureUrl = await getSignedR2Url(userProfilePictureUrl, 24 * 60 * 60);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate signed URL for user profile picture:', error);
|
||||
userProfilePictureUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...findItem,
|
||||
profilePictureUrl: userProfilePictureUrl,
|
||||
media: mediaWithSignedUrls,
|
||||
isLikedByUser: Boolean(findItem.isLikedByUser)
|
||||
};
|
||||
|
||||
51
src/routes/api/profile-picture/delete/+server.ts
Normal file
51
src/routes/api/profile-picture/delete/+server.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { deleteFromR2 } from '$lib/server/r2';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const { userId } = await request.json();
|
||||
|
||||
if (!userId) {
|
||||
return json({ error: 'userId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get current user to find profile picture URL
|
||||
const currentUser = await db.select().from(user).where(eq(user.id, userId)).limit(1);
|
||||
|
||||
if (!currentUser.length) {
|
||||
return json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const userRecord = currentUser[0];
|
||||
|
||||
if (!userRecord.profilePictureUrl) {
|
||||
return json({ error: 'No profile picture to delete' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Extract the base path from the stored URL (should be like users/123/profile-1234567890-abcdef.webp)
|
||||
const basePath = userRecord.profilePictureUrl.replace('.webp', '');
|
||||
|
||||
// Delete all variants (WebP and JPEG, main and thumbnails)
|
||||
const deletePromises = [
|
||||
deleteFromR2(`${basePath}.webp`),
|
||||
deleteFromR2(`${basePath}.jpg`),
|
||||
deleteFromR2(`${basePath}-thumb.webp`),
|
||||
deleteFromR2(`${basePath}-thumb.jpg`)
|
||||
];
|
||||
|
||||
// Execute all deletions, but don't fail if some files don't exist
|
||||
await Promise.allSettled(deletePromises);
|
||||
|
||||
// Update user profile picture URL in database
|
||||
await db.update(user).set({ profilePictureUrl: null }).where(eq(user.id, userId));
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Profile picture delete error:', error);
|
||||
return json({ error: 'Failed to delete profile picture' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
45
src/routes/api/profile-picture/upload/+server.ts
Normal file
45
src/routes/api/profile-picture/upload/+server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { processAndUploadProfilePicture } from '$lib/server/media-processor';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const userId = formData.get('userId') as string;
|
||||
|
||||
if (!file || !userId) {
|
||||
return json({ error: 'File and userId are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return json({ error: 'File must be an image' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file size (5MB max)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return json({ error: 'File size must be less than 5MB' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Process and upload profile picture
|
||||
const result = await processAndUploadProfilePicture(file, userId);
|
||||
|
||||
// Update user profile picture URL in database (store the WebP path)
|
||||
await db.update(user).set({ profilePictureUrl: result.url }).where(eq(user.id, userId));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
profilePictureUrl: result.url,
|
||||
thumbnailUrl: result.thumbnailUrl,
|
||||
fallbackUrl: result.fallbackUrl,
|
||||
fallbackThumbnailUrl: result.fallbackThumbnailUrl
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Profile picture upload error:', error);
|
||||
return json({ error: 'Failed to upload profile picture' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user