gridsearch - discover

This commit is contained in:
lovebird 2026-04-02 14:46:49 +02:00
parent 305c0c7045
commit ea4cbfde07
20 changed files with 1027 additions and 734 deletions

View File

@ -24,6 +24,7 @@ interface GalleryLargeProps {
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
visibilityFilter?: 'invisible' | 'private';
center?: boolean;
preset?: any;
}
const GalleryLarge = ({
@ -36,7 +37,8 @@ const GalleryLarge = ({
categoryIds,
contentType,
visibilityFilter,
center
center,
preset
}: GalleryLargeProps) => {
const { user } = useAuth();
const navigate = useNavigate();
@ -146,6 +148,7 @@ const GalleryLarge = ({
job={item.job}
responsive={item.responsive}
variant="feed"
preset={preset}
/>
);
})}

View File

@ -22,9 +22,10 @@ interface ListLayoutProps {
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
visibilityFilter?: 'invisible' | 'private';
center?: boolean;
preset?: any;
}
const ListItem = ({ item, isSelected, onClick }: { item: any, isSelected: boolean, onClick: () => void }) => {
const ListItem = ({ item, isSelected, onClick, preset }: { item: any, isSelected: boolean, onClick: () => void, preset?: any }) => {
const isExternal = item.type === 'page-external';
const domain = isExternal && item.meta?.url ? new URL(item.meta.url).hostname : null;
@ -46,9 +47,11 @@ const ListItem = ({ item, isSelected, onClick }: { item: any, isSelected: boolea
)}
</div>
<div className="text-xs text-muted-foreground line-clamp-3 mb-2">
{item.description}
</div>
{preset?.showDescription !== false && item.description && (
<div className="text-xs text-muted-foreground line-clamp-3 mb-2">
{item.description}
</div>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 min-w-0" onClick={(e) => { e.stopPropagation(); }}>
@ -120,7 +123,8 @@ export const ListLayout = ({
categoryIds,
contentType,
visibilityFilter,
center
center,
preset
}: ListLayoutProps) => {
const navigate = useNavigate();
const isMobile = useIsMobile();
@ -221,6 +225,7 @@ export const ListLayout = ({
item={post}
isSelected={!isMobileView && selectedId === post.id}
onClick={() => handleItemClick(post)}
preset={preset}
/>
</div>
));
@ -250,6 +255,7 @@ export const ListLayout = ({
item={post}
isSelected={!isMobileView && selectedId === post.id}
onClick={() => handleItemClick(post)}
preset={preset}
/>
</div>
))

View File

@ -63,6 +63,8 @@ const CategoryFeedWidget: React.FC<CategoryFeedWidgetProps> = ({
showFooter,
center,
columns,
showTitle,
showDescription,
variables,
searchQuery,
...rest
@ -184,6 +186,8 @@ const CategoryFeedWidget: React.FC<CategoryFeedWidgetProps> = ({
showSortBar={showSortBar}
showLayoutToggles={showLayoutToggles}
showFooter={showFooter}
showTitle={showTitle}
showDescription={showDescription}
center={center}
columns={columns === 'auto' ? 'auto' : (Number(columns) || 4)}
variables={variables}

View File

@ -14,6 +14,8 @@ export interface HomeWidgetProps {
showFooter?: boolean;
center?: boolean;
columns?: number | 'auto';
showTitle?: boolean;
showDescription?: boolean;
heading?: string;
headingLevel?: 'h1' | 'h2' | 'h3' | 'h4';
variables?: Record<string, any>;
@ -57,6 +59,8 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
showSortBar = true,
showLayoutToggles = true,
showFooter = true,
showTitle = true,
showDescription = false,
center = false,
columns = 'auto',
heading,
@ -340,11 +344,11 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
}
if (viewMode === 'grid') {
return <PhotoGrid key={refreshKey} navigationSource={feedSource} navigationSourceId={feedSourceId} sortBy={sortBy} showVideos={true} categorySlugs={categorySlugs} categoryIds={propCategoryId ? [propCategoryId] : undefined} contentType={contentType} visibilityFilter={visibilityFilter} center={center} columns={columns} />;
return <PhotoGrid key={refreshKey} navigationSource={feedSource} navigationSourceId={feedSourceId} sortBy={sortBy} showVideos={true} categorySlugs={categorySlugs} categoryIds={propCategoryId ? [propCategoryId] : undefined} contentType={contentType} visibilityFilter={visibilityFilter} center={center} columns={columns} preset={{ showTitle, showDescription }} />;
} else if (viewMode === 'large') {
return <GalleryLarge key={refreshKey} navigationSource={feedSource} navigationSourceId={feedSourceId} sortBy={sortBy} categorySlugs={categorySlugs} categoryIds={propCategoryId ? [propCategoryId] : undefined} contentType={contentType} visibilityFilter={visibilityFilter} center={center} />;
return <GalleryLarge key={refreshKey} navigationSource={feedSource} navigationSourceId={feedSourceId} sortBy={sortBy} categorySlugs={categorySlugs} categoryIds={propCategoryId ? [propCategoryId] : undefined} contentType={contentType} visibilityFilter={visibilityFilter} center={center} preset={{ showTitle, showDescription }} />;
}
return <ListLayout key={refreshKey} navigationSource={feedSource} navigationSourceId={feedSourceId} sortBy={sortBy} categorySlugs={categorySlugs} categoryIds={propCategoryId ? [propCategoryId] : undefined} contentType={contentType} visibilityFilter={visibilityFilter} center={center} />;
return <ListLayout key={refreshKey} navigationSource={feedSource} navigationSourceId={feedSourceId} sortBy={sortBy} categorySlugs={categorySlugs} categoryIds={propCategoryId ? [propCategoryId] : undefined} contentType={contentType} visibilityFilter={visibilityFilter} center={center} preset={{ showTitle, showDescription }} />;
};
return (

View File

@ -1022,6 +1022,8 @@ export function registerAllWidgets() {
showSortBar: true,
showLayoutToggles: true,
showFooter: false,
showTitle: true,
showDescription: false,
center: false,
columns: 'auto',
variables: {}
@ -1120,6 +1122,18 @@ export function registerAllWidgets() {
description: 'Show the site footer below the feed',
default: false
},
showTitle: {
type: 'boolean',
label: 'Show Title',
description: 'Display the picture/post title',
default: true
},
showDescription: {
type: 'boolean',
label: 'Show Description',
description: 'Display the picture/post description beneath the title',
default: false
},
center: {
type: 'boolean',
label: 'Center Content',

View File

@ -6,14 +6,13 @@ import {
type GridFilterModel, type GridSortModel, type GridColumnVisibilityModel, type GridPaginationModel
} from '@mui/x-data-grid';
import {
filterModelToParams, paramsToFilterModel,
sortModelToParams, paramsToSortModel,
visibilityModelToParams, paramsToVisibilityModel,
paramsToFilterModel,
paramsToSortModel,
paramsToVisibilityModel,
} from '@/components/grids/gridUtils';
import {
Contact, ContactGroup,
fetchContacts, createContact, updateContact, deleteContact, batchDeleteContacts,
importContacts, exportContacts,
fetchContactGroups, createContactGroup, deleteContactGroup,
addGroupMembers, fetchGroupMembers, removeGroupMember,
} from '@/modules/contacts/client-contacts';

View File

@ -133,11 +133,12 @@ export const retryPlacesGridSearchJob = async (id: string): Promise<any> => {
export const expandPlacesGridSearch = async (
parentId: string,
areas: { gid: string; name: string; level: number; raw?: any }[],
settings?: Record<string, any>
settings?: Record<string, any>,
viewportData?: { viewportSearch: boolean; viewportCenter: { lat: number; lng: number }; viewportZoom: number; types?: string[] }
): Promise<{ message: string; jobId: string }> => {
return apiClient<{ message: string; jobId: string }>(`/api/places/gridsearch/${parentId}/expand`, {
method: 'POST',
body: JSON.stringify({ areas, settings }),
body: JSON.stringify({ areas, settings, ...viewportData }),
});
};

View File

@ -14,10 +14,10 @@ import { POSTER_THEMES } from '../utils/poster-themes';
import { type PlaceFull } from '@polymech/shared';
import { type LogEntry } from '@/contexts/LogContext';
import ChatLogBrowser from '@/components/ChatLogBrowser';
import { GripVertical, Download, Code, FileSpreadsheet } from 'lucide-react';
import { GripVertical, Download, Code, FileSpreadsheet, Compass } from 'lucide-react';
import { LocationDetailView } from '../LocationDetail';
import { exportToCSV, exportToJSON } from './exportUtils';
import { T } from '@/i18n';
import { T, translate } from '@/i18n';
import {
DropdownMenu,
DropdownMenuContent,
@ -84,6 +84,17 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
gid: a.gid, name: a.name, level: a.level,
})) || undefined;
const defaultDiscoverQuery = restoredState?.run?.request?.search?.types?.[0] || restoredState?.run?.request?.types?.[0];
const [discoverQuery, setDiscoverQuery] = useState("");
const discoverQueryInitialized = React.useRef(false);
React.useEffect(() => {
if (restoredState && !discoverQueryInitialized.current) {
setDiscoverQuery(defaultDiscoverQuery || 'business,shop,factory,service,industry,company');
discoverQueryInitialized.current = true;
}
}, [restoredState, defaultDiscoverQuery]);
// Track regions picked in the map's GADM picker
const [pickedRegions, setPickedRegions] = useState<any[]>([]);
const [mapSimSettings, setMapSimSettings] = useState<any>(null);
@ -105,13 +116,20 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
setExpanding(true);
setExpandError(null);
try {
await expandPlacesGridSearch(jobId, freshRegions.map(r => ({
const res = await expandPlacesGridSearch(jobId, freshRegions.map(r => ({
gid: r.gid,
name: r.name,
level: r.level,
raw: r.raw || { level: r.level, gadmName: r.name, gid: r.gid },
})), mapSimSettings || undefined);
onExpandSubmitted?.();
setSearchParams(prev => {
const next = new URLSearchParams(prev);
next.set('jobId', res.jobId);
next.set('view', 'map');
return next;
});
} catch (e: any) {
setExpandError(e.message || 'Failed to expand search');
console.error('Expand failed:', e);
@ -120,6 +138,39 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
}
}, [jobId, freshRegions, onExpandSubmitted]);
const handleViewportExpand = useCallback(async () => {
const url = new URL(window.location.href);
const lat = parseFloat(url.searchParams.get('mapLat') || '0');
const lng = parseFloat(url.searchParams.get('mapLng') || '0');
const zoom = parseFloat(url.searchParams.get('mapZoom') || '15');
if (!discoverQuery.trim()) return;
setExpanding(true);
setExpandError(null);
try {
const res = await expandPlacesGridSearch(jobId, [], mapSimSettings || undefined, {
viewportSearch: true,
viewportCenter: { lat, lng },
viewportZoom: zoom,
types: [discoverQuery.trim()]
});
onExpandSubmitted?.();
setSearchParams(prev => {
const next = new URLSearchParams(prev);
next.set('jobId', res.jobId);
next.set('view', 'map');
return next;
});
} catch (e: any) {
setExpandError(e.message || 'Failed to start discover');
console.error('Discover failed:', e);
} finally {
setExpanding(false);
}
}, [jobId, mapSimSettings, onExpandSubmitted, discoverQuery]);
const [viewMode, setViewMode] = useState<ViewMode>(() => {
const urlView = searchParams.get('view') as ViewMode;
if (urlView && ['grid', 'thumb', 'map', 'meta', 'report', 'poster', ...(import.meta.env.DEV ? ['log'] : [])].includes(urlView)) return urlView;
@ -256,6 +307,36 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
<T>Expand</T> +{freshRegions.length}
</button>
)}
{isOwner && viewMode === 'map' && (
<div className="flex items-center gap-1.5 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-sm p-0.5 overflow-hidden">
<input
type="text"
className="px-2 py-1 text-xs border-none focus:ring-0 bg-transparent dark:text-gray-200 min-w-[140px] outline-none"
placeholder={translate("business,shop...")}
value={discoverQuery}
onChange={(e) => setDiscoverQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleViewportExpand();
}
}}
/>
<button
onClick={handleViewportExpand}
disabled={expanding || streaming || !discoverQuery.trim()}
className="flex items-center gap-1 px-3 py-1 bg-teal-600 hover:bg-teal-700 disabled:opacity-50 text-white text-xs font-medium rounded-md transition-colors"
title={translate("Discover new places in the current viewport")}
>
{expanding ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Compass className="w-3.5 h-3.5" />
)}
<T>Discover</T>
</button>
</div>
)}
{isOwner && expandError && (
<span className="text-xs text-red-500">{expandError}</span>
)}

View File

@ -64,6 +64,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
const [searchLimit, setSearchLimit] = useState(initialSettings?.searchLimit ?? 20);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [mapState, setMapState] = useState<{lat: number, lng: number, zoom: number} | null>(null);
const [pastSearches, setPastSearches] = useState<string[]>(() => {
try {
@ -88,7 +89,9 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
const handleNext = async () => {
if (step === 2 && !searchQuery.trim()) return;
if (step === 2 && !searchQuery.trim()) {
setSearchQuery('business,shop,factory,service,industry,company');
}
if (step === 1) { setStep(2); return; }
@ -139,8 +142,8 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
};
const res = await submitPlacesGridSearchJob({
region: regionNames,
types: [searchQuery],
region: regionNames || 'Manual Selection',
types: [searchQuery.trim() || 'business,shop,factory,service,industry,company'],
excludeTypes,
limitPerArea: searchLimit,
enrichers: enableEnrichments ? ['meta', 'emails'] : [],
@ -154,6 +157,40 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
setSubmitting(false);
};
const handleDiscover = async () => {
setSubmitting(true);
setError('');
try {
const excludeTypes = excludeTypesStr.split(',').map(s => s.trim()).filter(Boolean);
await saveGridSearchExcludeTypes(excludeTypes);
const q = searchQuery.trim() || 'business,shop,factory,service,industry,company';
if (!searchQuery.trim()) setSearchQuery(q);
// Save search to history
const newPastSearches = Array.from(new Set([q, ...pastSearches])).filter(Boolean).slice(0, 15);
localStorage.setItem('gridSearchPastQueries', JSON.stringify(newPastSearches));
setPastSearches(newPastSearches);
const res = await submitPlacesGridSearchJob({
region: 'Viewport Discover',
types: [q],
excludeTypes,
limitPerArea: searchLimit,
enrichers: enableEnrichments ? ['meta', 'emails'] : [],
viewportSearch: true,
viewportCenter: { lat: mapState?.lat || 0, lng: mapState?.lng || 0 },
viewportZoom: mapState?.zoom || 15,
guided: { areas: [], settings: simulatorSettings }
} as any);
onJobSubmitted(res.jobId);
setStep(5);
} catch (err: any) {
setError(err.message || translate('Failed to submit job'));
}
setSubmitting(false);
};
const handleReset = () => {
setStep(1);
setCollectedNodes([]);
@ -228,7 +265,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
autoFocus
onKeyDown={e => { if (e.key === 'Enter' && searchQuery) handleNext(); }}
onKeyDown={e => { if (e.key === 'Enter') handleNext(); }}
/>
<datalist id="past-searches">
{pastSearches.map(s => <option key={s} value={s} />)}
@ -247,6 +284,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
preset="Minimal"
places={[]}
onMapCenterUpdate={() => { }}
onMapMove={setMapState}
enrich={async () => { }}
isEnriching={false}
initialGadmRegions={initialGadmRegions}
@ -421,13 +459,24 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
</button>
{step < 4 ? (
<button
onClick={handleNext}
disabled={(step === 2 && !searchQuery.trim())}
className="flex items-center bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2.5 rounded-xl font-medium transition-colors disabled:opacity-50"
>
<T>Continue</T> <ChevronRight className="w-5 h-5 ml-1" />
</button>
<div className="flex space-x-3">
{step === 3 && (
<button
onClick={handleDiscover}
disabled={submitting || !mapState}
className="flex items-center bg-teal-600 hover:bg-teal-700 text-white px-6 py-2.5 rounded-xl font-medium transition-colors disabled:opacity-50"
>
{submitting ? <Loader2 className="w-5 h-5 animate-spin mr-2" /> : <MapPin className="w-5 h-5 mr-2" />}
<T>Discover Area</T>
</button>
)}
<button
onClick={handleNext}
className="flex items-center bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2.5 rounded-xl font-medium transition-colors disabled:opacity-50"
>
<T>Continue</T> <ChevronRight className="w-5 h-5 ml-1" />
</button>
</div>
) : (
<button
onClick={handleSubmit}

View File

@ -149,13 +149,13 @@ export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }:
return () => clearInterval(interval);
}, []);
const handleDelete = async (id: string, parentId?: string) => {
const handleDelete = async (id: string, parent?: string) => {
if (confirm('Are you sure you want to delete this search?')) {
try {
await deletePlacesGridSearch(id);
setPastSearches(prev => {
if (parentId) {
return prev.map(p => p.id === parentId ? { ...p, children: p.children?.filter(c => c.id !== id) } : p);
if (parent) {
return prev.map(p => p.id === parent ? { ...p, children: p.children?.filter(c => c.id !== id) } : p);
}
return prev.filter(s => s.id !== id);
});

View File

@ -0,0 +1,65 @@
import React from "react";
import { Save } from "lucide-react";
import { T, translate } from "@/i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CardContent } from "@/components/ui/card";
interface ApiKeysSettingsProps {
secrets: Record<string, string>;
handleSecretChange: (key: string, value: string) => void;
handleSecretFocus: (key: string) => void;
onSubmit: () => void;
updating: boolean;
}
export const ApiKeysSettings: React.FC<ApiKeysSettingsProps> = ({
secrets,
handleSecretChange,
handleSecretFocus,
onSubmit,
updating
}) => {
const fields = [
{ id: 'google_api_key', label: 'Google API Key', hint: 'For Google services' },
{ id: 'openai_api_key', label: 'OpenAI API Key', hint: 'For AI image generation' },
{ id: 'replicate_api_key', label: 'Replicate API Key', hint: 'For Replicate AI models' },
{ id: 'bria_api_key', label: 'Bria API Key', hint: 'For Bria AI services' },
{ id: 'huggingface_api_key', label: 'HuggingFace API Key', hint: 'For HuggingFace models' },
{ id: 'aimlapi_api_key', label: 'AIMLAPI API Key', hint: 'For AIMLAPI services' },
{ id: 'openrouter_api_key', label: 'OpenRouter API Key', hint: 'For OpenRouter AI models' },
{ id: 'serpapi_api_key', label: 'SerpAPI Key', hint: 'For search engine results' },
{ id: 'scrapeless_api_key', label: 'Scrapeless API Key', hint: 'For web scraping and crawling' },
];
return (
<CardContent className="space-y-4">
{fields.map(field => (
<div key={field.id} className="space-y-2">
<Label htmlFor={field.id}><T>{field.label}</T></Label>
<Input
id={field.id}
type="password"
value={secrets[field.id] || ''}
onChange={(e) => handleSecretChange(field.id, e.target.value)}
onFocus={() => handleSecretFocus(field.id)}
placeholder={translate(`Enter your ${field.label}`)}
/>
<p className="text-sm text-muted-foreground">
<T>{field.hint}</T> <T>(stored securely)</T>
</p>
</div>
))}
<Button
onClick={onSubmit}
disabled={updating}
className="w-full"
>
<Save className="mr-2 h-4 w-4" />
<T>{updating ? 'Saving...' : 'Save API Keys'}</T>
</Button>
</CardContent>
);
};

View File

@ -0,0 +1,94 @@
import React from "react";
import { Upload, Check, Camera } from "lucide-react";
import { T, translate } from "@/i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { ImageFile } from "@/types";
interface AvatarPickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
uploading: boolean;
onUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
galleryImages: ImageFile[];
onSelectFromGallery: (url: string) => void;
}
export const AvatarPicker: React.FC<AvatarPickerProps> = ({
open,
onOpenChange,
uploading,
onUpload,
galleryImages,
onSelectFromGallery
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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={uploading}
>
<label htmlFor="avatar-upload" className="cursor-pointer">
<Upload className="h-4 w-4 mr-2" />
{uploading ? translate('Uploading...') : translate('Choose File')}
<input
id="avatar-upload"
type="file"
accept="image/*"
onChange={onUpload}
className="hidden"
/>
</label>
</Button>
<span className="text-sm text-muted-foreground">
<T>Max 5MB, JPG/PNG/WebP</T>
</span>
</div>
</div>
{/* Select from Gallery */}
{galleryImages.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">
{galleryImages.map((image) => (
<button
key={image.path}
onClick={() => onSelectFromGallery(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.title || 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>
);
};

View File

@ -0,0 +1,161 @@
import React from "react";
import { User, Save, Key, Globe } from "lucide-react";
import { T, translate, supportedLanguages, setLanguage, getCurrentLang } from "@/i18n";
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 { CardContent } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ProfileData } from "../types";
import { AvatarPicker } from "./AvatarPicker";
import { ImageFile } from "@/types";
interface GeneralSettingsProps {
email: string;
setEmail: (val: string) => void;
profile: ProfileData;
setProfile: React.Dispatch<React.SetStateAction<ProfileData>>;
selectedLanguage: string;
setSelectedLanguage: (lang: string) => void;
resetPassword: (email: string) => void;
onSubmit: () => void;
updating: boolean;
avatarPickerProps: {
open: boolean;
onOpenChange: (open: boolean) => void;
uploading: boolean;
onUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
galleryImages: ImageFile[];
onSelectFromGallery: (url: string) => void;
};
}
export const GeneralSettings: React.FC<GeneralSettingsProps> = ({
email,
setEmail,
profile,
setProfile,
selectedLanguage,
setSelectedLanguage,
resetPassword,
onSubmit,
updating,
avatarPickerProps
}) => {
return (
<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>
<AvatarPicker {...avatarPickerProps} />
</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(email)}
>
<T>Change Password</T>
</Button>
</div>
<Button
onClick={() => {
onSubmit();
if (selectedLanguage !== getCurrentLang()) {
setLanguage(selectedLanguage as any);
}
}}
disabled={updating}
className="w-full"
>
<Save className="mr-2 h-4 w-4" />
<T>{updating ? 'Saving...' : 'Save Changes'}</T>
</Button>
</CardContent>
);
};

View File

@ -0,0 +1,44 @@
import React from "react";
import { T } from "@/i18n";
import { CardContent } from "@/components/ui/card";
import ImageGallery from "@/components/ImageGallery";
import { ImageFile } from "@/types";
interface ProfileGalleryProps {
fetching: boolean;
images: ImageFile[];
currentIndex: number;
setCurrentIndex: (index: number) => void;
onDelete: (id: string) => void;
onNavigate: (path: string) => void;
}
export const ProfileGallery: React.FC<ProfileGalleryProps> = ({
fetching,
images,
currentIndex,
setCurrentIndex,
onDelete,
onNavigate
}) => {
return (
<CardContent>
{fetching ? (
<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={onDelete}
showSelection={false}
onDoubleClick={(imagePath) => {
onNavigate(`/post/${imagePath}`);
}}
/>
)}
</CardContent>
);
};

View File

@ -0,0 +1,74 @@
import React from "react";
import { Images } from "lucide-react";
import { T } from "@/i18n";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar
} from "@/components/ui/sidebar";
import { ActiveSection, PROFILE_MENU_ITEMS } from "../types";
interface ProfileSidebarProps {
activeSection: ActiveSection;
onSectionChange: (section: ActiveSection) => void;
}
export const ProfileSidebar: React.FC<ProfileSidebarProps> = ({
activeSection,
onSectionChange
}) => {
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
const mainItems = PROFILE_MENU_ITEMS.filter(item => item.id !== 'gallery');
const galleryItem = PROFILE_MENU_ITEMS.find(item => item.id === 'gallery');
return (
<Sidebar collapsible="icon">
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel><T>Profile</T></SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{mainItems.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><T>{item.label}</T></span>}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup className="mt-auto">
<SidebarGroupContent>
<SidebarMenu>
{galleryItem && (
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => onSectionChange('gallery')}
className={activeSection === 'gallery' ? "bg-muted text-primary font-medium" : "hover:bg-muted/50"}
>
<galleryItem.icon className="h-4 w-4" />
{!isCollapsed && <span><T>{galleryItem.label}</T></span>}
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
};

View File

@ -0,0 +1,51 @@
import { useState } from "react";
import { toast } from "sonner";
import { translate } from "@/i18n";
import { uploadImage } from '@/lib/uploadUtils';
export function useAvatar(user: any, onAvatarUpdate: (url: string) => void) {
const [avatarDialogOpen, setAvatarDialogOpen] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !user) return;
if (!file.type.startsWith('image/')) {
toast.error(translate('Please select an image file'));
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error(translate('Image must be less than 5MB'));
return;
}
setUploadingAvatar(true);
try {
const { publicUrl } = await uploadImage(file, user.id);
onAvatarUpdate(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) => {
onAvatarUpdate(imageUrl);
setAvatarDialogOpen(false);
toast.success(translate('Avatar updated successfully'));
};
return {
avatarDialogOpen,
setAvatarDialogOpen,
uploadingAvatar,
handleAvatarUpload,
handleSelectFromGallery
};
}

View File

@ -0,0 +1,135 @@
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { translate } from "@/i18n";
import {
fetchProfileAPI,
updateProfileAPI,
getUserApiKeys,
updateUserSecrets,
updateUserEmail
} from "@/modules/user/client-user";
import { ProfileData } from "../types";
export function useProfile(user: any) {
const [profile, setProfile] = useState<ProfileData>({
username: '',
display_name: '',
bio: '',
avatar_url: '',
settings: {}
});
const [secrets, setSecrets] = useState<Record<string, string>>({});
const [dirtyKeys, setDirtyKeys] = useState<Set<string>>(new Set());
const [email, setEmail] = useState('');
const [updatingProfile, setUpdatingProfile] = useState(false);
useEffect(() => {
if (user) {
fetchProfile();
setEmail(user.email || '');
}
}, [user]);
const fetchProfile = async () => {
if (!user) return;
try {
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 || {}
});
}
try {
const apiKeys = await getUserApiKeys(user.id);
if (apiKeys) {
const loaded: Record<string, string> = {};
for (const [key, val] of Object.entries(apiKeys)) {
if (typeof val === 'string') {
loaded[key] = val;
} else if (val && typeof val === 'object' && 'has_key' in val) {
loaded[key] = val.has_key ? (val.masked || '••••••••') : '';
}
}
setSecrets(loaded);
setDirtyKeys(new Set());
}
} catch (e) {
console.warn('Could not load API keys:', e);
}
} 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 {
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 || {}
});
const keysToSave: Record<string, string> = {};
for (const key of dirtyKeys) {
const val = secrets[key];
if (val && val.trim()) {
keysToSave[key] = val;
}
}
if (Object.keys(keysToSave).length > 0) {
await updateUserSecrets(user.id, keysToSave);
}
if (email !== user.email && email.trim()) {
await updateUserEmail(email);
}
toast.success(translate('Profile updated successfully'));
return true;
} catch (error) {
console.error('Error updating profile:', error);
toast.error(translate('Failed to update profile'));
return false;
} finally {
setUpdatingProfile(false);
}
};
const handleSecretChange = (key: string, value: string) => {
setSecrets(prev => ({ ...prev, [key]: value }));
setDirtyKeys(prev => new Set(prev).add(key));
};
const handleSecretFocus = (key: string) => {
if (secrets[key] && !dirtyKeys.has(key)) {
setSecrets(prev => ({ ...prev, [key]: '' }));
setDirtyKeys(prev => new Set(prev).add(key));
}
};
return {
profile,
setProfile,
secrets,
handleSecretChange,
handleSecretFocus,
email,
setEmail,
updatingProfile,
handleProfileUpdate,
fetchProfile
};
}

View File

@ -0,0 +1,61 @@
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { translate } from "@/i18n";
import { fetchUserPictures, deletePicture } from "@/modules/posts/client-pictures";
import { ImageFile } from "@/types";
export function useUserImages(user: any) {
const [images, setImages] = useState<ImageFile[]>([]);
const [fetchingImages, setFetchingImages] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
if (user) {
fetchUserImages();
}
}, [user]);
const fetchUserImages = async () => {
if (!user) return;
try {
setFetchingImages(true);
const pictures = await fetchUserPictures(user.id);
const selected = pictures.filter((p: any) => p.is_selected);
const imageFiles: ImageFile[] = selected.map((picture: any) => ({
path: picture.id,
title: 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 (imageId: string) => {
try {
await deletePicture(imageId);
setImages(prevImages => prevImages.filter(img => img.path !== imageId));
toast.success(translate('Image deleted successfully'));
} catch (error) {
console.error('Error deleting image:', error);
toast.error(translate('Failed to delete image'));
}
};
return {
images,
fetchingImages,
currentIndex,
setCurrentIndex,
handleImageDelete,
fetchUserImages
};
}

View File

@ -0,0 +1,26 @@
import { User, Key, Hash, MapPin, Building2, BookUser, Send, Plug, ShoppingBag, Images } from "lucide-react";
import { translate } from "@/i18n";
export type ActiveSection = 'general' | 'api-keys' | 'variables' | 'addresses' | 'vendor' | 'gallery' | 'purchases' | 'contacts' | 'campaigns' | 'integrations' | 'smtp-servers';
export interface ProfileData {
username: string;
display_name: string;
bio: string;
avatar_url: string;
settings: Record<string, any>;
}
export const PROFILE_MENU_ITEMS = [
{ id: 'general' as ActiveSection, label: 'General', icon: User },
{ id: 'api-keys' as ActiveSection, label: 'API Keys', icon: Key },
{ id: 'variables' as ActiveSection, label: 'Hash Variables', icon: Hash },
{ id: 'addresses' as ActiveSection, label: 'Shipping Addresses', icon: MapPin },
{ id: 'vendor' as ActiveSection, label: 'Vendor Profiles', icon: Building2 },
{ id: 'contacts' as ActiveSection, label: 'Contacts', icon: BookUser },
{ id: 'campaigns' as ActiveSection, label: 'Campaigns', icon: Send },
{ id: 'integrations' as ActiveSection, label: 'IMAP Integrations', icon: Plug },
{ id: 'smtp-servers' as ActiveSection, label: 'SMTP Servers', icon: Send },
{ id: 'purchases' as ActiveSection, label: 'Purchases', icon: ShoppingBag },
{ id: 'gallery' as ActiveSection, label: 'Gallery', icon: Images },
];

View File

@ -1,40 +1,28 @@
import React, { useState, useEffect } from "react";
import React, { useState } 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, Routes, Route, useLocation } 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, BookUser, Send, Plug } 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 { getUserApiKeys, updateUserSecrets, getUserVariables, updateUserVariables, fetchProfileAPI, updateProfileAPI, updateUserEmail } from '@/modules/user/client-user';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
useSidebar
} from "@/components/ui/sidebar";
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import { T, getCurrentLang } from "@/i18n";
import { SidebarProvider } from "@/components/ui/sidebar";
// Types
import { ActiveSection } from "@/modules/profile/types";
// Hooks
import { useProfile } from "@/modules/profile/hooks/useProfile";
import { useAvatar } from "@/modules/profile/hooks/useAvatar";
import { useUserImages } from "@/modules/profile/hooks/useUserImages";
// Components
import { ProfileSidebar } from "@/modules/profile/components/ProfileSidebar";
import { GeneralSettings } from "@/modules/profile/components/GeneralSettings";
import { ApiKeysSettings } from "@/modules/profile/components/ApiKeysSettings";
import { ProfileGallery } from "@/modules/profile/components/ProfileGallery";
// Lazy Loaded Modules
const LazyPurchasesList = React.lazy(() =>
import("@polymech/ecommerce").then(m => ({ default: m.PurchasesList }))
);
const VariablesEditor = React.lazy(() =>
import('@/components/variables/VariablesEditor').then(m => ({ default: m.VariablesEditor }))
);
@ -57,18 +45,12 @@ const SmtpIntegrations = React.lazy(() =>
import('@/components/SmtpIntegrations').then(m => ({ default: m.SmtpIntegrations }))
);
type ActiveSection = 'general' | 'api-keys' | 'variables' | 'addresses' | 'vendor' | 'gallery' | 'purchases' | 'contacts' | 'campaigns' | 'integrations' | 'smtp-servers';
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 location = useLocation();
// Extract active section from pathname (e.g. /profile/api-keys -> api-keys)
// Extract active section from pathname
const pathParts = location.pathname.split('/');
const activeSection = (pathParts[2] || 'general') as ActiveSection;
@ -80,206 +62,37 @@ const Profile = () => {
}
};
// Profile data state
const [profile, setProfile] = useState({
username: '',
display_name: '',
bio: '',
avatar_url: '',
settings: {}
});
const [secrets, setSecrets] = useState<Record<string, string>>({});
const [dirtyKeys, setDirtyKeys] = useState<Set<string>>(new Set()); // Track which keys user actually edited
// State & Logic Hooks
const {
profile,
setProfile,
secrets,
handleSecretChange,
handleSecretFocus,
email,
setEmail,
updatingProfile,
handleProfileUpdate
} = useProfile(user);
const {
images,
fetchingImages,
currentIndex,
setCurrentIndex,
handleImageDelete
} = useUserImages(user);
const {
avatarDialogOpen,
setAvatarDialogOpen,
uploadingAvatar,
handleAvatarUpload,
handleSelectFromGallery
} = useAvatar(user, (url) => setProfile(prev => ({ ...prev, avatar_url: url })));
// Helper: update a secret key and mark it dirty
const handleSecretChange = (key: string, value: string) => {
setSecrets(prev => ({ ...prev, [key]: value }));
setDirtyKeys(prev => new Set(prev).add(key));
};
// Clear masked placeholder on focus so user can type real key
const handleSecretFocus = (key: string) => {
if (secrets[key] && !dirtyKeys.has(key)) {
setSecrets(prev => ({ ...prev, [key]: '' }));
setDirtyKeys(prev => new Set(prev).add(key));
}
};
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 || {}
});
}
// Load API keys — server GET returns raw values, password inputs mask them visually
try {
const apiKeys = await getUserApiKeys(user!.id);
if (apiKeys) {
// Runtime: server returns raw strings like { openai_api_key: "sk-xxx" }
// (TS type says {masked, has_key} but that's only from PUT response)
const loaded: Record<string, string> = {};
for (const [key, val] of Object.entries(apiKeys)) {
if (typeof val === 'string') {
loaded[key] = val; // raw string from GET
} else if (val && typeof val === 'object' && 'has_key' in val) {
loaded[key] = val.has_key ? (val.masked || '••••••••') : '';
}
}
setSecrets(loaded);
setDirtyKeys(new Set());
}
} catch (e) {
console.warn('Could not load API keys:', e);
}
} 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 || {}
});
// Only send keys the user actually edited (non-empty, dirty keys)
const keysToSave: Record<string, string> = {};
for (const key of dirtyKeys) {
const val = secrets[key];
if (val && val.trim()) {
keysToSave[key] = val;
}
}
if (Object.keys(keysToSave).length > 0) {
await updateUserSecrets(user.id, keysToSave);
}
// 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.id, // Use unique ID for path (which acts as the unique identifier)
title: 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 (imageId: string) => {
try {
// Find the image to delete
const imageToDelete = images.find(img => img.path === imageId);
if (!imageToDelete) return;
await deletePicture(imageId);
// Update local state
setImages(prevImages => prevImages.filter(img => img.path !== imageId));
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">
@ -316,186 +129,25 @@ const Profile = () => {
<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.title || 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>
<GeneralSettings
email={email}
setEmail={setEmail}
profile={profile}
setProfile={setProfile}
selectedLanguage={selectedLanguage}
setSelectedLanguage={setSelectedLanguage}
resetPassword={resetPassword}
onSubmit={handleProfileUpdate}
updating={updatingProfile}
avatarPickerProps={{
open: avatarDialogOpen,
onOpenChange: setAvatarDialogOpen,
uploading: uploadingAvatar,
onUpload: handleAvatarUpload,
galleryImages: images,
onSelectFromGallery: handleSelectFromGallery
}}
/>
</Card>
} />
@ -504,151 +156,13 @@ const Profile = () => {
<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) => handleSecretChange('google_api_key', e.target.value)}
onFocus={() => handleSecretFocus('google_api_key')}
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) => handleSecretChange('openai_api_key', e.target.value)}
onFocus={() => handleSecretFocus('openai_api_key')}
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) => handleSecretChange('replicate_api_key', e.target.value)}
onFocus={() => handleSecretFocus('replicate_api_key')}
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) => handleSecretChange('bria_api_key', e.target.value)}
onFocus={() => handleSecretFocus('bria_api_key')}
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) => handleSecretChange('huggingface_api_key', e.target.value)}
onFocus={() => handleSecretFocus('huggingface_api_key')}
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) => handleSecretChange('aimlapi_api_key', e.target.value)}
onFocus={() => handleSecretFocus('aimlapi_api_key')}
placeholder={translate("Enter your AIMLAPI API key")}
/>
<p className="text-sm text-muted-foreground">
<T>For AIMLAPI services (stored securely)</T>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="openrouter_api_key"><T>OpenRouter API Key</T></Label>
<Input
id="openrouter_api_key"
type="password"
value={secrets.openrouter_api_key || ''}
onChange={(e) => handleSecretChange('openrouter_api_key', e.target.value)}
onFocus={() => handleSecretFocus('openrouter_api_key')}
placeholder={translate("Enter your OpenRouter API key")}
/>
<p className="text-sm text-muted-foreground">
<T>For OpenRouter AI models (stored securely)</T>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="serpapi_api_key"><T>SerpAPI Key</T></Label>
<Input
id="serpapi_api_key"
type="password"
value={secrets.serpapi_api_key || ''}
onChange={(e) => handleSecretChange('serpapi_api_key', e.target.value)}
onFocus={() => handleSecretFocus('serpapi_api_key')}
placeholder={translate("Enter your SerpAPI key")}
/>
<p className="text-sm text-muted-foreground">
<T>For search engine results (stored securely)</T>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="scrapeless_api_key"><T>Scrapeless API Key</T></Label>
<Input
id="scrapeless_api_key"
type="password"
value={secrets.scrapeless_api_key || ''}
onChange={(e) => handleSecretChange('scrapeless_api_key', e.target.value)}
onFocus={() => handleSecretFocus('scrapeless_api_key')}
placeholder={translate("Enter your Scrapeless API key")}
/>
<p className="text-sm text-muted-foreground">
<T>For web scraping and crawling (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>
<ApiKeysSettings
secrets={secrets}
handleSecretChange={handleSecretChange}
handleSecretFocus={handleSecretFocus}
onSubmit={handleProfileUpdate}
updating={updatingProfile}
/>
</Card>
} />
@ -657,20 +171,18 @@ const Profile = () => {
<CardHeader>
<CardTitle><T>Variables</T></CardTitle>
</CardHeader>
<CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<VariablesEditor
onLoad={async () => {
if (!user?.id) return {};
return await getUserVariables(user.id) || {};
}}
onSave={async (data) => {
if (!user?.id) return;
await updateUserVariables(user.id, data);
}}
/>
</React.Suspense>
</CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<VariablesEditor
onLoad={async () => {
const { getUserVariables } = await import('@/modules/user/client-user');
return await getUserVariables(user.id) || {};
}}
onSave={async (data) => {
const { updateUserVariables } = await import('@/modules/user/client-user');
await updateUserVariables(user.id, data);
}}
/>
</React.Suspense>
</Card>
} />
@ -679,11 +191,9 @@ const Profile = () => {
<CardHeader>
<CardTitle><T>Shipping Addresses</T></CardTitle>
</CardHeader>
<CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<ShippingAddressManager userId={user.id} />
</React.Suspense>
</CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<ShippingAddressManager userId={user.id} />
</React.Suspense>
</Card>
} />
@ -692,11 +202,9 @@ const Profile = () => {
<CardHeader>
<CardTitle><T>Vendor Profiles</T></CardTitle>
</CardHeader>
<CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<VendorProfileManager userId={user.id} />
</React.Suspense>
</CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<VendorProfileManager userId={user.id} />
</React.Suspense>
</Card>
} />
@ -705,18 +213,18 @@ const Profile = () => {
<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>
<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) => {
import("sonner").then(m => m.toast.error(msg));
}}}
/>
</React.Suspense>
</Card>
} />
@ -725,11 +233,9 @@ const Profile = () => {
<CardHeader>
<CardTitle><T>Contacts</T></CardTitle>
</CardHeader>
<CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<ContactsManager />
</React.Suspense>
</CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<ContactsManager />
</React.Suspense>
</Card>
} />
@ -738,11 +244,9 @@ const Profile = () => {
<CardHeader>
<CardTitle><T>Campaigns</T></CardTitle>
</CardHeader>
<CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<CampaignsManager />
</React.Suspense>
</CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<CampaignsManager />
</React.Suspense>
</Card>
} />
@ -751,11 +255,9 @@ const Profile = () => {
<CardHeader>
<CardTitle><T>IMAP Integrations</T></CardTitle>
</CardHeader>
<CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<GmailIntegrations />
</React.Suspense>
</CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<GmailIntegrations />
</React.Suspense>
</Card>
} />
@ -764,11 +266,9 @@ const Profile = () => {
<CardHeader>
<CardTitle><T>SMTP Servers</T></CardTitle>
</CardHeader>
<CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<SmtpIntegrations />
</React.Suspense>
</CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<SmtpIntegrations />
</React.Suspense>
</Card>
} />
@ -777,101 +277,22 @@ const Profile = () => {
<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>
<ProfileGallery
fetching={fetchingImages}
images={images}
currentIndex={currentIndex}
setCurrentIndex={setCurrentIndex}
onDelete={handleImageDelete}
onNavigate={navigate}
/>
</Card>
} />
</Routes>
</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: 'contacts' as ActiveSection, label: translate('Contacts'), icon: BookUser },
{ id: 'campaigns' as ActiveSection, label: translate('Campaigns'), icon: Send },
{ id: 'integrations' as ActiveSection, label: translate('IMAP Integrations'), icon: Plug },
{ id: 'smtp-servers' as ActiveSection, label: translate('SMTP Servers'), icon: Send },
{ 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;