feat: add S3 file upload functionality and update profile image handling
- Added @aws-sdk/client-s3 dependency for S3 interactions. - Refactored ProfilePage to allow file uploads for avatar and header images, replacing URL inputs with file inputs. - Implemented loading state and error handling during image uploads. - Created new API route for handling file uploads to S3, including public URL generation. - Enhanced RightSidebar to show loading skeleton while fetching data.
This commit is contained in:
Generated
+1667
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.972.0",
|
||||
"@upstash/redis": "^1.34.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
|
||||
+68
-10
@@ -490,22 +490,80 @@ export default function ProfilePage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Avatar URL</label>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Avatar</label>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
value={profileForm.avatarUrl}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, avatarUrl: e.target.value })}
|
||||
placeholder="https://"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
setProfileForm(prev => ({ ...prev, avatarUrl: data.url }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setSaveError('Upload failed');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
disabled={isSaving}
|
||||
style={{ fontSize: '13px' }}
|
||||
/>
|
||||
{profileForm.avatarUrl && (
|
||||
<div style={{ width: '32px', height: '32px', borderRadius: '50%', overflow: 'hidden' }}>
|
||||
<img src={profileForm.avatarUrl} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Header URL</label>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Header</label>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
value={profileForm.headerUrl}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, headerUrl: e.target.value })}
|
||||
placeholder="https://"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
setProfileForm(prev => ({ ...prev, headerUrl: data.url }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setSaveError('Upload failed');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
disabled={isSaving}
|
||||
style={{ fontSize: '13px' }}
|
||||
/>
|
||||
{profileForm.headerUrl && (
|
||||
<div style={{ width: '48px', height: '32px', borderRadius: '4px', overflow: 'hidden' }}>
|
||||
<img src={profileForm.headerUrl} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{saveError && (
|
||||
<div style={{ color: 'var(--error)', fontSize: '13px' }}>{saveError}</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
|
||||
|
||||
// S3 Configuration
|
||||
const s3 = new S3Client({
|
||||
region: process.env.STORAGE_REGION || 'us-east-1',
|
||||
endpoint: process.env.STORAGE_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.STORAGE_ACCESS_KEY || '',
|
||||
secretAccessKey: process.env.STORAGE_SECRET_KEY || '',
|
||||
},
|
||||
forcePathStyle: true, // Needed for many S3-compatible providers (MinIO, etc.)
|
||||
});
|
||||
|
||||
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: filename,
|
||||
Body: buffer,
|
||||
ContentType: file.type,
|
||||
ACL: 'public-read', // Depends on bucket policy, but often needed
|
||||
}));
|
||||
|
||||
// Construct Public URL
|
||||
let url = '';
|
||||
if (process.env.STORAGE_PUBLIC_BASE_URL) {
|
||||
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
|
||||
} else {
|
||||
// Fallback if no public URL configured (e.g. valid for some providers)
|
||||
// This might need adjustment based on provider
|
||||
url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`;
|
||||
}
|
||||
|
||||
return NextResponse.json({ url });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export function RightSidebar() {
|
||||
bannerUrl: '',
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/node', { cache: 'no-store' })
|
||||
.then(res => res.json())
|
||||
@@ -26,9 +28,29 @@ export function RightSidebar() {
|
||||
bannerUrl: data?.bannerUrl ?? prev.bannerUrl,
|
||||
}));
|
||||
})
|
||||
.catch(() => { });
|
||||
.catch(() => { })
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<aside className="aside">
|
||||
<div className="card" style={{ overflow: 'hidden', padding: 0, height: '300px' }}>
|
||||
<div style={{
|
||||
height: '140px',
|
||||
background: 'var(--background-tertiary)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}} />
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div style={{ height: '24px', width: '60%', background: 'var(--background-tertiary)', borderRadius: '4px', marginBottom: '12px' }} />
|
||||
<div style={{ height: '16px', width: '90%', background: 'var(--background-tertiary)', borderRadius: '4px', marginBottom: '8px' }} />
|
||||
<div style={{ height: '16px', width: '75%', background: 'var(--background-tertiary)', borderRadius: '4px' }} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="aside">
|
||||
<div className="card" style={{ overflow: 'hidden', padding: 0 }}>
|
||||
|
||||
Reference in New Issue
Block a user