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"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.972.0",
|
||||||
"@upstash/redis": "^1.34.3",
|
"@upstash/redis": "^1.34.3",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
|||||||
+72
-14
@@ -490,22 +490,80 @@ export default function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Avatar URL</label>
|
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Avatar</label>
|
||||||
<input
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
className="input"
|
<input
|
||||||
value={profileForm.avatarUrl}
|
type="file"
|
||||||
onChange={(e) => setProfileForm({ ...profileForm, avatarUrl: e.target.value })}
|
accept="image/*"
|
||||||
placeholder="https://"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Header URL</label>
|
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Header</label>
|
||||||
<input
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
className="input"
|
<input
|
||||||
value={profileForm.headerUrl}
|
type="file"
|
||||||
onChange={(e) => setProfileForm({ ...profileForm, headerUrl: e.target.value })}
|
accept="image/*"
|
||||||
placeholder="https://"
|
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>
|
</div>
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<div style={{ color: 'var(--error)', fontSize: '13px' }}>{saveError}</div>
|
<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: '',
|
bannerUrl: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/node', { cache: 'no-store' })
|
fetch('/api/node', { cache: 'no-store' })
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
@@ -26,9 +28,29 @@ export function RightSidebar() {
|
|||||||
bannerUrl: data?.bannerUrl ?? prev.bannerUrl,
|
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 (
|
return (
|
||||||
<aside className="aside">
|
<aside className="aside">
|
||||||
<div className="card" style={{ overflow: 'hidden', padding: 0 }}>
|
<div className="card" style={{ overflow: 'hidden', padding: 0 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user