Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 40 additions & 14 deletions src/app/[locale]/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
} from "../../stores/useWalletStore";
import { useUserStore, selectUser } from "../../stores/useUserStore";
import { logoutUser } from "../../lib/session";
import { useTranslations } from "next-intl";
import { AvatarUpload } from "../../components/AvatarUpload";

// ─── Types ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -121,7 +123,9 @@ function Toggle({

function ProfileSection() {
const user = useUserStore(selectUser);
const [displayName, setDisplayName] = useState(user?.id ?? "");
const setAvatar = useUserStore((s) => s.setAvatar);
const removeAvatar = useUserStore((s) => s.removeAvatar);
const [displayName, setDisplayName] = useState(user?.name ?? "");
const [email, setEmail] = useState(user?.email ?? "");
const [saved, setSaved] = useState(false);

Expand All @@ -131,6 +135,28 @@ function ProfileSection() {
setTimeout(() => setSaved(false), 2000);
};

const handleAvatarChange = (url: string) => {
setAvatar(url);
};

const handleAvatarRemove = async () => {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/users/avatar`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${localStorage.getItem("authToken") || ""}`,
},
}
);
if (!res.ok) throw new Error("Failed to remove avatar");
removeAvatar();
} catch (err) {
console.error("Failed to remove avatar:", err);
}
};

return (
<Card>
<CardHeader>
Expand All @@ -139,18 +165,18 @@ function ProfileSection() {
Manage your public display name and contact info.
</p>
</CardHeader>
<CardContent className="space-y-4">
{/* Avatar */}
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-full bg-indigo-100 dark:bg-indigo-500/20 flex items-center justify-center">
<User className="h-8 w-8 text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">Profile Picture</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
Avatars are not supported yet — coming soon.
</p>
</div>
<CardContent className="space-y-6">
{/* Avatar Upload */}
<div className="pb-4 border-b border-zinc-100 dark:border-zinc-800">
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-3">
Profile Picture
</p>
<AvatarUpload
currentAvatarUrl={user?.avatarUrl}
userName={user?.name}
onAvatarChange={handleAvatarChange}
onAvatarRemove={handleAvatarRemove}
/>
</div>

<Input
Expand Down Expand Up @@ -573,4 +599,4 @@ export default function SettingsPage() {
</div>
</main>
);
}
}
161 changes: 161 additions & 0 deletions src/app/components/AvatarUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
'use client';

import Image from 'next/image';
import { useAvatarUpload } from '@/app/hooks/useAvatarUpload';
import { cn } from '@/lib/utils'; // or your class merging utility

interface AvatarUploadProps {
currentAvatarUrl?: string | null;
userName?: string;
onAvatarChange?: (url: string) => void;
onAvatarRemove?: () => void;
size?: 'sm' | 'md' | 'lg';
}

const sizeClasses = {
sm: 'w-16 h-16 text-sm',
md: 'w-24 h-24 text-base',
lg: 'w-32 h-32 text-lg',
};

export function AvatarUpload({
currentAvatarUrl,
userName = 'User',
onAvatarChange,
onAvatarRemove,
size = 'lg',
}: AvatarUploadProps) {
const {
uploadState,
progress,
preview,
error,
fileInputRef,
handleInputChange,
triggerFileSelect,
clearError,
} = useAvatarUpload({
onSuccess: onAvatarChange,
});

const isUploading = uploadState === 'uploading';
const hasError = uploadState === 'error';
const displayUrl = preview || currentAvatarUrl;
const initials = userName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);

return (
<div className="flex flex-col items-center gap-4">
{/* Avatar Display */}
<div className="relative group">
<div
className={cn(
'relative rounded-full overflow-hidden bg-gray-100 border-2 border-gray-200 flex items-center justify-center transition-all',
sizeClasses[size],
isUploading && 'opacity-70',
hasError && 'border-red-400 ring-2 ring-red-100'
)}
>
{displayUrl ? (
<Image
src={displayUrl}
alt={`${userName}'s avatar`}
fill
className="object-cover"
sizes={`(max-width: 768px) 100vw, ${size === 'lg' ? '128px' : size === 'md' ? '96px' : '64px'}`}
/>
) : (
<span className="font-semibold text-gray-500">{initials}</span>
)}

{/* Upload Progress Overlay */}
{isUploading && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 text-white">
<span className="text-sm font-medium">{progress}%</span>
<div className="w-16 h-1 bg-white/30 rounded-full mt-1 overflow-hidden">
<div
className="h-full bg-white rounded-full transition-all duration-200"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}

{/* Hover Overlay */}
{!isUploading && (
<button
onClick={triggerFileSelect}
className="absolute inset-0 flex items-center justify-center bg-black/0 hover:bg-black/40 text-transparent hover:text-white rounded-full transition-all cursor-pointer"
aria-label="Change avatar"
>
<span className="text-sm font-medium">Change</span>
</button>
)}
</div>

{/* Success Indicator */}
{uploadState === 'success' && (
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center text-white text-xs border-2 border-white">
</div>
)}
</div>

{/* Hidden File Input */}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleInputChange}
className="sr-only"
aria-label="Upload avatar image"
disabled={isUploading}
/>

{/* Action Buttons */}
<div className="flex items-center gap-2">
<button
onClick={triggerFileSelect}
disabled={isUploading}
className={cn(
'px-4 py-2 text-sm font-medium rounded-lg transition-colors',
isUploading
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
)}
>
{isUploading ? 'Uploading...' : displayUrl ? 'Change Avatar' : 'Upload Avatar'}
</button>

{currentAvatarUrl && !isUploading && (
<button
onClick={onAvatarRemove}
className="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors"
>
Remove
</button>
)}
</div>

{/* Error Message */}
{error && (
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">
<span>⚠</span>
<span>{error}</span>
<button onClick={clearError} className="ml-auto text-red-400 hover:text-red-600">
</button>
</div>
)}

{/* Helper Text */}
<p className="text-xs text-gray-500 text-center max-w-xs">
JPG, PNG, or WebP. Max 5MB. Max 2048x2048px.
</p>
</div>
);
}
36 changes: 36 additions & 0 deletions src/app/hooks/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1218,3 +1218,39 @@ export async function submitLoanTransaction(signedTxXdr: string) {
body: JSON.stringify({ signedTxXdr }),
});
}


// ─── Api hook ──────────────────────────────────────────────────────
export function useApi() {

const upload = async (
endpoint: string,
file: File,
options?: { headers?: Record<string, string> }
) => {
const formData = new FormData();
formData.append('avatar', file);

const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${endpoint}`, {
method: 'POST',
body: formData,
headers: {
// Don't set Content-Type — browser sets it with boundary for FormData
Authorization: `Bearer ${getToken()}`, // your auth method
...options?.headers,
},
});

if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Upload failed' }));
throw new Error(error.message || `Upload failed: ${response.status}`);
}

return response.json();
};

return {
// ... existing exports
upload,
};
}
Loading