gridsearch - discover
This commit is contained in:
parent
305c0c7045
commit
ea4cbfde07
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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>
|
||||
))
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 }),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
94
packages/ui/src/modules/profile/components/AvatarPicker.tsx
Normal file
94
packages/ui/src/modules/profile/components/AvatarPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
161
packages/ui/src/modules/profile/components/GeneralSettings.tsx
Normal file
161
packages/ui/src/modules/profile/components/GeneralSettings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
51
packages/ui/src/modules/profile/hooks/useAvatar.ts
Normal file
51
packages/ui/src/modules/profile/hooks/useAvatar.ts
Normal 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
|
||||
};
|
||||
}
|
||||
135
packages/ui/src/modules/profile/hooks/useProfile.ts
Normal file
135
packages/ui/src/modules/profile/hooks/useProfile.ts
Normal 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
|
||||
};
|
||||
}
|
||||
61
packages/ui/src/modules/profile/hooks/useUserImages.ts
Normal file
61
packages/ui/src/modules/profile/hooks/useUserImages.ts
Normal 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
|
||||
};
|
||||
}
|
||||
26
packages/ui/src/modules/profile/types.ts
Normal file
26
packages/ui/src/modules/profile/types.ts
Normal 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 },
|
||||
];
|
||||
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user