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:
Christopher
2026-01-22 09:36:26 -08:00
parent 1f24c3ab09
commit 6cd74e1cc2
5 changed files with 1816 additions and 15 deletions
+1667
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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
View File
@@ -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>
+53
View File
@@ -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 });
}
}
+23 -1
View File
@@ -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 }}>