Add profile image and fixes

This commit is contained in:
MartinBraquet
2025-07-29 01:09:16 +02:00
parent 9f533fefdb
commit 65be06620e
13 changed files with 2077 additions and 41 deletions

View File

@@ -44,12 +44,6 @@ After cloning the repo and navigating into it, install dependencies:
npm install
```
Create a Prisma Postgres instance by running the following command:
```
npx prisma init --db
```
You now need to configure your database connection via an environment variable.
First, create an `.env` file:

51
app/api/download/route.ts Normal file
View File

@@ -0,0 +1,51 @@
import type {NextApiRequest, NextApiResponse} from "next";
import {S3Client, GetObjectCommand} from "@aws-sdk/client-s3";
import {getSignedUrl} from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function GET(
req: Request
) {
// console.log(req)
const {searchParams} = new URL(req.url);
const key = searchParams.get('key'); // get the key from query params
if (!key) {
return new Response('S3 download error', {
status: 500,
headers: {'Content-Type': 'application/json'},
});
}
try {
// Option 1: Generate a signed URL (client downloads directly from S3)
const signedUrl = await getSignedUrl(
s3,
new GetObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME!,
Key: key,
}),
{expiresIn: 300} // 5 minutes
);
return new Response(JSON.stringify({url: signedUrl}), {
status: 200,
headers: {'Content-Type': 'application/json'},
});
} catch (err: any) {
console.error("S3 download error:", err);
return new Response('S3 download error', {
status: 500,
headers: {'Content-Type': 'application/json'},
});
}
}

View File

@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(
request: Request,
context: { params: Promise<{ id: string }> }
) {
try {
const params = await context.params;
const { id } = params;
// Find the user by ID
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
image: true,
gender: true,
description: true,
createdAt: true,
},
});
// If user not found, return 404
if (!user) {
return new NextResponse(JSON.stringify({ error: "User not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new NextResponse(JSON.stringify(user), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching user profile:", error);
return new NextResponse(
JSON.stringify({ error: "Failed to fetch user profile" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
}

75
app/api/upload/route.ts Normal file
View File

@@ -0,0 +1,75 @@
import {NextResponse} from 'next/server';
import {getSession} from '@/lib/auth';
import {v4 as uuidv4} from 'uuid';
import {GetObjectCommand, PutObjectCommand, S3Client} from '@aws-sdk/client-s3';
import {getSignedUrl} from "@aws-sdk/s3-request-presigner";
const s3Client = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session?.user?.email) {
return NextResponse.json({error: 'Not authenticated'}, {status: 401});
}
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({error: 'No file provided'}, {status: 400});
}
// Validate file type
if (!file.type.startsWith('image/')) {
return NextResponse.json({error: 'Only image files are allowed'}, {status: 400});
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json({error: 'File size must be less than 5MB'}, {status: 400});
}
const fileExtension = file.name.split('.').pop();
const fileName = `${uuidv4()}.${fileExtension}`;
const fileBuffer = await file.arrayBuffer();
const key = `profile-pictures/${fileName}`;
const uploadParams = {
Bucket: process.env.AWS_S3_BUCKET_NAME!,
Key: key,
Body: Buffer.from(fileBuffer),
ContentType: file.type,
};
const response = await s3Client.send(new PutObjectCommand(uploadParams));
console.log(`Response: ${response}`);
// get signed url
const url = await getSignedUrl(
s3Client,
new GetObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME!,
Key: key,
}),
{expiresIn: 300} // 5 minutes
);
console.log(`Signed URL: ${url}`);
// const fileUrl = `${process.env.AWS_S3_BUCKET_NAME}/profile-pictures/${fileName}`;
return NextResponse.json({url: url, key: key});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json(
{error: 'Failed to upload file'},
{status: 500}
);
}
}

View File

@@ -13,7 +13,8 @@ export async function POST(req: Request) {
);
}
const {description, gender} = await req.json();
const {description, gender, image} = await req.json();
console.log(`Req: ${description}, ${gender}, ${image}`)
// Validate required fields
if (!gender) {
@@ -29,6 +30,7 @@ export async function POST(req: Request) {
data: {
description: description || null,
gender: gender || null,
...(image && { image }), // Only update image if provided
},
select: {
id: true,
@@ -36,6 +38,7 @@ export async function POST(req: Request) {
name: true,
description: true,
gender: true,
image: true,
},
});

View File

@@ -1,17 +1,66 @@
'use client';
import { useState } from 'react';
import { useState, useRef, ChangeEvent } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import Image from 'next/image';
export default function CompleteProfile() {
const [description, setDescription] = useState('');
const [gender, setGender] = useState('');
const [image, setImage] = useState<string | null>(null);
const [key, setKey] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const { data: session, update } = useSession();
const handleImageUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
setError('Please upload an image file');
return;
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
setError('Image size must be less than 5MB');
return;
}
const formData = new FormData();
formData.append('file', file);
try {
setIsUploading(true);
setError('');
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to upload image');
}
const { url, key } = await response.json();
setImage(url);
setKey(key);
} catch (error) {
console.error('Upload error:', error);
setError(error instanceof Error ? error.message : 'Failed to upload image');
} finally {
setIsUploading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -24,15 +73,19 @@ export default function CompleteProfile() {
setIsSubmitting(true);
setError('');
const body = JSON.stringify({
description,
gender,
...(key && { image: key }),
});
console.log(`Body: ${body}`)
// alert(body)
const response = await fetch('/api/user/update-profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
description,
gender,
}),
body: body,
});
if (!response.ok) {
@@ -81,6 +134,44 @@ export default function CompleteProfile() {
)}
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="flex justify-center mb-6">
<div className="relative">
<div className="h-32 w-32 rounded-full overflow-hidden border-4 border-white shadow-md">
{image ? (
<Image
src={image}
alt="Profile"
width={128}
height={128}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-gray-200 flex items-center justify-center">
<span className="text-4xl text-gray-500">
{session?.user?.name?.charAt(0).toUpperCase() || 'U'}
</span>
</div>
)}
</div>
<label
className="absolute -bottom-2 -right-2 bg-blue-600 text-white rounded-full p-2 cursor-pointer hover:bg-blue-700 transition-colors"
title="Upload photo"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4 5a2 2 0 00-2 2v8a2 2 0 002 2h12a2 2 0 002-2V7a2 2 0 00-2-2h-1.586a1 1 0 01-.707-.293l-1.121-1.121A2 2 0 0011.172 3H8.828a2 2 0 00-1.414.586L6.293 4.707A1 1 0 015.586 5H4zm6 9a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept="image/*"
className="hidden"
disabled={isUploading}
/>
</label>
</div>
</div>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="gender" className="block text-sm font-medium text-gray-700 mb-1">
@@ -131,7 +222,7 @@ export default function CompleteProfile() {
isSubmitting ? 'opacity-70 cursor-not-allowed' : ''
}`}
>
{isSubmitting ? 'Saving...' : 'Save and Continue'}
{isSubmitting || isUploading ? 'Saving...' : 'Save and Continue'}
</button>
{/*<div className="mt-4 text-center">*/}

