mono/packages/ui/src/pages/Profile.tsx
2026-02-25 10:11:54 +01:00

703 lines
27 KiB
TypeScript

import React, { useState, useEffect } from "react";
import { useAuth } from "@/hooks/useAuth";
import ImageGallery from "@/components/ImageGallery";
import { ImageFile } from "@/types";
import { fetchUserPictures, deletePicture } from "@/modules/posts/client-pictures";
import { toast } from "sonner";
import { Navigate, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { User, Images, Save, Camera, Upload, Check, Key, Globe, Hash, MapPin, Building2, ShoppingBag } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { T, translate, getCurrentLang, supportedLanguages, setLanguage } from "@/i18n";
import { uploadImage } from '@/lib/uploadUtils';
import { getUserSecrets, updateUserSecrets, getUserVariables, updateUserVariables, fetchProfileAPI, updateProfileAPI, updateUserEmail } from '@/modules/user/client-user';
import { VariablesEditor } from '@/components/variables/VariablesEditor';
import { ShippingAddressManager } from '@/components/ShippingAddressManager';
import { VendorProfileManager } from '@/components/VendorProfileManager';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
useSidebar
} from "@/components/ui/sidebar";
import { AnalyticsDashboard } from "./analytics";
const LazyPurchasesList = React.lazy(() =>
import("@polymech/ecommerce").then(m => ({ default: m.PurchasesList }))
);
type ActiveSection = 'general' | 'api-keys' | 'variables' | 'addresses' | 'vendor' | 'gallery' | 'purchases';
const Profile = () => {
const { user, loading, resetPassword } = useAuth();
const navigate = useNavigate();
const [images, setImages] = useState<ImageFile[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [fetchingImages, setFetchingImages] = useState(true);
const [activeSection, setActiveSection] = useState<ActiveSection>('general');
// Profile data state
const [profile, setProfile] = useState({
username: '',
display_name: '',
bio: '',
avatar_url: '',
settings: {}
});
const [secrets, setSecrets] = useState<Record<string, string>>({});
const [email, setEmail] = useState('');
const [updatingProfile, setUpdatingProfile] = useState(false);
const [avatarDialogOpen, setAvatarDialogOpen] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [selectedLanguage, setSelectedLanguage] = useState(getCurrentLang());
useEffect(() => {
if (user) {
fetchUserImages();
fetchProfile();
setEmail(user.email || '');
}
}, [user]);
const fetchProfile = async () => {
try {
console.log('Fetching profile for user:', user?.id);
const result = await fetchProfileAPI(user!.id);
if (result?.profile) {
const data = result.profile;
setProfile({
username: data.username || '',
display_name: data.display_name || '',
bio: data.bio || '',
avatar_url: data.avatar_url || '',
settings: data.settings || {}
});
// Fetch secrets
const fetchedSecrets = await getUserSecrets(user!.id);
if (fetchedSecrets) {
setSecrets(fetchedSecrets);
}
}
} catch (error) {
console.error('Error fetching profile:', error);
toast.error(translate('Failed to load profile data'));
}
};
const handleProfileUpdate = async () => {
if (!user) return;
setUpdatingProfile(true);
try {
// Update profile via API
await updateProfileAPI({
username: profile.username || null,
display_name: profile.display_name || null,
bio: profile.bio || null,
avatar_url: profile.avatar_url || null,
settings: profile.settings || {}
});
// Update secrets
await updateUserSecrets(user.id, secrets);
// Update email if changed
if (email !== user.email && email.trim()) {
await updateUserEmail(email);
}
toast.success(translate('Profile updated successfully'));
} catch (error) {
console.error('Error updating profile:', error);
toast.error(translate('Failed to update profile'));
} finally {
setUpdatingProfile(false);
}
};
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !user) return;
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error(translate('Please select an image file'));
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error(translate('Image must be less than 5MB'));
return;
}
setUploadingAvatar(true);
try {
// Upload to storage (direct or via proxy)
const { publicUrl } = await uploadImage(file, user.id);
// Update profile with new avatar URL
setProfile(prev => ({ ...prev, avatar_url: publicUrl }));
setAvatarDialogOpen(false);
toast.success(translate('Avatar updated successfully'));
} catch (error) {
console.error('Error uploading avatar:', error);
toast.error(translate('Failed to upload avatar'));
} finally {
setUploadingAvatar(false);
}
};
const handleSelectFromGallery = (imageUrl: string) => {
setProfile(prev => ({ ...prev, avatar_url: imageUrl }));
setAvatarDialogOpen(false);
toast.success(translate('Avatar updated successfully'));
};
const fetchUserImages = async () => {
try {
const pictures = await fetchUserPictures(user!.id);
// Filter client-side for is_selected (API returns all)
const selected = pictures.filter((p: any) => p.is_selected);
const imageFiles: ImageFile[] = selected.map((picture: any) => ({
path: picture.title,
src: picture.image_url,
isGenerated: false,
selected: false
}));
setImages(imageFiles);
} catch (error) {
console.error('Error fetching user images:', error);
toast.error(translate('Failed to load your images'));
} finally {
setFetchingImages(false);
}
};
const handleImageDelete = async (imagePath: string) => {
try {
// Find the image to delete
const imageToDelete = images.find(img => img.path === imagePath);
if (!imageToDelete) return;
// Find the picture ID from our fetched data by matching title
const allPics = await fetchUserPictures(user!.id);
const picture = allPics.find((p: any) => p.title === imagePath);
if (!picture) {
toast.error(translate('Failed to find image to delete'));
return;
}
await deletePicture(picture.id);
// Update local state
setImages(prevImages => prevImages.filter(img => img.path !== imagePath));
toast.success(translate('Image deleted successfully'));
} catch (error) {
console.error('Error deleting image:', error);
toast.error(translate('Failed to delete image'));
}
};
if (loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-muted-foreground"><T>Loading...</T></div>
</div>
);
}
if (!user) {
return <Navigate to="/auth" replace />;
}
return (
<SidebarProvider>
<div className="min-h-screen flex w-full bg-background pt-14">
<ProfileSidebar activeSection={activeSection} onSectionChange={setActiveSection} />
<main className="flex-1 p-4 overflow-auto">
<div className="mx-auto">
<div className="mb-8">
<h1 className="text-4xl font-bold mb-2">
<span className="bg-gradient-primary bg-clip-text text-transparent">
<T>Profile Settings</T>
</span>
</h1>
<p className="text-muted-foreground text-lg">
<T>Manage your account settings and preferences</T>
</p>
</div>
{activeSection === 'general' && (
<Card>
<CardHeader>
<CardTitle><T>General Settings</T></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Avatar Section */}
<div className="flex flex-col items-center space-y-4 pb-6 border-b">
<Avatar className="h-32 w-32">
<AvatarImage src={profile.avatar_url} alt={translate("Profile picture")} />
<AvatarFallback className="bg-gradient-primary text-white text-4xl">
<User className="h-16 w-16" />
</AvatarFallback>
</Avatar>
<Dialog open={avatarDialogOpen} onOpenChange={setAvatarDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Camera className="h-4 w-4 mr-2" />
<T>Change Avatar</T>
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle><T>Choose Avatar</T></DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Upload New Image */}
<div className="space-y-3">
<h4 className="font-medium"><T>Upload New Image</T></h4>
<div className="flex items-center gap-2">
<Button
variant="outline"
asChild
disabled={uploadingAvatar}
>
<label htmlFor="avatar-upload" className="cursor-pointer">
<Upload className="h-4 w-4 mr-2" />
{uploadingAvatar ? translate('Uploading...') : translate('Choose File')}
<input
id="avatar-upload"
type="file"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
/>
</label>
</Button>
<span className="text-sm text-muted-foreground">
<T>Max 5MB, JPG/PNG/WebP</T>
</span>
</div>
</div>
{/* Select from Gallery */}
{images.length > 0 && (
<div className="space-y-3">
<h4 className="font-medium"><T>Select from Gallery</T></h4>
<div className="grid grid-cols-4 gap-3 max-h-64 overflow-y-auto">
{images.map((image) => (
<button
key={image.path}
onClick={() => handleSelectFromGallery(image.src)}
className="relative aspect-square rounded-lg overflow-hidden bg-muted hover:ring-2 hover:ring-primary transition-all group"
>
<img
src={image.src}
alt={image.path}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Check className="h-6 w-6 text-white" />
</div>
</button>
))}
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
{/* Profile Form Fields */}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="email"><T>Email</T></Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={translate("your.email@example.com")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="username"><T>Username</T></Label>
<Input
id="username"
value={profile.username}
onChange={(e) => setProfile(prev => ({ ...prev, username: e.target.value }))}
placeholder={translate("Enter username")}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="display_name"><T>Display Name</T></Label>
<Input
id="display_name"
value={profile.display_name}
onChange={(e) => setProfile(prev => ({ ...prev, display_name: e.target.value }))}
placeholder={translate("Enter display name")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bio"><T>Bio</T></Label>
<Textarea
id="bio"
value={profile.bio}
onChange={(e) => setProfile(prev => ({ ...prev, bio: e.target.value }))}
placeholder={translate("Tell us about yourself...")}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="language" className="flex items-center gap-2">
<Globe className="h-4 w-4" />
<T>Language</T>
</Label>
<Select
value={selectedLanguage}
onValueChange={(value) => setSelectedLanguage(value as any)}
>
<SelectTrigger id="language">
<SelectValue />
</SelectTrigger>
<SelectContent>
{supportedLanguages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
<T>Your preferred language for the interface</T>
</p>
</div>
{/* Change Password */}
<div className="space-y-2 pt-4 border-t">
<Label className="flex items-center gap-2">
<Key className="h-4 w-4" />
<T>Password</T>
</Label>
<p className="text-sm text-muted-foreground">
<T>Send a password reset link to your email address</T>
</p>
<Button
variant="outline"
onClick={() => resetPassword(user.email || email)}
>
<T>Change Password</T>
</Button>
</div>
<Button
onClick={() => {
handleProfileUpdate();
// Apply language change if it changed
if (selectedLanguage !== getCurrentLang()) {
setLanguage(selectedLanguage as any);
}
}}
disabled={updatingProfile}
className="w-full"
>
<Save className="mr-2 h-4 w-4" />
<T>{updatingProfile ? 'Saving...' : 'Save Changes'}</T>
</Button>
</CardContent>
</Card>
)}
{activeSection === 'api-keys' && (
<Card>
<CardHeader>
<CardTitle><T>API Keys</T></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="google_api_key"><T>Google API Key</T></Label>
<Input
id="google_api_key"
type="password"
value={secrets.google_api_key || ''}
onChange={(e) => setSecrets(prev => ({ ...prev, google_api_key: e.target.value }))}
placeholder={translate("Enter your Google API key")}
/>
<p className="text-sm text-muted-foreground">
<T>For Google services (stored securely)</T>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="openai_api_key"><T>OpenAI API Key</T></Label>
<Input
id="openai_api_key"
type="password"
value={secrets.openai_api_key || ''}
onChange={(e) => setSecrets(prev => ({ ...prev, openai_api_key: e.target.value }))}
placeholder={translate("Enter your OpenAI API key")}
/>
<p className="text-sm text-muted-foreground">
<T>For AI image generation (stored securely)</T>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="replicate_api_key"><T>Replicate API Key</T></Label>
<Input
id="replicate_api_key"
type="password"
value={secrets.replicate_api_key || ''}
onChange={(e) => setSecrets(prev => ({ ...prev, replicate_api_key: e.target.value }))}
placeholder={translate("Enter your Replicate API key")}
/>
<p className="text-sm text-muted-foreground">
<T>For Replicate AI models (stored securely)</T>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="bria_api_key"><T>Bria API Key</T></Label>
<Input
id="bria_api_key"
type="password"
value={secrets.bria_api_key || ''}
onChange={(e) => setSecrets(prev => ({ ...prev, bria_api_key: e.target.value }))}
placeholder={translate("Enter your Bria API key")}
/>
<p className="text-sm text-muted-foreground">
<T>For Bria AI services (stored securely)</T>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="huggingface_api_key"><T>HuggingFace API Key</T></Label>
<Input
id="huggingface_api_key"
type="password"
value={secrets.huggingface_api_key || ''}
onChange={(e) => setSecrets(prev => ({ ...prev, huggingface_api_key: e.target.value }))}
placeholder={translate("Enter your HuggingFace API key")}
/>
<p className="text-sm text-muted-foreground">
<T>For HuggingFace models (stored securely)</T>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="aimlapi_api_key"><T>AIMLAPI API Key</T></Label>
<Input
id="aimlapi_api_key"
type="password"
value={secrets.aimlapi_api_key || ''}
onChange={(e) => setSecrets(prev => ({ ...prev, aimlapi_api_key: e.target.value }))}
placeholder={translate("Enter your AIMLAPI API key")}
/>
<p className="text-sm text-muted-foreground">
<T>For AIMLAPI services (stored securely)</T>
</p>
</div>
<Button
onClick={handleProfileUpdate}
disabled={updatingProfile}
className="w-full"
>
<Save className="mr-2 h-4 w-4" />
<T>{updatingProfile ? 'Saving...' : 'Save API Keys'}</T>
</Button>
</CardContent>
</Card>
)}
{activeSection === 'variables' && (
<Card>
<CardHeader>
<CardTitle><T>Variables</T></CardTitle>
</CardHeader>
<CardContent>
<VariablesEditor
onLoad={async () => {
if (!user?.id) return {};
return await getUserVariables(user.id) || {};
}}
onSave={async (data) => {
if (!user?.id) return;
await updateUserVariables(user.id, data);
}}
/>
</CardContent>
</Card>
)}
{activeSection === 'addresses' && (
<Card>
<CardHeader>
<CardTitle><T>Shipping Addresses</T></CardTitle>
</CardHeader>
<CardContent>
<ShippingAddressManager userId={user.id} />
</CardContent>
</Card>
)}
{activeSection === 'vendor' && (
<Card>
<CardHeader>
<CardTitle><T>Vendor Profiles</T></CardTitle>
</CardHeader>
<CardContent>
<VendorProfileManager userId={user.id} />
</CardContent>
</Card>
)}
{activeSection === 'purchases' && (
<Card>
<CardHeader>
<CardTitle><T>My Purchases</T></CardTitle>
</CardHeader>
<CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<LazyPurchasesList
onFetchTransactions={async () => {
const { listTransactions } = await import('@/modules/ecommerce/client-ecommerce');
return listTransactions();
}}
onNavigate={navigate}
toast={{ error: (msg: string) => toast.error(msg) }}
/>
</React.Suspense>
</CardContent>
</Card>
)}
{activeSection === 'gallery' && (
<Card>
<CardHeader>
<CardTitle><T>My Gallery</T></CardTitle>
</CardHeader>
<CardContent>
{fetchingImages ? (
<div className="flex items-center justify-center py-16">
<div className="text-muted-foreground"><T>Loading your images...</T></div>
</div>
) : (
<ImageGallery
images={images}
currentIndex={currentIndex}
setCurrentIndex={setCurrentIndex}
onImageDelete={handleImageDelete}
showSelection={false}
onDoubleClick={(imagePath) => {
navigate(`/post/${imagePath}`);
}}
/>
)}
</CardContent>
</Card>
)}
</div>
</main>
</div>
</SidebarProvider>
);
};
const ProfileSidebar = ({
activeSection,
onSectionChange
}: {
activeSection: ActiveSection;
onSectionChange: (section: ActiveSection) => void;
}) => {
const { state } = useSidebar();
const navigate = useNavigate();
const isCollapsed = state === "collapsed";
const menuItems = [
{ id: 'general' as ActiveSection, label: translate('General'), icon: User },
{ id: 'api-keys' as ActiveSection, label: translate('API Keys'), icon: Key },
{ id: 'variables' as ActiveSection, label: translate('Variables'), icon: Hash },
{ id: 'addresses' as ActiveSection, label: translate('Shipping Addresses'), icon: MapPin },
{ id: 'vendor' as ActiveSection, label: translate('Vendor Profiles'), icon: Building2 },
{ id: 'purchases' as ActiveSection, label: translate('Purchases'), icon: ShoppingBag },
];
return (
<Sidebar collapsible="icon">
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel><T>Profile</T></SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{menuItems.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
onClick={() => onSectionChange(item.id)}
className={activeSection === item.id ? "bg-muted text-primary font-medium" : "hover:bg-muted/50"}
>
<item.icon className="h-4 w-4" />
{!isCollapsed && <span>{item.label}</span>}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup className="mt-auto">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => onSectionChange('gallery')}
className={activeSection === 'gallery' ? "bg-muted text-primary font-medium" : "hover:bg-muted/50"}
>
<Images className="h-4 w-4" />
{!isCollapsed && <span><T>Gallery</T></span>}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
};
export default Profile;