feat: Implement identity lock screen for sensitive user actions and introduce avatar generation.
This commit is contained in:
@@ -10,6 +10,7 @@ import { requireAuth, verifyPassword, hashPassword } from '@/lib/auth';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as crypto from 'crypto';
|
||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
|
||||
/**
|
||||
* Decrypt the private key using the OLD password
|
||||
@@ -70,9 +71,17 @@ function encryptPrivateKey(privateKey: string, password: string): { encrypted: s
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const body = await req.json();
|
||||
const { currentPassword, newPassword } = body;
|
||||
// Parse signed action
|
||||
const signedAction: SignedAction = await req.json();
|
||||
|
||||
// Verify signature and get user
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
if (signedAction.action !== 'change_password') {
|
||||
return NextResponse.json({ error: 'Invalid action type' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = signedAction.data;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSession, requireAuth } from '@/lib/auth';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
displayName: z.string().min(1).max(50).optional(),
|
||||
@@ -52,9 +53,20 @@ export async function PATCH(request: Request) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const currentUser = await requireAuth();
|
||||
const body = await request.json();
|
||||
const data = updateProfileSchema.parse(body);
|
||||
// Parse signed action
|
||||
const signedAction: SignedAction = await request.json();
|
||||
|
||||
// Verify signature and get user
|
||||
// This ensures the request was signed by the user's private key
|
||||
const currentUser = await requireSignedAction(signedAction);
|
||||
|
||||
// Ensure the action type is correct for profile updates
|
||||
if (signedAction.action !== 'update_profile') {
|
||||
return NextResponse.json({ error: 'Invalid action type' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Parse inner data
|
||||
const data = updateProfileSchema.parse(signedAction.data);
|
||||
|
||||
const updateData: {
|
||||
displayName?: string;
|
||||
@@ -119,8 +131,13 @@ export async function PATCH(request: Request) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
if (error.message === 'Invalid signature' || error.message === 'User not found') {
|
||||
return NextResponse.json({ error: 'Invalid signature or identity' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
console.error('Profile update error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
|
||||
|
||||
@@ -71,8 +71,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// LAZY LOAD: If remote and not cached, try to fetch it now
|
||||
if (!cachedUser && isRemote) {
|
||||
// LAZY LOAD: If remote and (not cached OR missing avatar), try to fetch it now
|
||||
if (isRemote && (!cachedUser || !cachedUser.avatarUrl)) {
|
||||
try {
|
||||
const [rHandle, rDomain] = participant2Handle.split('@');
|
||||
const { fetchSwarmUserProfile } = await import('@/lib/swarm/interactions');
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||
import { eq, desc, and, lt, isNull, sql } from 'drizzle-orm';
|
||||
import { eq, desc, and, lt, isNull, sql, inArray } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (senderDids.size > 0) {
|
||||
const found = await db.query.users.findMany({
|
||||
where: sql`${users.did} IN ${Array.from(senderDids)}`
|
||||
where: inArray(users.did, Array.from(senderDids))
|
||||
});
|
||||
found.forEach(u => usersByDid[u.did] = u);
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export async function GET(request: NextRequest) {
|
||||
// Also fetch local users by handle if needed
|
||||
if (senderHandles.size > 0) {
|
||||
const found = await db.query.users.findMany({
|
||||
where: sql`${users.handle} IN ${Array.from(senderHandles)}`
|
||||
where: inArray(users.handle, Array.from(senderHandles))
|
||||
});
|
||||
found.forEach(u => usersByHandle[u.handle] = u);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { IdentityLockScreen } from '@/components/IdentityLockScreen';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
const { isIdentityUnlocked, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '60vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--foreground-tertiary)'
|
||||
}}>
|
||||
<Loader2 className="animate-spin" size={24} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isIdentityUnlocked) {
|
||||
return (
|
||||
<IdentityLockScreen
|
||||
title="Settings Locked"
|
||||
description="To view or change your settings, you must unlock your identity. Your private keys are required to sign any changes you make."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { MessageSquare, Check } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
|
||||
export default function PrivacySettingsPage() {
|
||||
const router = useRouter();
|
||||
@@ -13,6 +14,8 @@ export default function PrivacySettingsPage() {
|
||||
const [dmPrivacy, setDmPrivacy] = useState<'everyone' | 'following' | 'none'>('everyone');
|
||||
const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
||||
|
||||
const { isIdentityUnlocked, setShowUnlockPrompt, signUserAction } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then(res => res.json())
|
||||
@@ -27,15 +30,23 @@ export default function PrivacySettingsPage() {
|
||||
}, []);
|
||||
|
||||
const handleSave = async (newValue: 'everyone' | 'following' | 'none') => {
|
||||
// If identity is locked, prompt to unlock and return
|
||||
if (!isIdentityUnlocked) {
|
||||
setShowUnlockPrompt(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setDmPrivacy(newValue);
|
||||
setSaving(true);
|
||||
setStatus(null);
|
||||
|
||||
try {
|
||||
const signedPayload = await signUserAction('update_profile', { dmPrivacy: newValue });
|
||||
|
||||
const res = await fetch('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dmPrivacy: newValue }),
|
||||
body: JSON.stringify(signedPayload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -44,6 +55,11 @@ export default function PrivacySettingsPage() {
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setStatus({ type: 'error', message: data.error || 'Failed to save settings' });
|
||||
|
||||
// If error due to identity lock
|
||||
if (data.error === 'Invalid signature or identity') {
|
||||
setShowUnlockPrompt(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus({ type: 'error', message: 'An error occurred' });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { Shield, Lock, Check, AlertCircle } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
|
||||
export default function SecuritySettingsPage() {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
@@ -12,6 +13,7 @@ export default function SecuritySettingsPage() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const { isIdentityUnlocked, setShowUnlockPrompt, signUserAction } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -34,18 +36,35 @@ export default function SecuritySettingsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If identity is locked, prompt to unlock and return
|
||||
// Note: It seems redundant since they entered the password,
|
||||
// but this ensures the KEY is loaded in memory.
|
||||
if (!isIdentityUnlocked) {
|
||||
setShowUnlockPrompt(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Sign the password change action
|
||||
// This proves we have the key unlocked (which required knowing the password)
|
||||
const signedPayload = await signUserAction('change_password', { currentPassword, newPassword });
|
||||
|
||||
const res = await fetch('/api/account/password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
body: JSON.stringify(signedPayload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
// If error due to identity lock
|
||||
if (data.error === 'Invalid signature or identity' || data.error === 'User not found') {
|
||||
setShowUnlockPrompt(true);
|
||||
throw new Error('Identity verification failed. Please unlock your identity.');
|
||||
}
|
||||
throw new Error(data.error || 'Failed to change password');
|
||||
}
|
||||
|
||||
|
||||
+187
-100
@@ -7,9 +7,10 @@ import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons';
|
||||
import { PostCard } from '@/components/PostCard';
|
||||
import { User, Post } from '@/lib/types';
|
||||
import AutoTextarea from '@/components/AutoTextarea';
|
||||
import { Rocket, MoreHorizontal, Mail } from 'lucide-react';
|
||||
import { Rocket, MoreHorizontal, Mail, Camera } from 'lucide-react';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
|
||||
interface BotOwner {
|
||||
id: string;
|
||||
@@ -78,6 +79,7 @@ export default function ProfilePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const handle = (params.handle as string)?.replace(/^@/, '') || '';
|
||||
const { isIdentityUnlocked, setShowUnlockPrompt, signUserAction } = useAuth();
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
@@ -111,6 +113,74 @@ export default function ProfilePage() {
|
||||
const [isBlocked, setIsBlocked] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
const headerInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: 'avatarUrl' | 'headerUrl') => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Reset inputs so change event fires again for same file
|
||||
e.target.value = '';
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// 1. Upload the file
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const uploadRes = await fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const uploadData = await uploadRes.json();
|
||||
|
||||
if (!uploadData.url) throw new Error('Upload failed');
|
||||
const imageUrl = uploadData.url;
|
||||
|
||||
// 2. Update local form state immediately for UI feedback
|
||||
setProfileForm(prev => ({ ...prev, [field]: imageUrl }));
|
||||
|
||||
// 3. Auto-save the profile change
|
||||
if (!isIdentityUnlocked) {
|
||||
setShowUnlockPrompt(true);
|
||||
throw new Error('Please unlock your identity to save the changes.');
|
||||
}
|
||||
|
||||
// Create partial update payload
|
||||
const updatePayload: any = { [field]: imageUrl };
|
||||
|
||||
// Sign the action
|
||||
const signedPayload = await signUserAction('update_profile', updatePayload);
|
||||
|
||||
const saveRes = await fetch('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(signedPayload),
|
||||
});
|
||||
|
||||
const saveData = await saveRes.json();
|
||||
|
||||
if (!saveRes.ok) {
|
||||
// If error due to identity lock
|
||||
if (saveData.error === 'Invalid signature or identity') {
|
||||
setShowUnlockPrompt(true);
|
||||
throw new Error('Identity verification failed. Please try again after unlocking.');
|
||||
}
|
||||
throw new Error(saveData.error || 'Failed to update profile');
|
||||
}
|
||||
|
||||
// Update user state with the returned updated user
|
||||
setUser(saveData.user);
|
||||
// We do NOT exit edit mode automatically, allowing them to edit other fields
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setSaveError(err instanceof Error ? err.message : 'Upload failed');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsEditing(false);
|
||||
setSaveError(null);
|
||||
@@ -303,6 +373,11 @@ export default function ProfilePage() {
|
||||
const handleFollow = async () => {
|
||||
if (!currentUser) return;
|
||||
|
||||
if (!isIdentityUnlocked) {
|
||||
setShowUnlockPrompt(true, () => handleFollow());
|
||||
return;
|
||||
}
|
||||
|
||||
const method = isFollowing ? 'DELETE' : 'POST';
|
||||
const res = await fetch(`/api/users/${handle}/follow`, { method });
|
||||
|
||||
@@ -318,6 +393,11 @@ export default function ProfilePage() {
|
||||
const handleBlock = async () => {
|
||||
if (!currentUser) return;
|
||||
|
||||
if (!isIdentityUnlocked) {
|
||||
setShowUnlockPrompt(true, () => handleBlock());
|
||||
return;
|
||||
}
|
||||
|
||||
const method = isBlocked ? 'DELETE' : 'POST';
|
||||
const res = await fetch(`/api/users/${handle}/block`, { method });
|
||||
|
||||
@@ -333,14 +413,24 @@ export default function ProfilePage() {
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!isOwnProfile) return;
|
||||
|
||||
// If identity is locked, prompt to unlock and return
|
||||
if (!isIdentityUnlocked) {
|
||||
setShowUnlockPrompt(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
// Sign the profile update action
|
||||
const signedPayload = await signUserAction('update_profile', profileForm);
|
||||
|
||||
const res = await fetch('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(profileForm),
|
||||
body: JSON.stringify(signedPayload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
@@ -353,7 +443,14 @@ export default function ProfilePage() {
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error('Profile update failed', error);
|
||||
setSaveError('Unable to update profile. Please try again.');
|
||||
setSaveError(error instanceof Error && error.message.includes('Identity locked')
|
||||
? 'Please unlock your identity to save changes.'
|
||||
: 'Unable to update profile. Please try again.');
|
||||
|
||||
// If the error was due to lock state (race condition), prompt unlock
|
||||
if (error instanceof Error && error.message.includes('Identity locked')) {
|
||||
setShowUnlockPrompt(true);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -465,12 +562,47 @@ export default function ProfilePage() {
|
||||
{/* Profile Header */}
|
||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
{/* Banner */}
|
||||
<div style={{
|
||||
height: '150px',
|
||||
background: user.headerUrl
|
||||
? `url(${user.headerUrl}) center/cover`
|
||||
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
||||
}} />
|
||||
{/* Banner */}
|
||||
<div
|
||||
style={{
|
||||
height: '150px',
|
||||
background: (isEditing ? profileForm.headerUrl : user.headerUrl)
|
||||
? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover`
|
||||
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
||||
position: 'relative',
|
||||
cursor: isEditing ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isEditing) {
|
||||
if (!isIdentityUnlocked) {
|
||||
setShowUnlockPrompt(true);
|
||||
} else {
|
||||
headerInputRef.current?.click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEditing && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white'
|
||||
}}>
|
||||
<Camera size={32} />
|
||||
<input
|
||||
ref={headerInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => handleUpload(e, 'headerUrl')}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Avatar & Actions */}
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
@@ -479,18 +611,54 @@ export default function ProfilePage() {
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
}}>
|
||||
<div className="avatar avatar-lg" style={{
|
||||
width: '96px',
|
||||
height: '96px',
|
||||
fontSize: '36px',
|
||||
border: '4px solid var(--background)',
|
||||
marginTop: '-48px',
|
||||
}}>
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
|
||||
<div
|
||||
className="avatar avatar-lg"
|
||||
style={{
|
||||
width: '96px',
|
||||
height: '96px',
|
||||
fontSize: '36px',
|
||||
border: '4px solid var(--background)',
|
||||
marginTop: '-48px',
|
||||
position: 'relative',
|
||||
cursor: isEditing ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isEditing) {
|
||||
if (!isIdentityUnlocked) {
|
||||
setShowUnlockPrompt(true);
|
||||
} else {
|
||||
avatarInputRef.current?.click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(isEditing ? profileForm.avatarUrl : user.avatarUrl) ? (
|
||||
<img src={(isEditing ? profileForm.avatarUrl : user.avatarUrl) || ''} alt={user.displayName || user.handle} />
|
||||
) : (
|
||||
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white'
|
||||
}}>
|
||||
<Camera size={24} />
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => handleUpload(e, 'avatarUrl')}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
@@ -704,88 +872,7 @@ export default function ProfilePage() {
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Avatar</label>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
||||
{isSaving ? 'Uploading...' : 'Choose File'}
|
||||
<input
|
||||
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={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
{profileForm.avatarUrl && (
|
||||
<div style={{ width: '40px', height: '40px', borderRadius: '50%', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
||||
<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</label>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
||||
{isSaving ? 'Uploading...' : 'Choose File'}
|
||||
<input
|
||||
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={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
{profileForm.headerUrl && (
|
||||
<div style={{ width: '80px', height: '40px', borderRadius: '4px', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
||||
<img src={profileForm.headerUrl} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* File inputs removed, now click-to-upload on visual elements */}
|
||||
{saveError && (
|
||||
<div style={{ color: 'var(--error)', fontSize: '13px' }}>{saveError}</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user