View File

@@ -1,45 +1,88 @@
'use client';
import {useEffect, useState} from "react";
import {notFound, useParams} from "next/navigation";
interface ProfileData {
name?: string;
image?: string;
gender?: string;
description?: string;
}
export const dynamic = "force-dynamic"; // This disables SSG and ISR
import { prisma }from "@/lib/prisma";
import { notFound, redirect } from "next/navigation";
export default function Post() {
const {id} = useParams();
const [profile, setProfile] = useState<ProfileData | null>(null);
const [image, setImage] = useState(null);
const [loading, setLoading] = useState(true);
export default async function Post({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
// const profileId = id;
useEffect(() => {
const fetchProfile = async () => {
try {
const response = await fetch(`/api/profiles/${id}`);
if (!response.ok) {
notFound();
}
const data = await response.json();
setProfile(data);
console.log(`Data image: ${data.image}`)
const profile = await prisma.user.findUnique({
where: { id: id },
// include: {
// author: true,
// },
});
// If user has an image key, fetch the image
if (data.image) {
const imageResponse = await fetch(`/api/download?key=${data.image}`);
console.log(`imageResponse: ${imageResponse}`)
if (imageResponse.ok) {
const imageBlob = await imageResponse.json();
const imageUrl = imageBlob['url'];
setImage(imageUrl);
}
}
} catch (error) {
console.error('Error fetching profile:', error);
} finally {
setLoading(false);
}
};
fetchProfile();
}, [id]);
if (loading) {
return <div></div>;
}
if (!profile) {
notFound();
}
console.log(`Image: ${image}`)
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<article className="max-w-3xl mx-auto bg-white shadow-lg rounded-lg overflow-hidden">
{/* Profile Header with Image */}
<div className="bg-gradient-to-r from-blue-600 to-blue-800 h-48 relative">
{profile.image ? (
<div className="bg-gradient-to-r h-16 relative">
{image ? (
<div className="absolute -bottom-16 left-8">
<div className="h-32 w-32 rounded-full border-4 border-white overflow-hidden bg-white">
<img
src={profile.image}
src={image}
alt={profile.name || 'Profile picture'}
className="h-full w-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(profile.name || 'U')}&background=random`;
}}
// onError={(e) => {
// const target = e.target as HTMLImageElement;
// target.onerror = null;
// target.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(profile.name || 'U')}&background=random`;
// }}
/>
</div>
</div>
) : (
<div className="absolute -bottom-16 left-8 h-32 w-32 rounded-full border-4 border-white bg-gray-200 flex items-center justify-center">
<div
className="absolute -bottom-16 left-8 h-32 w-32 rounded-full border-4 border-white bg-gray-200 flex items-center justify-center">
<span className="text-4xl font-bold text-gray-600">
{profile.name ? profile.name.charAt(0).toUpperCase() : 'U'}
</span>

View File

@@ -13,10 +13,15 @@ export default function RegisterPage() {
const [registrationSuccess, setRegistrationSuccess] = useState(false);
const [registeredEmail, setRegisteredEmail] = useState('');
function redirect() {
// Redirect to complete profile page
window.location.href = '/complete-profile';
}
const handleGoogleSignUp = async () => {
try {
setIsLoading(true);
await signIn('google', {callbackUrl: '/'});
await signIn('google', {callbackUrl: '/complete-profile'});
} catch (error) {
setError('Failed to sign up with Google');
setIsLoading(false);
@@ -65,8 +70,8 @@ export default function RegisterPage() {
throw new Error("Failed to sign in after registration");
}
// Redirect to complete profile page
window.location.href = '/complete-profile';
redirect()
} catch (error) {
setError(error instanceof Error ? error.message : "Registration failed");
setIsLoading(false);

6
lib/supabase.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

View File

@@ -1,7 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'bayesbond.s3.eu-north-1.amazonaws.com',
pathname: '/**', // allow all paths
},
],
},
};
export default nextConfig;

1715
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,13 @@
},
"dependencies": {
"@auth/prisma-adapter": "^2.10.0",
"@aws-sdk/client-s3": "^3.855.0",
"@aws-sdk/s3-request-presigner": "^3.855.0",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^6.12.0",
"@prisma/extension-accelerate": "^2.0.2",
"@react-email/render": "^1.1.3",
"@supabase/supabase-js": "^2.53.0",
"@types/uuid": "^10.0.0",
"bcryptjs": "^3.0.2",
"jsonwebtoken": "^9.0.2",

View File

@@ -1,5 +1,5 @@
datasource db {
provider = "sqlite"
provider = "postgresql"
url = env("DATABASE_URL")
}