This commit is contained in:
babayaga 2026-01-20 10:34:09 +01:00
parent 47714d78e3
commit 62b6fed0e6
218 changed files with 45584 additions and 3 deletions

View File

@ -27,14 +27,11 @@
"test:all": "playwright test",
"test:home": "playwright test tests/home.spec.ts --project=chromium",
"test:post": "playwright test tests/post.spec.ts --project=chromium",
"test:wizard": "playwright test tests/wizard.spec.ts --project=chromium --ui",
"test:responsive": "playwright test tests/responsive.spec.ts --project=chromium",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed --project=chromium",
"test:debug": "playwright test --debug",
"test:report": "playwright show-report",
"test:verify-env": "node tests/verify-env.js",
"supabase:types": "npx supabase gen types typescript --linked > src/integrations/supabase/types.ts",
"screenshots": "playwright test tests/example.spec.ts"
},
"dependencies": {

View File

@ -0,0 +1,440 @@
/**
* AI Page Generator Component
* A specialized version of AITextGenerator for creating new pages from scratch.
* It removes application-specific logic like 'Apply', 'Replace', 'Append'.
*/
import React from 'react';
import { T } from '@/i18n';
import {
Sparkles,
Mic,
MicOff,
Loader2,
FileTextIcon,
Plus,
Trash2,
ArrowUp,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ProviderSelector } from '@/components/filters/ProviderSelector';
import { ModelSelector } from '@/components/ImageWizard/components/ModelSelector';
import { useVoiceInput } from '@/hooks/useVoiceInput';
import { useProviderSettings } from '@/hooks/useProviderSettings';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { Image as ImageIcon, X } from 'lucide-react';
import { useLog } from '@/contexts/LogContext';
interface AIPageGeneratorProps {
// Prompt state
prompt: string;
onPromptChange: (prompt: string) => void;
// Actions
onGenerate: (options: { useImageTools: boolean; model?: string; imageModel?: string; referenceImages?: string[] }) => void;
onCancel?: () => void;
// State flags
isGenerating: boolean;
generationStatus?: 'idle' | 'transcribing' | 'generating' | 'creating' | 'success' | 'error';
disabled?: boolean;
promptHistory?: string[];
historyIndex?: number;
onNavigateHistory?: (direction: 'up' | 'down') => void;
initialReferenceImages?: string[];
}
export const AIPageGenerator: React.FC<AIPageGeneratorProps> = ({
prompt,
onPromptChange,
onGenerate,
onCancel,
isGenerating,
generationStatus,
disabled = false,
promptHistory = [],
historyIndex = -1,
onNavigateHistory,
initialReferenceImages = []
}) => {
const {
loading: loadingSettings,
selectedProvider,
selectedModel,
onProviderChange,
onModelChange,
} = useProviderSettings();
const [imageToolsEnabled, setImageToolsEnabled] = React.useState(true);
const [referenceImages, setReferenceImages] = React.useState<any[]>([]); // Using any[] for now to avoid extensive type imports, will refine
const [showImagePicker, setShowImagePicker] = React.useState(false);
// Initialize reference images from prop
React.useEffect(() => {
if (initialReferenceImages.length > 0) {
const initialPics = initialReferenceImages.map((url, index) => ({
id: `preload-${index}`,
url: url,
title: 'Selected Image',
image_url: url,
width: 0,
height: 0,
created_at: new Date().toISOString(),
user_id: 'current-user'
}));
setReferenceImages(initialPics);
}
}, [initialReferenceImages]);
// Image model state with persistence
const [imageModel, setImageModel] = React.useState<string>(() => {
return localStorage.getItem('aipagegenerator-image-model') || 'google/gemini-3-pro-image-preview';
});
// Save image model to localStorage when it changes
React.useEffect(() => {
if (imageModel) {
localStorage.setItem('aipagegenerator-image-model', imageModel);
}
}, [imageModel]);
// Templates and optimization state removed as they are not fully implemented
// const [templates, setTemplates] = React.useState<{ name: string; template: string }[]>([]);
const [isOptimizing, setIsOptimizing] = React.useState(false);
const { isRecording, isTranscribing, handleMicrophoneToggle } = useVoiceInput(onPromptChange);
const { logs } = useLog();
const logsEndRef = React.useRef<HTMLDivElement>(null);
// Auto-scroll logs
React.useEffect(() => {
if (isGenerating && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs, isGenerating]);
const getStatusMessage = () => {
switch (generationStatus) {
case 'transcribing': return 'Transcribing audio...';
case 'generating': return 'Generating content...';
case 'creating': return 'Creating page...';
case 'success': return 'Success!';
default: return onCancel ? 'Cancel Generation' : 'Generating...';
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.key === 'Enter' && e.ctrlKey) && prompt.trim() && !isGenerating) {
e.preventDefault();
onGenerate({
useImageTools: imageToolsEnabled,
model: selectedModel,
imageModel,
referenceImages: referenceImages.map(img => img.image_url || img.src)
});
} else if (e.key === 'ArrowUp' && e.ctrlKey && onNavigateHistory) {
e.preventDefault();
onNavigateHistory('up');
} else if (e.key === 'ArrowDown' && e.ctrlKey && onNavigateHistory) {
e.preventDefault();
onNavigateHistory('down');
}
};
return (
<div className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left Column: Settings */}
<Card className="p-4 space-y-3 bg-muted/30">
<h4 className="text-sm font-semibold">
<T>Settings</T>
</h4>
<ProviderSelector
provider={selectedProvider}
model={selectedModel}
onProviderChange={onProviderChange}
onModelChange={onModelChange}
disabled={disabled || isGenerating || isOptimizing || loadingSettings}
showManagement={true}
/>
<div className="flex items-center justify-between p-3 bg-background rounded-lg border">
<div className="flex items-center space-x-2">
<Switch
id="image-tools"
checked={imageToolsEnabled}
onCheckedChange={setImageToolsEnabled}
disabled={disabled || isGenerating || isOptimizing}
/>
<Label htmlFor="image-tools" className="text-sm font-medium cursor-pointer">
<T>Image Tools</T>
</Label>
</div>
<Badge variant={imageToolsEnabled ? 'default' : 'secondary'} className="text-xs">
{imageToolsEnabled ? 'Enabled' : 'Text Only'}
</Badge>
</div>
{imageToolsEnabled && (
<div className="pt-2">
<ModelSelector
selectedModel={imageModel}
onChange={setImageModel}
label="Image Model"
showStepNumber={false}
/>
</div>
)}
{imageToolsEnabled && (
<div className="pt-2">
<div className="flex items-center justify-between mb-2">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<T>Reference Images</T>
</Label>
<span className="text-xs text-muted-foreground">
{referenceImages.length} selected
</span>
</div>
{referenceImages.length === 0 ? (
<Button
variant="outline"
className="w-full h-20 border-dashed flex flex-col items-center justify-center mb-2 text-muted-foreground hover:text-primary hover:border-primary/50 hover:bg-accent/50 transition-all"
onClick={() => setShowImagePicker(true)}
disabled={disabled}
>
<ImageIcon className="h-5 w-5 mb-1 opacity-50" />
<span className="text-xs font-medium"><T>Select Reference Images</T></span>
</Button>
) : (
<div className="grid grid-cols-3 gap-2 mb-2">
{referenceImages.map((img) => (
<div key={img.id} className="relative aspect-square rounded-md overflow-hidden border group bg-muted/30">
<img
src={img.image_url || img.src}
alt={img.title}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<button
onClick={() => setReferenceImages(prev => prev.filter(i => i.id !== img.id))}
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-destructive text-white rounded-full opacity-0 group-hover:opacity-100 transition-all transform scale-90 group-hover:scale-100"
title="Remove image"
>
<X className="h-3 w-3" />
</button>
</div>
))}
<Button
variant="outline"
className="aspect-square flex flex-col items-center justify-center p-0 border-dashed hover:border-primary hover:text-primary hover:bg-accent/50 transition-all"
onClick={() => setShowImagePicker(true)}
disabled={disabled}
title="Add more images"
>
<Plus className="h-5 w-5 mb-1 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"><T>Add</T></span>
</Button>
</div>
)}
<p className="text-xs text-muted-foreground">
<T>Images to help guide the AI generation</T>
</p>
</div>
)}
<ImagePickerDialog
isOpen={showImagePicker}
onClose={() => setShowImagePicker(false)}
multiple={true}
currentValues={referenceImages.map(img => img.id)}
onMultiSelectPictures={(pictures) => {
setReferenceImages(pictures);
setShowImagePicker(false);
}}
/>
<div className="flex flex-wrap gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={disabled}
className="flex-1"
>
<FileTextIcon className="h-3 w-3 mr-2" />
<T>Templates</T>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* Templates logic would go here */}
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
<T>No templates saved yet</T>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem disabled>
<Plus className="h-3 w-3 mr-2" />
<T>Save current as template</T>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
onClick={() => { /* Optimize logic */ }}
disabled={disabled || isOptimizing || !prompt.trim()}
className="flex-1"
>
{isOptimizing ? (
<>
<Loader2 className="h-3 w-3 mr-2 animate-spin" />
<T>Optimizing...</T>
</>
) : (
<>
<Sparkles className="h-3 w-3 mr-2" />
<T>Optimize</T>
</>
)}
</Button>
</div>
</Card>
{/* Right Column: Prompt Input */}
<div className="space-y-3 flex flex-col">
{promptHistory.length > 0 && onNavigateHistory && (
<div className="flex items-center justify-between p-2 bg-muted/30 rounded-lg border">
<span className="text-xs text-muted-foreground">
<T>History</T>: {historyIndex >= 0 ? `${historyIndex + 1}/${promptHistory.length}` : 'Current'}
</span>
<div className="flex gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onNavigateHistory('up')}
disabled={historyIndex >= promptHistory.length - 1}
className="h-7 px-2"
title="Previous prompt (Ctrl+↑)"
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onNavigateHistory('down')}
disabled={historyIndex < 0}
className="h-7 px-2"
title="Next prompt (Ctrl+↓)"
>
<ArrowUp className="h-3 w-3 rotate-180" />
</Button>
</div>
</div>
)}
<div className="relative flex-grow flex flex-col">
<Textarea
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
placeholder={"Describe the page you want to generate...\\n\\nKeyboard shortcuts:\\n• Ctrl+Enter: Generate\\n• Ctrl+↑/↓: Navigate history"}
className="resize-none pr-10 flex-grow"
onKeyDown={handleKeyDown}
disabled={disabled}
rows={10}
/>
<button
onClick={handleMicrophoneToggle}
disabled={isTranscribing || disabled}
className={`absolute right-2 bottom-2 p-1.5 rounded-md transition-colors ${isRecording
? 'bg-red-100 text-red-600 hover:bg-red-200'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={isRecording ? 'Stop recording' : 'Record audio'}
>
{isTranscribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isRecording ? (
<MicOff className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
</button>
</div>
{isGenerating && (
<div className="mb-2 p-3 bg-zinc-950 text-green-400 font-mono text-xs rounded-md h-32 overflow-y-auto border border-zinc-800 shadow-inner">
<div className="flex flex-col gap-1.5">
{logs.slice(-20).map((log) => (
<div key={log.id} className="break-words leading-tight">
<span className="opacity-40 select-none mr-2">
{log.timestamp.toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className={log.level === 'error' ? 'text-red-400' : log.level === 'warning' ? 'text-yellow-400' : ''}>
{log.message}
</span>
</div>
))}
<div ref={logsEndRef} />
</div>
</div>
)}
<Button
onClick={isGenerating && onCancel ? onCancel : () => onGenerate({
useImageTools: imageToolsEnabled,
model: selectedModel,
imageModel,
referenceImages: referenceImages.map(img => img.image_url || img.src)
})}
disabled={disabled || (!isGenerating && !prompt.trim())}
className="w-full"
size="lg"
variant={isGenerating && onCancel ? 'destructive' : 'default'}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<T>{getStatusMessage()}</T>
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
<T>{imageToolsEnabled ? 'Generate with Images' : 'Generate Text Only'}</T>
</>
)}
</Button>
{imageToolsEnabled && (
<div className="p-3 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800 space-y-1">
<div className="text-xs font-medium text-blue-700 dark:text-blue-300">
💡 <T>Image Tools enabled: AI can generate and embed images in the content</T>
</div>
<div className="text-xs text-blue-600 dark:text-blue-400">
<T>Note: Image Tools require an OpenAI provider and will use your selected OpenAI model</T>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default AIPageGenerator;

View File

@ -0,0 +1,614 @@
/**
* AI Text Generator Component
* 2-column layout: Settings (left) + Prompt Input (right)
* Mobile-friendly: Stacks vertically on small screens
*/
import React, { useState, useCallback } from 'react';
import { T, translate } from '@/i18n';
import {
Sparkles,
Mic,
MicOff,
Loader2,
FileTextIcon,
Plus,
Trash2,
ArrowUp,
Image as ImageIcon,
X,
} from 'lucide-react';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
// Define Picture type locally if not imported (matches usages in other files)
interface Picture {
id: string;
image_url: string;
title: string;
[key: string]: any;
}
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ProviderSelector } from '@/components/filters/ProviderSelector';
interface AITextGeneratorProps {
// Prompt state
prompt: string;
onPromptChange: (prompt: string) => void;
// Provider state
provider: string;
model: string;
onProviderChange: (provider: string) => void;
onModelChange: (model: string) => void;
// Image tools
imageToolsEnabled: boolean;
onImageToolsChange: (enabled: boolean) => void;
// Web Search
webSearchEnabled: boolean;
onWebSearchChange: (enabled: boolean) => void;
// Context mode
contextMode: 'clear' | 'selection' | 'all';
onContextModeChange: (mode: 'clear' | 'selection' | 'all') => void;
hasSelection: boolean;
selectionLength?: number;
hasContent: boolean;
contentLength?: number;
// Application mode
applicationMode: 'replace' | 'insert' | 'append';
onApplicationModeChange: (mode: 'replace' | 'insert' | 'append') => void;
// Stream mode
streamMode?: boolean;
onStreamModeChange?: (enabled: boolean) => void;
// Templates
templates: Array<{ name: string; template: string }>;
onApplyTemplate: (template: string) => void;
onSaveTemplate: () => void;
onDeleteTemplate: (index: number) => void;
// Actions
onGenerate: (options?: { referenceImages?: string[] }) => void;
onOptimize: () => void;
onCancel?: () => void;
// State flags
isGenerating: boolean;
isOptimizing: boolean;
disabled?: boolean;
// Voice recording
onMicrophoneToggle?: () => void;
isRecording?: boolean;
isTranscribing?: boolean;
// Prompt history
promptHistory?: string[];
historyIndex?: number;
onNavigateHistory?: (direction: 'up' | 'down') => void;
}
export const AITextGenerator: React.FC<AITextGeneratorProps> = ({
prompt,
onPromptChange,
provider,
model,
onProviderChange,
onModelChange,
imageToolsEnabled,
onImageToolsChange,
webSearchEnabled,
onWebSearchChange,
contextMode,
onContextModeChange,
hasSelection,
selectionLength = 0,
hasContent,
contentLength = 0,
applicationMode,
onApplicationModeChange,
streamMode,
onStreamModeChange,
templates,
onApplyTemplate,
onSaveTemplate,
onDeleteTemplate,
onGenerate,
onOptimize,
onCancel,
isGenerating,
isOptimizing,
disabled = false,
onMicrophoneToggle,
isRecording = false,
isTranscribing = false,
promptHistory = [],
historyIndex = -1,
onNavigateHistory,
}) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Ctrl+Enter to generate
if ((e.key === 'Enter' && e.ctrlKey) && prompt.trim() && !isGenerating) {
e.preventDefault();
onGenerate();
}
// Ctrl+Up to navigate to previous prompt in history
else if (e.key === 'ArrowUp' && e.ctrlKey && onNavigateHistory) {
e.preventDefault();
onNavigateHistory('up');
}
// Ctrl+Down to navigate to next prompt in history
else if (e.key === 'ArrowDown' && e.ctrlKey && onNavigateHistory) {
e.preventDefault();
onNavigateHistory('down');
}
};
const [referenceImages, setReferenceImages] = useState<Picture[]>([]);
const [showImagePicker, setShowImagePicker] = useState(false);
const handleReferenceImagesSelect = (pictures: Picture[]) => {
// Merge new pictures with existing ones, avoiding duplicates by ID
setReferenceImages(prev => {
const existingIds = new Set(prev.map(p => p.id));
const newPictures = pictures.filter(p => !existingIds.has(p.id));
return [...prev, ...newPictures];
});
setShowImagePicker(false);
};
return (
<div className="space-y-4">
{/* 2-Column Layout: Settings (left) + Prompt (right) */}
{/* Single Column Layout for Side Panel */}
<div className="flex flex-col gap-4">
{/* Settings Area */}
<Card className="p-4 space-y-3 bg-muted/30">
<h4 className="text-sm font-semibold">
<T>Settings</T>
</h4>
{/* Provider & Model Selection */}
<ProviderSelector
provider={provider}
model={model}
onProviderChange={onProviderChange}
onModelChange={onModelChange}
disabled={disabled || isGenerating || isOptimizing}
showManagement={true}
/>
{/* Image Tools Toggle */}
<div className="flex items-center justify-between p-3 bg-background rounded-lg border">
<div className="flex items-center space-x-2">
<Switch
id="image-tools"
checked={imageToolsEnabled}
onCheckedChange={onImageToolsChange}
disabled={disabled || isGenerating || isOptimizing}
/>
<Label htmlFor="image-tools" className="text-sm font-medium cursor-pointer">
<T>Image Tools</T>
</Label>
</div>
<Badge variant={imageToolsEnabled ? 'default' : 'secondary'} className="text-xs">
{imageToolsEnabled ? 'Enabled' : 'Text Only'}
</Badge>
</div>
{/* Web Search Toggle */}
<div className="flex items-center justify-between p-3 bg-background rounded-lg border">
<div className="flex items-center space-x-2">
<Switch
id="web-search"
checked={webSearchEnabled}
onCheckedChange={onWebSearchChange}
disabled={disabled || isGenerating || isOptimizing}
/>
<Label htmlFor="web-search" className="text-sm font-medium cursor-pointer">
<T>Web Search</T>
</Label>
</div>
<Badge variant={webSearchEnabled ? 'default' : 'secondary'} className="text-xs">
{webSearchEnabled ? 'On' : 'Off'}
</Badge>
</div>
{/* Context Mode Selection */}
<div className="space-y-2">
<h4 className="text-sm font-semibold">
<T>Context Source</T>
</h4>
<Card className="border-muted bg-background">
<CardContent className="p-3 space-y-2">
<RadioGroup value={contextMode} onValueChange={(value: 'clear' | 'selection' | 'all') => onContextModeChange(value)}>
{/* Prompt Only */}
<div className="flex items-center space-x-2">
<RadioGroupItem value="clear" id="ctx-clear" />
<Label htmlFor="ctx-clear" className="text-sm cursor-pointer">
<T>Prompt Only</T>
<Badge variant="outline" className="ml-2 text-xs text-muted-foreground font-normal">
No context
</Badge>
</Label>
</div>
{/* Selected Text */}
<div className="flex items-center space-x-2">
<RadioGroupItem value="selection" id="ctx-selection" disabled={!hasSelection} />
<Label htmlFor="ctx-selection" className={`text-sm cursor-pointer ${!hasSelection ? 'opacity-50' : ''}`}>
<T>Selected Text</T>
{hasSelection ? (
<Badge variant="secondary" className="ml-2 text-xs">
{selectionLength} chars
</Badge>
) : (
<Badge variant="outline" className="ml-2 text-xs text-muted-foreground font-normal">
None
</Badge>
)}
</Label>
</div>
{/* Entire Document */}
<div className="flex items-center space-x-2">
<RadioGroupItem value="all" id="ctx-all" disabled={!hasContent} />
<Label htmlFor="ctx-all" className={`text-sm cursor-pointer ${!hasContent ? 'opacity-50' : ''}`}>
<T>Entire Document</T>
{hasContent ? (
<Badge variant="secondary" className="ml-2 text-xs">
{contentLength} chars
</Badge>
) : (
<Badge variant="outline" className="ml-2 text-xs text-muted-foreground font-normal">
Empty
</Badge>
)}
</Label>
</div>
</RadioGroup>
</CardContent>
</Card>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
{/* Templates Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={disabled}
className="flex-1"
>
<FileTextIcon className="h-3 w-3 mr-2" />
<T>Templates</T>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{templates.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
<T>No templates saved yet</T>
</div>
) : (
<>
{templates.map((template, index) => (
<DropdownMenuItem
key={index}
onSelect={() => onApplyTemplate(template.template)}
className="flex items-center justify-between group"
>
<span className="flex-1 truncate">{template.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteTemplate(index);
}}
className="opacity-0 group-hover:opacity-100 ml-2 p-1 hover:bg-destructive/20 rounded"
>
<Trash2 className="h-3 w-3 text-destructive" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onSelect={onSaveTemplate}>
<Plus className="h-3 w-3 mr-2" />
<T>Save current as template</T>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Optimize Button */}
<Button
variant="outline"
size="sm"
onClick={onOptimize}
disabled={disabled || isOptimizing || !prompt.trim()}
className="flex-1"
>
{isOptimizing ? (
<>
<Loader2 className="h-3 w-3 mr-2 animate-spin" />
<T>Optimizing...</T>
</>
) : (
<>
<Sparkles className="h-3 w-3 mr-2" />
<T>Optimize</T>
</>
)}
</Button>
</div>
</Card>
{/* Prompt Input Area */}
<div className="space-y-3">
{/* Application Mode Toggle - Above Prompt */}
<div className="flex items-center gap-2 p-2 bg-muted/30 rounded-lg border">
<span className="text-xs font-medium text-muted-foreground">
<T>Apply:</T>
</span>
<div className="grid grid-cols-3 gap-1 flex-1">
<Button
variant={applicationMode === 'replace' ? 'default' : 'outline'}
size="sm"
onClick={() => onApplicationModeChange('replace')}
disabled={disabled || isGenerating || isOptimizing || !hasSelection}
className="h-7 text-xs"
title={!hasSelection ? 'Select text to enable replace mode' : 'Replace selected text with generated content'}
>
<T>Replace</T>
</Button>
<Button
variant={applicationMode === 'insert' ? 'default' : 'outline'}
size="sm"
onClick={() => onApplicationModeChange('insert')}
disabled={disabled || isGenerating || isOptimizing}
className="h-7 text-xs"
title="Add with single line break (clean append)"
>
<T>Insert</T>
</Button>
<Button
variant={applicationMode === 'append' ? 'default' : 'outline'}
size="sm"
onClick={() => onApplicationModeChange('append')}
disabled={disabled || isGenerating || isOptimizing}
className="h-7 text-xs"
title="Append at end of document with separator"
>
<T>Append</T>
</Button>
</div>
</div>
{/* Stream Toggle */}
{onStreamModeChange && (
<div className="flex items-center justify-between p-3 bg-background rounded-lg border">
<Label htmlFor="stream-mode" className="text-sm font-medium cursor-pointer">
<T>Stream Output</T>
</Label>
<Switch
id="stream-mode"
checked={streamMode}
onCheckedChange={onStreamModeChange}
disabled={disabled || isGenerating || isOptimizing}
/>
</div>
)}
{/* History Navigation - Above Textarea */}
{promptHistory.length > 0 && onNavigateHistory && (
<div className="flex items-center justify-between p-2 bg-muted/30 rounded-lg border">
<span className="text-xs text-muted-foreground">
<T>History</T>: {historyIndex >= 0 ? `${historyIndex + 1}/${promptHistory.length}` : 'Current'}
</span>
<div className="flex gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onNavigateHistory('up')}
disabled={promptHistory.length === 0 || historyIndex === promptHistory.length - 1}
className="h-7 px-2"
title="Previous prompt (Ctrl+↑)"
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onNavigateHistory('down')}
disabled={historyIndex === -1}
className="h-7 px-2"
title="Next prompt (Ctrl+↓)"
>
<ArrowUp className="h-3 w-3 rotate-180" />
</Button>
</div>
</div>
)}
<div className="relative">
<Textarea
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
placeholder={translate("Describe what you want to generate...\n\nKeyboard shortcuts:\n• Ctrl+Enter: Generate\n• Ctrl+↑/↓: Navigate history")}
rows={imageToolsEnabled ? 8 : 6}
className="resize-none pr-10"
onKeyDown={handleKeyDown}
disabled={disabled}
/>
{onMicrophoneToggle && (
<button
onClick={onMicrophoneToggle}
disabled={isTranscribing || disabled}
className={`absolute right-2 bottom-2 p-1.5 rounded-md transition-colors ${isRecording
? 'bg-red-100 text-red-600 hover:bg-red-200'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={isRecording ? 'Stop recording' : 'Record audio'}
>
{isTranscribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isRecording ? (
<MicOff className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
</button>
)}
</div>
{/* Reference Images Section */}
{imageToolsEnabled && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">
<T>Reference Images</T>
</h4>
<span className="text-xs text-muted-foreground">
{referenceImages.length} selected
</span>
</div>
{referenceImages.length === 0 ? (
<Button
variant="outline"
className="w-full h-16 border-dashed flex flex-col items-center justify-center mb-2 text-muted-foreground hover:text-primary hover:border-primary/50 hover:bg-accent/50 transition-all"
onClick={() => setShowImagePicker(true)}
disabled={disabled}
>
<ImageIcon className="h-4 w-4 mb-1 opacity-50" />
<span className="text-xs font-medium"><T>Select Reference Images</T></span>
</Button>
) : (
<div className="grid grid-cols-4 gap-2 mb-2">
{referenceImages.map((img) => (
<div key={img.id} className="relative aspect-square rounded-md overflow-hidden border group bg-muted/30">
<img
src={img.image_url || img.src}
alt={img.title}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<button
onClick={() => setReferenceImages(prev => prev.filter(i => i.id !== img.id))}
className="absolute top-1 right-1 p-0.5 bg-black/50 hover:bg-destructive text-white rounded-full opacity-0 group-hover:opacity-100 transition-all transform scale-90 group-hover:scale-100"
title="Remove image"
>
<X className="h-3 w-3" />
</button>
</div>
))}
<Button
variant="outline"
className="aspect-square flex flex-col items-center justify-center p-0 border-dashed hover:border-primary hover:text-primary hover:bg-accent/50 transition-all"
onClick={() => setShowImagePicker(true)}
disabled={disabled}
title="Add more images"
>
<Plus className="h-4 w-4 mb-0.5 text-muted-foreground" />
<span className="text-[9px] text-muted-foreground"><T>Add</T></span>
</Button>
</div>
)}
</div>
)}
<ImagePickerDialog
isOpen={showImagePicker}
onClose={() => setShowImagePicker(false)}
onSelect={(url) => {
// Fallback for single select, though we prefer multi-select
console.log('Single select fallback:', url);
setShowImagePicker(false);
}}
onMultiSelectPictures={handleReferenceImagesSelect}
multiple={true}
/>
{/* Generate/Cancel Button */}
{isGenerating && onCancel ? (
<Button
onClick={onCancel}
variant="destructive"
className="w-full"
size="lg"
>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<T>Cancel Generation</T>
</Button>
) : (
<Button
onClick={() => onGenerate({ referenceImages: referenceImages.map(img => img.image_url || img.src) })}
disabled={disabled || isGenerating || !prompt.trim()}
className="w-full"
size="lg"
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<T>Generating...</T>
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
<T>{imageToolsEnabled ? 'Generate with Images' : webSearchEnabled ? 'Generate with Web Search' : 'Generate Text'}</T>
</>
)}
</Button>
)}
{/* Helper Text */}
{(imageToolsEnabled || webSearchEnabled) && (
<div className="space-y-2">
{imageToolsEnabled && (
<div className="p-3 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800 space-y-1">
<div className="text-xs font-medium text-blue-700 dark:text-blue-300">
💡 <T>Image Tools enabled: AI can generate and embed images in the content</T>
</div>
<div className="text-xs text-blue-600 dark:text-blue-400">
<T>Note: Image Tools require OpenAI provider and will use your selected OpenAI model</T>
</div>
</div>
)}
{webSearchEnabled && (
<div className="p-3 bg-green-50 dark:bg-green-950/20 rounded-lg border border-green-200 dark:border-green-800 space-y-1">
<div className="text-xs font-medium text-green-700 dark:text-green-300">
🌐 <T>Web Search enabled: AI can search the web for up-to-date information</T>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};
export default AITextGenerator;

View File

@ -0,0 +1,350 @@
import React, { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, Bookmark } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
interface Collection {
id: string;
name: string;
description: string;
slug: string;
is_public: boolean;
created_at: string;
}
interface AddToCollectionModalProps {
isOpen: boolean;
onClose: () => void;
pictureId?: string;
postId?: string;
}
const AddToCollectionModal = ({ isOpen, onClose, pictureId, postId }: AddToCollectionModalProps) => {
if (!pictureId && !postId) {
console.error('AddToCollectionModal requires either pictureId or postId');
return null;
}
const { user } = useAuth();
const { toast } = useToast();
const [collections, setCollections] = useState<Collection[]>([]);
const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
const [showCreateForm, setShowCreateForm] = useState(false);
const [newCollection, setNewCollection] = useState({
name: '',
description: '',
is_public: true
});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isOpen && user) {
fetchCollections();
fetchItemCollections();
}
}, [isOpen, user, pictureId, postId]);
const fetchCollections = async () => {
if (!user) return;
const { data, error } = await supabase
.from('collections')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching collections:', error);
return;
}
setCollections(data || []);
};
const fetchItemCollections = async () => {
if (!user) return;
if (postId) {
// Fetch for post
const { data, error } = await supabase
.from('collection_posts' as any) // Cast as any until types are generated
.select('collection_id')
.eq('post_id', postId);
if (error) {
console.error('Error fetching post collections:', error);
return;
}
const collectionIds = new Set(data.map((item: any) => item.collection_id));
setSelectedCollections(collectionIds);
} else if (pictureId) {
// Fetch for picture
const { data, error } = await supabase
.from('collection_pictures')
.select('collection_id')
.eq('picture_id', pictureId);
if (error) {
console.error('Error fetching picture collections:', error);
return;
}
const collectionIds = new Set(data.map(item => item.collection_id));
setSelectedCollections(collectionIds);
}
};
const createSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
const handleCreateCollection = async () => {
if (!user || !newCollection.name.trim()) return;
setLoading(true);
const slug = createSlug(newCollection.name);
const { data, error } = await supabase
.from('collections')
.insert({
user_id: user.id,
name: newCollection.name.trim(),
description: newCollection.description.trim() || null,
slug,
is_public: newCollection.is_public
})
.select()
.single();
if (error) {
console.error('Error creating collection:', error);
toast({
title: "Error",
description: "Failed to create collection",
variant: "destructive"
});
setLoading(false);
return;
}
// Add item to new collection
if (postId) {
await supabase
.from('collection_posts' as any)
.insert({
collection_id: data.id,
post_id: postId
});
} else if (pictureId) {
await supabase
.from('collection_pictures')
.insert({
collection_id: data.id,
picture_id: pictureId
});
}
setCollections(prev => [data, ...prev]);
setSelectedCollections(prev => new Set([...prev, data.id]));
setNewCollection({ name: '', description: '', is_public: true });
setShowCreateForm(false);
setLoading(false);
toast({
title: "Success",
description: "Collection created and photo added!"
});
};
const handleToggleCollection = async (collectionId: string) => {
if (!user) return;
const isSelected = selectedCollections.has(collectionId);
if (isSelected) {
// Remove from collection
if (postId) {
const { error } = await supabase
.from('collection_posts' as any)
.delete()
.eq('collection_id', collectionId)
.eq('post_id', postId);
if (error) console.error('Error removing post from collection:', error);
} else if (pictureId) {
const { error } = await supabase
.from('collection_pictures')
.delete()
.eq('collection_id', collectionId)
.eq('picture_id', pictureId);
if (error) console.error('Error removing picture from collection:', error);
}
setSelectedCollections(prev => {
const newSet = new Set(prev);
newSet.delete(collectionId);
return newSet;
});
} else {
// Add to collection
if (postId) {
const { error } = await supabase
.from('collection_posts' as any)
.insert({
collection_id: collectionId,
post_id: postId
});
if (error) console.error('Error adding post to collection:', error);
} else if (pictureId) {
const { error } = await supabase
.from('collection_pictures')
.insert({
collection_id: collectionId,
picture_id: pictureId
});
if (error) console.error('Error adding picture to collection:', error);
}
setSelectedCollections(prev => new Set([...prev, collectionId]));
}
};
const handleSave = () => {
toast({
title: "Saved",
description: "Collection preferences updated!"
});
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bookmark className="h-5 w-5" />
Add to Collection
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Create new collection */}
{!showCreateForm ? (
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setShowCreateForm(true)}
>
<Plus className="h-4 w-4 mr-2" />
Create New Collection
</Button>
) : (
<Card>
<CardContent className="p-4 space-y-3">
<Input
placeholder="Collection name"
value={newCollection.name}
onChange={(e) => setNewCollection(prev => ({ ...prev, name: e.target.value }))}
/>
<Textarea
placeholder="Description (optional)"
value={newCollection.description}
onChange={(e) => setNewCollection(prev => ({ ...prev, description: e.target.value }))}
rows={2}
/>
<div className="flex items-center space-x-2">
<Checkbox
id="public"
checked={newCollection.is_public}
onCheckedChange={(checked) =>
setNewCollection(prev => ({ ...prev, is_public: checked as boolean }))
}
/>
<label htmlFor="public" className="text-sm">Make public</label>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCreateCollection}
disabled={!newCollection.name.trim() || loading}
>
Create
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateForm(false)}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
)}
{/* Existing collections */}
<div className="space-y-2 max-h-60 overflow-y-auto">
{collections.map((collection) => (
<Card
key={collection.id}
className={`cursor-pointer transition-colors ${selectedCollections.has(collection.id)
? 'bg-primary/10 border-primary'
: 'hover:bg-muted/50'
}`}
onClick={() => handleToggleCollection(collection.id)}
>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">{collection.name}</h4>
{collection.description && (
<p className="text-sm text-muted-foreground">
{collection.description}
</p>
)}
</div>
<Checkbox
checked={selectedCollections.has(collection.id)}
disabled
/>
</div>
</CardContent>
</Card>
))}
</div>
{collections.length === 0 && !showCreateForm && (
<p className="text-center text-muted-foreground py-4">
No collections yet. Create your first one!
</p>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>
Done
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default AddToCollectionModal;

View File

@ -0,0 +1,136 @@
import React, { useId, useMemo } from 'react';
import { useResponsiveImage } from '@/hooks/useResponsiveImage';
import { ResponsiveData } from './ResponsiveImage';
interface BackgroundImageProps extends React.HTMLAttributes<HTMLDivElement> {
src: string | File;
sizes?: string; // Not directly used in CSS but consistent with API
responsiveSizes?: number[];
formats?: string[];
as?: React.ElementType;
}
export const BackgroundImage: React.FC<BackgroundImageProps> = ({
src,
responsiveSizes = [180, 640, 1024, 2048],
formats = ['avif', 'webp'],
className,
style,
as: Component = 'div',
children,
...props
}) => {
const { data } = useResponsiveImage({ src, responsiveSizes, formats });
const uuid = useId().replace(/:/g, ''); // Sanitize ID for CSS class usage
const uniqueClass = `bg-${uuid}`;
const css = useMemo(() => {
if (!data) return '';
let cssString = '';
// Group sources by width for easier media query generation
const sourcesByWidth: Record<number, { src: string; format: string }[]> = {};
// Also find fallback (jpeg max width)
let fallbackUrl = data.img.src;
data.sources.forEach(source => {
const type = source.type.split('/')[1]; // image/avif -> avif
const variants = source.srcset.split(', ');
variants.forEach(variant => {
const [url, widthDesc] = variant.split(' ');
const width = parseInt(widthDesc.replace('w', ''));
if (!sourcesByWidth[width]) sourcesByWidth[width] = [];
sourcesByWidth[width].push({ src: url, format: type });
});
});
const widths = Object.keys(sourcesByWidth).map(Number).sort((a, b) => b - a); // Descending
// Generate CSS
// 1. Base styles (largest width, fallback format (jpeg/png))
const maxWidth = widths[0];
// Helper to generate rule block
const generateRules = (w: number) => {
const variants = sourcesByWidth[w];
if (!variants) return '';
// Order: AVIF -> WebP -> JPEG (CSS precedence via class selectors)
// Rules:
// .avif .bg-id { background-image: url(...) }
// .webp .bg-id { background-image: url(...) }
// .bg-id { background-image: url(...) } <-- Default/Fallback
let rules = '';
// Specific formats
variants.forEach(v => {
if (v.format === 'jpeg' || v.format === 'png') return; // Handled as default
rules += `html.${v.format} .${uniqueClass} { background-image: url('${v.src}'); }\n`;
});
// Default (JPEG/PNG)
const def = variants.find(v => v.format === 'jpeg' || v.format === 'png' || v.format === 'jpg');
if (def) {
rules += `.${uniqueClass} { background-image: url('${def.src}'); }\n`;
} else {
// Fallback if no jpeg found for this width (unlikely with defaults)
// Just use the first one as default
rules += `.${uniqueClass} { background-image: url('${variants[0].src}'); }\n`;
}
return rules;
};
// Desktop / Base (Max Width)
cssString += generateRules(maxWidth);
// Media Queries (Descending)
// Skip max width as it is base
for (let i = 1; i < widths.length; i++) {
const w = widths[i];
// Since we sort descending, we use max-width for the current step?
// Wait, standard mobile-first is min-width. Desktop-first is max-width.
// Responsive images usually work by "if viewport < X, use this".
// So: @media (max-width: ${w}px) { ... }
// Note: If we have 2048, 1024, 640.
// Base = 2048.
// @media (max-width: 1024px) { use 1024 }
// @media (max-width: 640px) { use 640 }
// Caution: If widths are very close or logic is slighty off, we might load wrong one.
// But this matches the "overwrite" strategy.
cssString += `@media (max-width: ${w}px) {
${generateRules(w)}
}\n`;
}
return cssString;
}, [data, uniqueClass]);
if (!data) {
return <Component className={`${className} animate-pulse bg-muted`} style={style} {...props}>{children}</Component>;
}
return (
<>
<style dangerouslySetInnerHTML={{ __html: css }} />
<Component
className={`${className || ''} ${uniqueClass}`}
style={{
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
...style
}}
{...props}
>
{children}
</Component>
</>
);
};

View File

@ -0,0 +1,156 @@
import React, { useState, type ReactNode, useEffect } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardContent } from '@/components/ui/card';
interface CollapsibleSectionProps {
title: ReactNode;
children: ReactNode;
initiallyOpen?: boolean;
storageKey?: string; // New prop for localStorage key
className?: string;
headerClassName?: string;
headerContent?: ReactNode;
titleClassName?: string;
buttonClassName?: string;
contentClassName?: string;
asCard?: boolean; // New prop to decide if it should render as a Card
onStateChange?: (isOpen: boolean) => void; // New prop for state change callback
id?: string;
minimal?: boolean; // New prop for minimal styling
renderHeader?: (
toggle: () => void,
isOpen: boolean
) => React.ReactNode;
}
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
children,
initiallyOpen = true,
storageKey,
className = '',
headerClassName,
headerContent,
titleClassName,
buttonClassName = '', // Made button smaller
contentClassName,
asCard = false, // Default to not rendering as a card
onStateChange, // Destructure new prop
id,
minimal = false,
renderHeader
}) => {
const [isOpen, setIsOpen] = useState(() => {
if (storageKey) {
try {
const storedState = localStorage.getItem(storageKey);
if (storedState !== null) {
return JSON.parse(storedState) as boolean;
}
} catch (error) {
console.error(`Error reading CollapsibleSection state from localStorage for key "${storageKey}":`, error);
}
}
return initiallyOpen;
});
useEffect(() => {
if (storageKey) {
try {
localStorage.setItem(storageKey, JSON.stringify(isOpen));
if (onStateChange) { // Call onStateChange when state is synced from localStorage
onStateChange(isOpen);
}
} catch (error) {
console.error(`Error writing CollapsibleSection state to localStorage for key "${storageKey}":`, error);
}
}
}, [isOpen, storageKey, onStateChange]);
const toggleOpen = () => {
const newState = !isOpen;
setIsOpen(newState);
if (onStateChange) { // Call onStateChange when toggling
onStateChange(newState);
}
};
// Apply minimal styling if enabled
const finalHeaderClassName = headerClassName || (minimal
? 'flex justify-between items-center p-1 cursor-pointer border-b border-border'
: 'flex justify-between items-center p-3 md:p-4 cursor-pointer border-b border-border'
);
const finalTitleClassName = titleClassName || (minimal
? 'text-sm font-semibold'
: 'text-md md:text-lg font-semibold'
);
const finalContentClassName = contentClassName || (minimal
? 'p-0'
: 'p-3 md:p-4'
);
const finalContainerClassName = minimal
? `bg-card ${className}`
: `rounded-lg shadow-none md:shadow-md border border-border bg-card ${className}`;
const header = renderHeader ? (
renderHeader(toggleOpen, isOpen)
) : (
<div className={finalHeaderClassName} onClick={toggleOpen}>
<div className={finalTitleClassName}>{title}</div>
<div className="flex items-center gap-2">
{headerContent}
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
toggleOpen();
}}
className={buttonClassName}
>
{isOpen ? (
<ChevronUp size={32} />
) : (
<ChevronDown size={32} />
)}
</Button>
</div>
</div>
);
if (asCard) {
const cardClassName = minimal
? `${className}`
: `shadow-none md:shadow-md ${className}`;
return (
<Card className={cardClassName} id={id}>
<CardHeader className={`cursor-pointer ${finalHeaderClassName}`} onClick={toggleOpen}>
<div className="flex justify-between items-center w-full">
<div className={finalTitleClassName}>{title}</div>
<div className="flex items-center gap-2">
{headerContent}
<Button variant="ghost" size="icon" onClick={(e) => { e.stopPropagation(); toggleOpen(); }} className={buttonClassName}>
{isOpen ? <ChevronUp className="h-6 w-6" /> : <ChevronDown className="h-6 w-6" />}
</Button>
</div>
</div>
</CardHeader>
{isOpen && <CardContent className={finalContentClassName}>{children}</CardContent>}
</Card>
);
}
return (
<div className={finalContainerClassName} id={id}>
{header}
{isOpen && <div className={finalContentClassName}>{children}</div>}
</div>
);
};
export default CollapsibleSection;

View File

@ -0,0 +1,49 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Bookmark } from 'lucide-react';
import AddToCollectionModal from './AddToCollectionModal';
import { useAuth } from '@/hooks/useAuth';
interface CollectionButtonProps {
pictureId: string;
size?: 'sm' | 'lg';
variant?: 'default' | 'ghost' | 'outline';
className?: string;
}
const CollectionButton = ({
pictureId,
size = 'sm',
variant = 'ghost',
className = ''
}: CollectionButtonProps) => {
const { user } = useAuth();
const [showModal, setShowModal] = useState(false);
if (!user) return null;
return (
<>
<Button
variant={variant}
size={size}
onClick={(e) => {
e.stopPropagation();
setShowModal(true);
}}
className={`transition-all duration-200 hover:scale-110 ${className}`}
title="Add to collection"
>
<Bookmark className={`${size === 'sm' ? 'h-3 w-3' : 'h-5 w-5'}`} />
</Button>
<AddToCollectionModal
isOpen={showModal}
onClose={() => setShowModal(false)}
pictureId={pictureId}
/>
</>
);
};
export default CollectionButton;

View File

@ -0,0 +1,736 @@
import { useState, useEffect, useRef } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { User, MessageCircle, Heart, MoreHorizontal, Mic, MicOff, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Link } from "react-router-dom";
import MarkdownRenderer from "@/components/MarkdownRenderer";
import MarkdownEditor from "@/components/MarkdownEditor";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { transcribeAudio } from "@/lib/openai";
import { T, translate } from "@/i18n";
interface Comment {
id: string;
content: string;
user_id: string;
parent_comment_id: string | null;
created_at: string;
updated_at: string;
likes_count: number;
replies?: Comment[];
depth?: number;
}
interface UserProfile {
user_id: string;
avatar_url: string | null;
display_name: string | null;
username: string | null;
}
interface CommentsProps {
pictureId: string;
}
const Comments = ({ pictureId }: CommentsProps) => {
const { user } = useAuth();
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState("");
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState("");
const [loading, setLoading] = useState(true);
const [useMarkdown, setUseMarkdown] = useState(false);
const [likedComments, setLikedComments] = useState<Set<string>>(new Set());
const [userProfiles, setUserProfiles] = useState<Map<string, UserProfile>>(new Map());
const [editingComment, setEditingComment] = useState<string | null>(null);
const [editText, setEditText] = useState("");
// Microphone recording state
const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);
const [recordingFor, setRecordingFor] = useState<'new' | 'reply' | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
useEffect(() => {
fetchComments();
}, [pictureId]);
const fetchComments = async () => {
try {
const { data, error } = await supabase
.from('comments')
.select('*')
.eq('picture_id', pictureId)
.order('created_at', { ascending: true });
if (error) throw error;
// Fetch user's likes if logged in
let userLikes: string[] = [];
if (user) {
const { data: likesData, error: likesError } = await supabase
.from('comment_likes')
.select('comment_id')
.eq('user_id', user.id);
if (!likesError && likesData) {
userLikes = likesData.map(like => like.comment_id);
}
}
setLikedComments(new Set(userLikes));
// Fetch user profiles for all comment authors
const uniqueUserIds = [...new Set(data.map(comment => comment.user_id))];
if (uniqueUserIds.length > 0) {
const { data: profilesData, error: profilesError } = await supabase
.from('profiles')
.select('user_id, avatar_url, display_name, username')
.in('user_id', uniqueUserIds);
if (!profilesError && profilesData) {
const profilesMap = new Map<string, UserProfile>();
profilesData.forEach(profile => {
profilesMap.set(profile.user_id, profile);
});
setUserProfiles(profilesMap);
}
}
// Organize comments into nested structure with max 3 levels
const commentsMap = new Map<string, Comment>();
const rootComments: Comment[] = [];
// First pass: create all comments with depth tracking
data.forEach(comment => {
const commentWithReplies = { ...comment, replies: [], depth: 0 };
commentsMap.set(comment.id, commentWithReplies);
});
// Second pass: organize hierarchy with depth limit
data.forEach(comment => {
const commentWithReplies = commentsMap.get(comment.id)!;
if (comment.parent_comment_id) {
const parent = commentsMap.get(comment.parent_comment_id);
if (parent) {
// Calculate depth: parent depth + 1, but max 2 (0, 1, 2 = 3 levels)
const newDepth = Math.min(parent.depth + 1, 2);
commentWithReplies.depth = newDepth;
// If we're at max depth, flatten to parent's level instead of nesting deeper
if (parent.depth >= 2) {
// Find the root ancestor to add this comment to
let rootParent = parent;
while (rootParent.depth > 0) {
const rootParentData = data.find(c => c.id === rootParent.parent_comment_id);
if (rootParentData) {
rootParent = commentsMap.get(rootParentData.id)!;
} else {
break;
}
}
rootParent.replies!.push(commentWithReplies);
} else {
parent.replies!.push(commentWithReplies);
}
}
} else {
rootComments.push(commentWithReplies);
}
});
setComments(rootComments);
} catch (error) {
console.error('Error fetching comments:', error);
toast.error('Failed to load comments');
} finally {
setLoading(false);
}
};
const handleAddComment = async () => {
if (!user || !newComment.trim()) return;
try {
const { error } = await supabase
.from('comments')
.insert([{
picture_id: pictureId,
user_id: user.id,
content: newComment.trim(),
parent_comment_id: null
}]);
if (error) throw error;
setNewComment("");
fetchComments();
toast.success(translate('Comment added!'));
} catch (error) {
console.error('Error adding comment:', error);
toast.error(translate('Failed to add comment'));
}
};
const handleAddReply = async (parentId: string) => {
if (!user || !replyText.trim()) return;
try {
const { error } = await supabase
.from('comments')
.insert([{
picture_id: pictureId,
user_id: user.id,
content: replyText.trim(),
parent_comment_id: parentId
}]);
if (error) throw error;
setReplyText("");
setReplyingTo(null);
fetchComments();
toast.success(translate('Reply added!'));
} catch (error) {
console.error('Error adding reply:', error);
toast.error(translate('Failed to add reply'));
}
};
const handleDeleteComment = async (commentId: string) => {
if (!user) return;
try {
const { error } = await supabase
.from('comments')
.delete()
.eq('id', commentId)
.eq('user_id', user.id);
if (error) throw error;
fetchComments();
toast.success(translate('Comment deleted'));
} catch (error) {
console.error('Error deleting comment:', error);
toast.error(translate('Failed to delete comment'));
}
};
const handleEditComment = async (commentId: string) => {
if (!user || !editText.trim()) return;
try {
const { error } = await supabase
.from('comments')
.update({
content: editText.trim(),
updated_at: new Date().toISOString()
})
.eq('id', commentId)
.eq('user_id', user.id);
if (error) throw error;
toast.success(translate('Comment updated successfully'));
setEditingComment(null);
setEditText("");
fetchComments(); // Refresh comments
} catch (error) {
console.error('Error updating comment:', error);
toast.error(translate('Failed to update comment'));
}
};
const startEditingComment = (comment: Comment) => {
setEditingComment(comment.id);
setEditText(comment.content);
};
const cancelEditingComment = () => {
setEditingComment(null);
setEditText("");
};
const handleToggleLike = async (commentId: string) => {
if (!user) {
toast.error(translate('Please sign in to like comments'));
return;
}
const isLiked = likedComments.has(commentId);
try {
if (isLiked) {
// Unlike the comment
const { error } = await supabase
.from('comment_likes')
.delete()
.eq('comment_id', commentId)
.eq('user_id', user.id);
if (error) throw error;
// Update local state
const newLikedComments = new Set(likedComments);
newLikedComments.delete(commentId);
setLikedComments(newLikedComments);
// Update comments state
const updateCommentsLikes = (comments: Comment[]): Comment[] => {
return comments.map(comment => {
if (comment.id === commentId) {
return { ...comment, likes_count: Math.max(0, comment.likes_count - 1) };
}
if (comment.replies) {
return { ...comment, replies: updateCommentsLikes(comment.replies) };
}
return comment;
});
};
setComments(updateCommentsLikes);
} else {
// Like the comment
const { error } = await supabase
.from('comment_likes')
.insert([{
comment_id: commentId,
user_id: user.id
}]);
if (error) throw error;
// Update local state
const newLikedComments = new Set(likedComments);
newLikedComments.add(commentId);
setLikedComments(newLikedComments);
// Update comments state
const updateCommentsLikes = (comments: Comment[]): Comment[] => {
return comments.map(comment => {
if (comment.id === commentId) {
return { ...comment, likes_count: comment.likes_count + 1 };
}
if (comment.replies) {
return { ...comment, replies: updateCommentsLikes(comment.replies) };
}
return comment;
});
};
setComments(updateCommentsLikes);
}
} catch (error) {
console.error('Error toggling like:', error);
toast.error('Failed to toggle like');
}
};
const getRelativeTime = (dateString: string) => {
const now = new Date();
const commentDate = new Date(dateString);
const diffInMs = now.getTime() - commentDate.getTime();
const diffInHours = diffInMs / (1000 * 60 * 60);
// If more than 24 hours ago, show regular date
if (diffInHours >= 24) {
return commentDate.toLocaleDateString();
}
// Show relative time for recent comments
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
if (diffInMinutes < 1) {
return 'just now';
} else if (diffInMinutes < 60) {
return `${diffInMinutes} min${diffInMinutes === 1 ? '' : 's'} ago`;
} else {
const hours = Math.floor(diffInMinutes / 60);
return `${hours} hour${hours === 1 ? '' : 's'} ago`;
}
};
const handleMicrophone = async (type: 'new' | 'reply') => {
if (isRecording) {
// Stop recording
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
setIsRecording(false);
}
} else {
// Start recording
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
toast.error(translate('Audio recording is not supported in your browser'));
return;
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const options = { mimeType: 'audio/webm' };
let mediaRecorder: MediaRecorder;
try {
mediaRecorder = new MediaRecorder(stream, options);
} catch (e) {
mediaRecorder = new MediaRecorder(stream);
}
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
setRecordingFor(type);
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
setIsTranscribing(true);
try {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' });
toast.info(translate('Transcribing audio...'));
const transcribedText = await transcribeAudio(audioFile);
if (transcribedText) {
if (type === 'new') {
setNewComment(prev => {
const trimmed = prev.trim();
return trimmed ? `${trimmed}\n\n${transcribedText}` : transcribedText;
});
} else if (type === 'reply') {
setReplyText(prev => {
const trimmed = prev.trim();
return trimmed ? `${trimmed}\n\n${transcribedText}` : transcribedText;
});
}
toast.success(translate('Audio transcribed successfully!'));
} else {
toast.error(translate('Failed to transcribe audio. Please check your OpenAI API key.'));
}
} catch (error: any) {
console.error('Error transcribing audio:', error);
toast.error(error.message || translate('Failed to transcribe audio'));
} finally {
setIsTranscribing(false);
setRecordingFor(null);
audioChunksRef.current = [];
stream.getTracks().forEach(track => track.stop());
}
};
mediaRecorder.start();
setIsRecording(true);
toast.info(translate('Recording... Click mic again to stop'));
} catch (error: any) {
console.error('Error accessing microphone:', error);
if (error.name === 'NotAllowedError') {
toast.error(translate('Microphone access denied. Please allow microphone access in your browser settings.'));
} else {
toast.error(translate('Failed to access microphone') + ': ' + error.message);
}
}
}
};
const renderComment = (comment: Comment) => {
const isOwner = user?.id === comment.user_id;
const marginLeft = comment.depth ? comment.depth * 24 : 0;
const userProfile = userProfiles.get(comment.user_id);
return (
<div key={comment.id} className="space-y-3">
<div className="flex space-x-3" style={{ marginLeft: `${marginLeft}px` }}>
<Link
to={`/user/${comment.user_id}`}
className="w-8 h-8 bg-gradient-primary rounded-full flex items-center justify-center flex-shrink-0 hover:scale-110 transition-transform overflow-hidden"
>
{userProfile?.avatar_url ? (
<img
src={userProfile.avatar_url}
alt={userProfile.display_name || 'User avatar'}
className="w-full h-full rounded-full object-cover"
/>
) : (
<User className="h-4 w-4 text-white" />
)}
</Link>
<div className="flex-1 min-w-0">
<div className="bg-card rounded-lg p-3">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center space-x-2">
<Link
to={`/user/${comment.user_id}`}
className="text-sm font-semibold hover:underline"
>
{userProfile?.display_name || `User ${comment.user_id.slice(0, 8)}`}
</Link>
<span className="text-xs text-muted-foreground">
{getRelativeTime(comment.created_at)}
</span>
</div>
{isOwner && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => startEditingComment(comment)}
>
<T>Edit</T>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteComment(comment.id)}
className="text-destructive"
>
<T>Delete</T>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="text-sm">
{editingComment === comment.id ? (
<div className="space-y-2">
<Textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
className="min-h-[60px] text-sm"
autoFocus
/>
<div className="flex items-center space-x-2">
<Button
size="sm"
onClick={() => handleEditComment(comment.id)}
disabled={!editText.trim()}
>
<T>Save</T>
</Button>
<Button
size="sm"
variant="ghost"
onClick={cancelEditingComment}
>
<T>Cancel</T>
</Button>
</div>
</div>
) : (
<MarkdownRenderer content={comment.content} className="prose-sm" />
)}
</div>
</div>
<div className="flex items-center space-x-4 mt-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleLike(comment.id)}
className={`h-6 px-2 text-xs ${
likedComments.has(comment.id)
? 'text-red-500 hover:text-red-600'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Heart
className={`h-3 w-3 mr-1 ${
likedComments.has(comment.id) ? 'fill-current' : ''
}`}
/>
{comment.likes_count > 0 && <span className="mr-1">{comment.likes_count}</span>}
<T>Like</T>
</Button>
{comment.depth < 2 && (
<Button
variant="ghost"
size="sm"
onClick={() => setReplyingTo(replyingTo === comment.id ? null : comment.id)}
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<MessageCircle className="h-3 w-3 mr-1" />
<T>Reply</T>
</Button>
)}
</div>
{replyingTo === comment.id && (
<div className="mt-3 space-y-2">
<div className="relative">
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder={translate('Write a reply...')}
className="min-h-[60px] text-sm pr-10"
autoFocus={false}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
handleAddReply(comment.id);
}
}}
/>
<button
onClick={() => handleMicrophone('reply')}
disabled={isTranscribing}
className={`absolute right-2 bottom-2 p-1.5 rounded-md transition-colors ${
isRecording && recordingFor === 'reply'
? 'bg-red-100 text-red-600 hover:bg-red-200'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={isRecording && recordingFor === 'reply' ? 'Stop recording' : 'Record audio'}
>
{isTranscribing && recordingFor === 'reply' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : isRecording && recordingFor === 'reply' ? (
<MicOff className="h-3 w-3" />
) : (
<Mic className="h-3 w-3" />
)}
</button>
</div>
<div className="flex space-x-2">
<Button
size="sm"
onClick={() => handleAddReply(comment.id)}
disabled={!replyText.trim()}
>
<T>Reply</T>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setReplyingTo(null);
setReplyText("");
}}
>
<T>Cancel</T>
</Button>
</div>
</div>
)}
</div>
</div>
{comment.replies && comment.replies.length > 0 && (
<div className="space-y-3">
{comment.replies.map(reply => renderComment(reply))}
</div>
)}
</div>
);
};
if (loading) {
return (
<div className="text-center py-4">
<div className="text-muted-foreground"><T>Loading comments...</T></div>
</div>
);
}
return (
<div className="space-y-4">
{/* Comment Input - Always visible */}
{user && (
<div className="space-y-3 pb-4 border-b">
<div className="relative">
{useMarkdown ? (
<MarkdownEditor
value={newComment}
onChange={setNewComment}
placeholder={translate('Add a comment...')}
className="min-h-[60px]"
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
handleAddComment();
}
}}
/>
) : (
<Textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder={translate('Add a comment...')}
className="min-h-[60px] pr-10"
autoFocus={false}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
handleAddComment();
}
}}
/>
)}
<button
onClick={() => handleMicrophone('new')}
disabled={isTranscribing}
className={`absolute right-2 bottom-2 p-1.5 rounded-md transition-colors ${
isRecording && recordingFor === 'new'
? 'bg-red-100 text-red-600 hover:bg-red-200'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={isRecording && recordingFor === 'new' ? 'Stop recording' : 'Record audio'}
>
{isTranscribing && recordingFor === 'new' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isRecording && recordingFor === 'new' ? (
<MicOff className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
</button>
</div>
<div className="flex items-center justify-between">
<Button
onClick={handleAddComment}
disabled={!newComment.trim()}
>
<T>Post Comment</T>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setUseMarkdown(!useMarkdown)}
className="text-xs"
>
<T>{useMarkdown ? "Simple" : "Markdown"}</T>
</Button>
</div>
</div>
)}
{/* Comments List - Scrollable */}
<div className="max-h-[50vh] overflow-y-auto space-y-4">
{comments.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<MessageCircle className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p><T>No comments yet</T></p>
<p className="text-sm"><T>Be the first to comment!</T></p>
</div>
) : (
comments.map(comment => renderComment(comment))
)}
</div>
</div>
);
};
export default Comments;

View File

@ -0,0 +1,688 @@
import React, { useState, useRef } from 'react';
import { uploadImage } from '@/lib/uploadUtils';
import { useNavigate } from 'react-router-dom';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { T, translate } from '@/i18n';
import { Image, FilePlus, Zap, Mic, Loader2, Upload, Video, Layers, BookPlus } from 'lucide-react';
import { useImageWizard } from '@/hooks/useImageWizard';
import { usePageGenerator } from '@/hooks/usePageGenerator';
import VoiceRecordingPopup from './VoiceRecordingPopup';
import AIPageGenerator from './AIPageGenerator';
import PostPicker from './PostPicker';
import { useWizardContext } from '@/hooks/useWizardContext';
import { usePromptHistory } from '@/hooks/usePromptHistory';
import { useAuth } from '@/hooks/useAuth';
import { useOrganization } from '@/contexts/OrganizationContext';
import { useMediaRefresh } from '@/contexts/MediaRefreshContext';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
interface CreationWizardPopupProps {
isOpen: boolean;
onClose: () => void;
preloadedImages?: any[]; // ImageFile[]
initialMode?: 'default' | 'page';
}
export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({
isOpen,
onClose,
preloadedImages = [],
initialMode = 'default'
}) => {
const navigate = useNavigate();
const { user } = useAuth();
const { orgSlug, isOrgContext } = useOrganization();
const { clearWizardImage } = useWizardContext();
const { openWizard } = useImageWizard();
const { generatePageFromVoice, generatePageFromText, isGenerating, status, cancelGeneration } = usePageGenerator();
const { triggerRefresh } = useMediaRefresh();
const [showVoicePopup, setShowVoicePopup] = useState(false);
const [showTextGenerator, setShowTextGenerator] = useState(initialMode === 'page');
const [showPostPicker, setShowPostPicker] = useState(false);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [isUploadingVideo, setIsUploadingVideo] = useState(false);
const [videoUploadProgress, setVideoUploadProgress] = useState(0);
const imageInputRef = useRef<HTMLInputElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const {
prompt: textPrompt,
setPrompt: setTextPrompt,
promptHistory,
historyIndex,
navigateHistory,
addPromptToHistory,
setHistoryIndex
} = usePromptHistory();
// Reset/Sync state when dialog opens
React.useEffect(() => {
if (isOpen && initialMode === 'page') {
setShowTextGenerator(true);
}
}, [isOpen, initialMode]);
const handleImageWizard = (mode: 'direct' | 'agent' | 'voice') => {
onClose();
if (mode === 'direct') {
clearWizardImage();
// If we have preloaded images, pass them to the wizard
if (preloadedImages.length > 0) {
navigate('/wizard', { state: { initialImages: preloadedImages } });
} else {
navigate('/wizard');
}
} else {
console.log(`Opening Image Wizard in ${mode} mode`);
}
};
const handleCreatePost = () => {
onClose();
clearWizardImage();
// Check for link metadata to auto-populate post
let postTitle = '';
let postDescription = '';
// Use the first external page's metadata for the post
const externalPage = preloadedImages.find(img => img.type === 'page-external');
if (externalPage) {
postTitle = externalPage.title || '';
postDescription = externalPage.description || '';
}
// If we have preloaded images, pass them to the wizard
if (preloadedImages.length > 0) {
navigate('/wizard', {
state: {
mode: 'post',
initialImages: preloadedImages,
postTitle,
postDescription
}
});
} else {
navigate('/wizard', { state: { mode: 'post' } });
}
};
const handleAppendToPost = () => {
if (!user) {
toast.error(translate('Please sign in to manage posts'));
return;
}
setShowPostPicker(true);
};
const handlePostSelected = async (postId: string) => {
setShowPostPicker(false);
try {
const toastId = toast.loading(translate('Loading post...'));
// Fetch full post with all pictures
const { data: post, error } = await supabase
.from('posts')
.select(`*, pictures (*)`)
.eq('id', postId)
.single();
if (error) {
toast.dismiss(toastId);
toast.error(translate('Failed to load post'));
console.error('Error fetching post:', error);
return;
}
// Transform existing pictures
const existingImages = (post.pictures || [])
.sort((a: any, b: any) => (a.position - b.position))
.map((p: any) => ({
id: p.id,
path: p.id,
src: p.image_url,
title: p.title,
description: p.description || '',
selected: false,
realDatabaseId: p.id,
type: p.type || 'image',
isGenerated: false
}));
// Merge with preloaded images
// Mark preloaded images as NEW (no realDatabaseId) so they can be added
const newImages = (preloadedImages || []).map(img => ({
...img,
selected: true // Select new images by default
}));
const combinedImages = [...existingImages, ...newImages];
toast.dismiss(toastId);
onClose();
clearWizardImage();
// Navigate to wizard with combined images and post details
navigate('/wizard', {
state: {
mode: 'post',
editingPostId: postId,
initialImages: combinedImages,
postTitle: post.title,
postDescription: post.description
}
});
} catch (err) {
console.error('Error in handlePostSelected:', err);
toast.error(translate('An error occurred'));
}
};
const handlePageFromVoice = () => {
setShowVoicePopup(true);
};
const handleGeneratePageFromText = async (options: { useImageTools: boolean; model: string; imageModel?: string; referenceImages?: string[] }) => {
if (!textPrompt.trim()) return;
addPromptToHistory(textPrompt);
setHistoryIndex(-1);
const result = await generatePageFromText(textPrompt, options);
// Only close on success
if (result) {
setShowTextGenerator(false);
onClose();
}
};
const handleGeneratePageFromVoice = async (transcribedText: string) => {
setShowVoicePopup(false);
// For voice, we assume the user wants the full experience, including images, as per the button's text "Voice + AI"
const result = await generatePageFromText(transcribedText, { useImageTools: true });
if (result) onClose();
};
const handleImageUpload = async () => {
// If we have preloaded images, upload them directly
if (preloadedImages.length > 0) {
if (!user) {
toast.error(translate('Please sign in to upload images'));
return;
}
setIsUploadingImage(true);
try {
let organizationId = null;
if (isOrgContext && orgSlug) {
const { data: org } = await supabase
.from('organizations')
.select('id')
.eq('slug', orgSlug)
.single();
organizationId = org?.id || null;
}
for (const img of preloadedImages) {
// Handle External Pages (Links)
if (img.type === 'page-external') {
console.log('Skipping upload for external page:', img.title);
const { error: dbError } = await supabase
.from('pictures')
.insert({
user_id: user.id,
title: img.title || 'Untitled Link',
description: img.description || null,
image_url: img.src, // Use the preview image as main URL? Or should we store the link URL?
// Wait, "image_url" usually stores the image.
// For pages, we should probably store the link in `meta` or `url` if exists?
// The `pictures` table has `image_url` and `video_url`.
// Let's store the LINK in `image_url` (since it's the primary content) or specific logic?
// Actually, for PAGE type, `image_url` is often the link, or we use a separate field?
// Looking at `mediaUtils`: for `PAGE`, `url` is passed through.
// But `PAGE` usually implies internal.
// Let's check `MediaCard`. It uses `url` prop.
// For `PAGE_EXTERNAL`, the `url` should be the external link.
// But where do we store the thumbnail?
// `pictures` table has `thumbnail_url`.
// So: image_url = external_link, thumbnail_url = preview_image.
image_url: img.path, // The generic URL
thumbnail_url: img.src, // The preview image
organization_id: organizationId,
type: 'page-external',
meta: img.meta
});
if (dbError) throw dbError;
continue; // Skip the rest of loop
}
const file = img.file;
if (!file) continue;
const { publicUrl } = await uploadImage(file, user.id);
console.log('image uploaded, url:', publicUrl);
let dbData = {
user_id: user.id,
title: file.name.replace(/\.[^/.]+$/, ''),
description: null,
image_url: publicUrl,
organization_id: organizationId,
// type: default (image)
};
const { error: dbError } = await supabase
.from('pictures')
.insert(dbData);
if (dbError) throw dbError;
}
toast.success(translate(`${preloadedImages.length} image(s) uploaded successfully!`));
triggerRefresh();
onClose();
navigate('/');
} catch (error) {
console.error('Error uploading images:', error);
toast.error(translate('Failed to upload images'));
} finally {
setIsUploadingImage(false);
}
return;
}
imageInputRef.current?.click();
};
// Helper function to upload internal video
const uploadInternalVideo = async (file: File): Promise<void> => {
return new Promise((resolve, reject) => {
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://192.168.1.11:3333';
const formData = new FormData();
formData.append('file', file);
const title = file.name.replace(/\.[^/.]+$/, '');
const xhr = new XMLHttpRequest();
xhr.open('POST', `${serverUrl}/api/videos/upload?userId=${user?.id}&title=${encodeURIComponent(title)}&preset=original`);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
setVideoUploadProgress(percent);
}
};
xhr.onload = () => {
if (xhr.status === 200 || xhr.status === 202) {
resolve();
} else {
try {
const err = JSON.parse(xhr.responseText);
reject(new Error(err.error || 'Upload failed'));
} catch {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
}
};
xhr.onerror = () => {
reject(new Error('Network error'));
};
xhr.send(formData);
});
};
const handleVideoUpload = async () => {
// If we have preloaded images (which might be videos), upload them directly
if (preloadedImages.length > 0) {
if (!user) {
toast.error(translate('Please sign in to upload videos'));
return;
}
const videos = preloadedImages.filter(img => img.file?.type.startsWith('video/'));
if (videos.length === 0) {
toast.error(translate('No videos found in selection. Please drop video files.'));
return;
}
setIsUploadingVideo(true);
setVideoUploadProgress(0);
try {
let successCount = 0;
const total = videos.length;
for (const [index, img] of videos.entries()) {
const file = img.file;
if (!file) continue;
toast.info(translate(`Uploading video ${index + 1}/${total}...`));
await uploadInternalVideo(file);
successCount++;
}
toast.success(translate(`${successCount} video(s) uploaded successfully!`));
triggerRefresh();
onClose();
navigate('/');
} catch (error: any) {
console.error('Error uploading videos:', error);
toast.error(translate(`Failed to upload video(s): ${error.message}`));
} finally {
setIsUploadingVideo(false);
setVideoUploadProgress(0);
}
return;
}
videoInputRef.current?.click();
};
const handleImageFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error(translate('Please select a valid image file'));
return;
}
if (!user) {
toast.error(translate('Please sign in to upload images'));
return;
}
setIsUploadingImage(true);
try {
// Upload file to storage
// Upload file to storage (direct or via proxy)
const { publicUrl } = await uploadImage(file, user.id);
// Get organization ID if in org context
let organizationId = null;
if (isOrgContext && orgSlug) {
const { data: org } = await supabase
.from('organizations')
.select('id')
.eq('slug', orgSlug)
.single();
organizationId = org?.id || null;
}
// Save picture metadata to database
const imageTitle = file.name.replace(/\.[^/.]+$/, ''); // Remove extension
const { error: dbError, data: dbData } = await supabase
.from('pictures')
.insert({
user_id: user.id,
title: imageTitle,
description: null,
image_url: publicUrl,
organization_id: organizationId,
});
if (dbError) throw dbError;
toast.success(translate('Image uploaded successfully!'));
console.log(dbData);
// Trigger PhotoGrid refresh
triggerRefresh();
onClose();
// Navigate to home to see the uploaded image
navigate('/');
} catch (error) {
console.error('Error uploading image:', error);
toast.error(translate('Failed to upload image'));
} finally {
setIsUploadingImage(false);
// Clear the input so the same file can be selected again
if (imageInputRef.current) {
imageInputRef.current.value = '';
}
}
};
const handleVideoFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('video/')) {
toast.error(translate('Please select a valid video file'));
return;
}
if (!user) {
toast.error(translate('Please sign in to upload videos'));
return;
}
setIsUploadingVideo(true);
setVideoUploadProgress(0);
try {
toast.info(translate('Uploading video...'));
await uploadInternalVideo(file);
toast.success(translate('Video upload started! Processing in background.'));
// Trigger PhotoGrid refresh
triggerRefresh();
onClose();
navigate('/');
} catch (error: any) {
console.error('Error uploading video:', error);
toast.error(translate(`Failed to upload video: ${error.message}`));
} finally {
setIsUploadingVideo(false);
setVideoUploadProgress(0);
// Clear the input
if (videoInputRef.current) {
videoInputRef.current.value = '';
}
}
};
return (
<>
<Dialog open={isOpen && !showTextGenerator} onOpenChange={(open) => {
if (!open) {
onClose();
setShowTextGenerator(false);
}
}}>
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-bold text-center">
<T>What would you like to create?</T>
</DialogTitle>
<DialogDescription className="text-center">
<T>Choose from the options below to start generating or uploading content.</T>
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 py-6">
{/* Column 1: Images */}
<div className="flex flex-col gap-4 p-6 bg-muted/30 rounded-lg border">
<div className="flex items-center gap-3">
<Image className="h-6 w-6 text-primary" />
<h3 className="text-lg font-semibold"><T>Images</T></h3>
</div>
<p className="text-sm text-muted-foreground">
<T>Generate AI images or upload your own photos to share.</T>
</p>
<div className="flex flex-col gap-2 mt-auto">
<Button variant="outline" onClick={() => handleImageWizard('direct')}>
<Zap className="h-4 w-4 mr-2" />
<T>Generate AI Image</T>
</Button>
<Button
variant="default"
onClick={handleImageUpload}
disabled={isUploadingImage || !user}
>
{isUploadingImage ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<T>Uploading...</T>
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
<T>Upload Image</T>
</>
)}
</Button>
<Button variant="outline" onClick={handleCreatePost}>
<Layers className="h-4 w-4 mr-2" />
<T>Create Post</T>
</Button>
{preloadedImages.length > 0 && (
<Button variant="outline" onClick={handleAppendToPost}>
<BookPlus className="h-4 w-4 mr-2" />
<T>Append to Existing Post</T>
</Button>
)}
</div>
</div>
{/* Column 2: Videos */}
<div className="flex flex-col gap-4 p-6 bg-muted/30 rounded-lg border">
<div className="flex items-center gap-3">
<Video className="h-6 w-6 text-primary" />
<h3 className="text-lg font-semibold"><T>Videos</T></h3>
</div>
<p className="text-sm text-muted-foreground">
<T>Upload videos to share with the community. Automatic processing and optimization.</T>
</p>
<div className="flex flex-col gap-2 mt-auto">
<Button
variant="default"
onClick={handleVideoUpload}
disabled={isUploadingVideo || !user}
>
{isUploadingVideo ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<T>Uploading</T> {videoUploadProgress}%
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
<T>Upload Video</T>
</>
)}
</Button>
{isUploadingVideo && (
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${videoUploadProgress}%` }}
/>
</div>
)}
</div>
</div>
{/* Column 3: Pages */}
<div className="flex flex-col gap-4 p-6 bg-muted/30 rounded-lg border">
<div className="flex items-center gap-3">
<FilePlus className="h-6 w-6 text-primary" />
<h3 className="text-lg font-semibold"><T>Pages</T></h3>
</div>
<p className="text-sm text-muted-foreground">
<T>Generate complete pages with AI-powered text and images.</T>
</p>
<div className="flex flex-col gap-2 mt-auto">
<Button variant="outline" onClick={() => setShowTextGenerator(true)}>
<Zap className="h-4 w-4 mr-2" />
<T>Generate with AI</T>
</Button>
<Button variant="default" onClick={handlePageFromVoice}>
<Mic className="h-4 w-4 mr-2" />
<T>Voice + AI</T>
</Button>
</div>
</div>
</div>
{/* Hidden file inputs */}
<input
ref={imageInputRef}
type="file"
accept="image/*"
onChange={handleImageFileSelected}
className="hidden"
/>
<input
ref={videoInputRef}
type="file"
accept="video/*"
onChange={handleVideoFileSelected}
className="hidden"
/>
</DialogContent>
</Dialog>
<Dialog open={showPostPicker} onOpenChange={setShowPostPicker}>
<DialogContent className="sm:max-w-[800px] h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle><T>Select a Post to Append To</T></DialogTitle>
<DialogDescription>
<T>Choose one of your existing posts to add these {preloadedImages.length} file(s) to.</T>
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden min-h-0">
<PostPicker onSelect={handlePostSelected} />
</div>
</DialogContent>
</Dialog>
<Dialog open={showTextGenerator} onOpenChange={setShowTextGenerator}>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle><T>Create a Page From Scratch</T></DialogTitle>
<DialogDescription>
<T>Describe the page you want the AI to create. It can generate text and images for you.</T>
</DialogDescription>
</DialogHeader>
<div className="py-4">
<AIPageGenerator
prompt={textPrompt}
onPromptChange={setTextPrompt}
onGenerate={handleGeneratePageFromText}
isGenerating={isGenerating}
generationStatus={status}
onCancel={cancelGeneration}
promptHistory={promptHistory}
historyIndex={historyIndex}
onNavigateHistory={navigateHistory}
initialReferenceImages={preloadedImages.map(img => img.src || img.image_url).filter(Boolean)}
/>
</div>
</DialogContent>
</Dialog>
{showVoicePopup && (
<VoiceRecordingPopup
isOpen={showVoicePopup}
onClose={() => setShowVoicePopup(false)}
onTranscriptionComplete={handleGeneratePageFromVoice}
/>
)}
</>
);
};

View File

@ -0,0 +1,576 @@
import { useState, useRef, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent } from '@/components/ui/card';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { Edit3, GitBranch, Sparkles, Mic, MicOff, Loader2, Bookmark } from 'lucide-react';
import MarkdownEditor from '@/components/MarkdownEditor';
import VersionSelector from '@/components/VersionSelector';
import { analyzeImages, transcribeAudio } from '@/lib/openai';
import { AI_IMAGE_ANALYSIS_PROMPT } from '@/constants';
import { T, translate } from '@/i18n';
interface Collection {
id: string;
name: string;
description: string | null;
slug: string;
is_public: boolean;
}
const editSchema = z.object({
title: z.string().max(100, 'Title must be less than 100 characters').optional(),
description: z.string().max(1000, 'Description must be less than 1000 characters').optional(),
visible: z.boolean(),
});
type EditFormData = z.infer<typeof editSchema>;
interface EditImageModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pictureId: string;
currentTitle: string;
currentDescription: string | null;
currentVisible: boolean;
imageUrl?: string;
onUpdateSuccess: () => void;
}
const EditImageModal = ({
open,
onOpenChange,
pictureId,
currentTitle,
currentDescription,
currentVisible,
imageUrl,
onUpdateSuccess
}: EditImageModalProps) => {
const [updating, setUpdating] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const { user } = useAuth();
// Microphone recording state
const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
// Collections state
const [collections, setCollections] = useState<Collection[]>([]);
const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
const [loadingCollections, setLoadingCollections] = useState(false);
const form = useForm<EditFormData>({
resolver: zodResolver(editSchema),
defaultValues: {
title: currentTitle,
description: currentDescription || '',
visible: currentVisible,
},
});
// Load collections when modal opens
useEffect(() => {
if (open && user) {
fetchCollections();
fetchPictureCollections();
}
}, [open, user, pictureId]);
const fetchCollections = async () => {
if (!user) return;
setLoadingCollections(true);
try {
const { data, error } = await supabase
.from('collections')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) throw error;
setCollections(data || []);
} catch (error) {
console.error('Error fetching collections:', error);
toast.error(translate('Failed to load collections'));
} finally {
setLoadingCollections(false);
}
};
const fetchPictureCollections = async () => {
if (!user) return;
try {
const { data, error } = await supabase
.from('collection_pictures')
.select('collection_id')
.eq('picture_id', pictureId);
if (error) throw error;
const collectionIds = new Set(data.map(item => item.collection_id));
setSelectedCollections(collectionIds);
} catch (error) {
console.error('Error fetching picture collections:', error);
}
};
const handleToggleCollection = async (collectionId: string) => {
if (!user) return;
const isSelected = selectedCollections.has(collectionId);
try {
if (isSelected) {
// Remove from collection
const { error } = await supabase
.from('collection_pictures')
.delete()
.eq('collection_id', collectionId)
.eq('picture_id', pictureId);
if (error) throw error;
setSelectedCollections(prev => {
const newSet = new Set(prev);
newSet.delete(collectionId);
return newSet;
});
toast.success(translate('Removed from collection'));
} else {
// Add to collection
const { error } = await supabase
.from('collection_pictures')
.insert({
collection_id: collectionId,
picture_id: pictureId
});
if (error) throw error;
setSelectedCollections(prev => new Set([...prev, collectionId]));
toast.success(translate('Added to collection'));
}
} catch (error) {
console.error('Error toggling collection:', error);
toast.error(translate('Failed to update collection'));
}
};
const onSubmit = async (data: EditFormData) => {
if (!user) return;
setUpdating(true);
try {
const { error } = await supabase
.from('pictures')
.update({
title: data.title?.trim() || null,
description: data.description || null,
visible: data.visible,
updated_at: new Date().toISOString(),
})
.eq('id', pictureId)
.eq('user_id', user.id);
if (error) throw error;
toast.success(translate('Picture updated successfully!'));
onUpdateSuccess();
} catch (error: any) {
console.error('Error updating picture:', error);
toast.error(translate('Failed to update picture'));
} finally {
setUpdating(false);
}
};
const handleClose = () => {
form.reset();
onOpenChange(false);
};
const handleMagicGenerate = async () => {
if (!imageUrl) {
toast.error(translate('No image URL available'));
return;
}
setIsGenerating(true);
try {
// Fetch the image and convert to File
const response = await fetch(imageUrl);
const blob = await response.blob();
const file = new File([blob], 'image.png', { type: blob.type || 'image/png' });
// Call OpenAI to analyze the image
const result = await analyzeImages([file], AI_IMAGE_ANALYSIS_PROMPT);
if (!result) {
toast.error(translate('OpenAI API key not configured. Please add your OpenAI API key in your profile settings.'));
return;
}
// Update form fields with generated content
form.setValue('title', result.title);
form.setValue('description', result.description);
toast.success(translate('Title and description generated!'));
} catch (error: any) {
console.error('Error generating metadata:', error);
toast.error(error.message || translate('Failed to generate metadata. Please check your OpenAI API key.'));
} finally {
setIsGenerating(false);
}
};
const handleMicrophone = async () => {
if (isRecording) {
// Stop recording
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
setIsRecording(false);
}
} else {
// Start recording
try {
// Check if MediaRecorder is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
toast.error(translate('Audio recording is not supported in your browser'));
return;
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Create MediaRecorder with common audio format
const options = { mimeType: 'audio/webm' };
let mediaRecorder: MediaRecorder;
try {
mediaRecorder = new MediaRecorder(stream, options);
} catch (e) {
// Fallback without options if the format is not supported
mediaRecorder = new MediaRecorder(stream);
}
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
setIsTranscribing(true);
try {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' });
toast.info(translate('Transcribing audio...'));
const transcribedText = await transcribeAudio(audioFile);
if (transcribedText) {
// Append transcribed text to description field
const currentDescription = form.getValues('description') || '';
const trimmed = currentDescription.trim();
const newDescription = trimmed ? `${trimmed}\n\n${transcribedText}` : transcribedText;
form.setValue('description', newDescription);
toast.success(translate('Audio transcribed successfully!'));
} else {
toast.error(translate('Failed to transcribe audio. Please check your OpenAI API key.'));
}
} catch (error: any) {
console.error('Error transcribing audio:', error);
toast.error(error.message || translate('Failed to transcribe audio'));
} finally {
setIsTranscribing(false);
audioChunksRef.current = [];
// Stop all tracks
stream.getTracks().forEach(track => track.stop());
}
};
mediaRecorder.start();
setIsRecording(true);
toast.info(translate('Recording started... Click again to stop'));
} catch (error: any) {
console.error('Error accessing microphone:', error);
if (error.name === 'NotAllowedError') {
toast.error(translate('Microphone access denied. Please allow microphone access in your browser settings.'));
} else {
toast.error(translate('Failed to access microphone') + ': ' + error.message);
}
}
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Edit3 className="h-5 w-5" />
<T>Edit Picture</T>
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="edit" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="edit" className="flex items-center gap-2">
<Edit3 className="h-4 w-4" />
<T>Edit Details</T>
</TabsTrigger>
<TabsTrigger value="collections" className="flex items-center gap-2">
<Bookmark className="h-4 w-4" />
<T>Collections</T>
</TabsTrigger>
<TabsTrigger value="versions" className="flex items-center gap-2">
<GitBranch className="h-4 w-4" />
<T>Versions</T>
</TabsTrigger>
</TabsList>
<TabsContent value="edit" className="mt-4">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Magic Generate Button */}
{imageUrl && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleMagicGenerate}
disabled={isGenerating || updating}
>
{isGenerating ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-primary border-t-transparent mr-2"></div>
<T>Generating...</T>
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
<T>Generate Title & Description with AI</T>
</>
)}
</Button>
)}
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel><T>Title (Optional)</T></FormLabel>
<FormControl>
<Input
placeholder={translate("Enter a title...")}
{...field}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between mb-2">
<FormLabel><T>Description (Optional)</T></FormLabel>
<button
type="button"
onClick={handleMicrophone}
disabled={isTranscribing || updating}
className={`p-1.5 rounded-md transition-colors ${
isRecording
? 'bg-red-100 text-red-600 hover:bg-red-200'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={translate(isRecording ? 'Stop recording' : 'Record audio')}
>
{isTranscribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isRecording ? (
<MicOff className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
</button>
</div>
<FormControl>
<MarkdownEditor
value={field.value || ''}
onChange={field.onChange}
placeholder={translate("Describe your photo... You can use **markdown** formatting!")}
className="min-h-[120px]"
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="visible"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
<T>Visible</T>
</FormLabel>
<div className="text-sm text-muted-foreground">
<T>Make this picture visible to others</T>
</div>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={handleClose}
disabled={updating}
>
<T>Cancel</T>
</Button>
<Button
type="submit"
className="flex-1"
disabled={updating}
>
<T>{updating ? 'Updating...' : 'Update'}</T>
</Button>
</div>
</form>
</Form>
</TabsContent>
<TabsContent value="collections" className="mt-4">
<div className="space-y-4">
{loadingCollections ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : collections.length === 0 ? (
<div className="text-center py-8">
<Bookmark className="h-12 w-12 mx-auto mb-3 text-muted-foreground opacity-50" />
<p className="text-muted-foreground mb-4">
<T>No collections yet</T>
</p>
<Button
variant="outline"
onClick={() => {
handleClose();
// Navigate to create collection - could be improved with proper navigation
window.location.href = '/collections/new';
}}
>
<T>Create Collection</T>
</Button>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{collections.map((collection) => (
<Card
key={collection.id}
className={`cursor-pointer transition-colors ${
selectedCollections.has(collection.id)
? 'bg-primary/10 border-primary'
: 'hover:bg-muted/50'
}`}
onClick={() => handleToggleCollection(collection.id)}
>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div className="flex-1 mr-3">
<div className="flex items-center gap-2">
<h4 className="font-medium">{collection.name}</h4>
{!collection.is_public && (
<span className="text-xs text-muted-foreground">(Private)</span>
)}
</div>
{collection.description && (
<p className="text-sm text-muted-foreground line-clamp-1">
{collection.description}
</p>
)}
</div>
<Checkbox
checked={selectedCollections.has(collection.id)}
onCheckedChange={() => handleToggleCollection(collection.id)}
onClick={(e) => e.stopPropagation()}
/>
</div>
</CardContent>
</Card>
))}
</div>
)}
<div className="pt-2 text-sm text-muted-foreground text-center">
<T>
{selectedCollections.size === 0
? 'Not in any collections'
: selectedCollections.size === 1
? 'In 1 collection'
: `In ${selectedCollections.size} collections`}
</T>
</div>
</div>
</TabsContent>
<TabsContent value="versions" className="mt-4">
<VersionSelector
currentPictureId={pictureId}
onVersionSelect={onUpdateSuccess}
/>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};
export default EditImageModal;

View File

@ -0,0 +1,422 @@
import { useState, useRef, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent } from '@/components/ui/card';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { Mic, MicOff, Loader2, Bookmark } from 'lucide-react';
import MarkdownEditor from '@/components/MarkdownEditor';
import { transcribeAudio } from '@/lib/openai';
import { T, translate } from '@/i18n';
interface Collection {
id: string;
name: string;
description: string | null;
slug: string;
is_public: boolean;
}
const editSchema = z.object({
title: z.string().max(100, 'Title must be less than 100 characters').optional(),
description: z.string().max(1000, 'Description must be less than 1000 characters').optional(),
visible: z.boolean(),
});
type EditFormData = z.infer<typeof editSchema>;
interface EditVideoModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
videoId: string;
currentTitle: string;
currentDescription: string | null;
currentVisible: boolean;
onUpdateSuccess: () => void;
}
const EditVideoModal = ({
open,
onOpenChange,
videoId,
currentTitle,
currentDescription,
currentVisible,
onUpdateSuccess
}: EditVideoModalProps) => {
const [updating, setUpdating] = useState(false);
const { user } = useAuth();
// Microphone recording state
const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
// Collections state
const [collections, setCollections] = useState<Collection[]>([]);
const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
const [loadingCollections, setLoadingCollections] = useState(false);
const form = useForm<EditFormData>({
resolver: zodResolver(editSchema),
defaultValues: {
title: currentTitle,
description: currentDescription || '',
visible: currentVisible,
},
});
// Load collections when modal opens
useEffect(() => {
if (open && user) {
fetchCollections();
fetchVideoCollections();
}
}, [open, user, videoId]);
const fetchCollections = async () => {
if (!user) return;
setLoadingCollections(true);
try {
const { data, error } = await supabase
.from('collections')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) throw error;
setCollections(data || []);
} catch (error) {
console.error('Error fetching collections:', error);
toast.error(translate('Failed to load collections'));
} finally {
setLoadingCollections(false);
}
};
const fetchVideoCollections = async () => {
if (!user) return;
try {
// Reuse collection_pictures table with picture_id field for videos
const { data, error } = await supabase
.from('collection_pictures')
.select('collection_id')
.eq('picture_id', videoId);
if (error) throw error;
if (data) {
const collectionIds = data.map(cp => cp.collection_id);
setSelectedCollections(new Set(collectionIds));
}
} catch (error) {
console.error('Error fetching video collections:', error);
}
};
const handleCollectionToggle = (collectionId: string) => {
const newSelected = new Set(selectedCollections);
if (newSelected.has(collectionId)) {
newSelected.delete(collectionId);
} else {
newSelected.add(collectionId);
}
setSelectedCollections(newSelected);
};
const onSubmit = async (data: EditFormData) => {
if (!user) return;
setUpdating(true);
try {
// Update video metadata in pictures table
const { error: updateError } = await supabase
.from('pictures')
.update({
title: data.title?.trim() || null,
description: data.description?.trim() || null,
visible: data.visible,
updated_at: new Date().toISOString(),
})
.eq('id', videoId)
.eq('user_id', user.id)
.eq('type', 'mux-video');
if (updateError) throw updateError;
// Update collections (reuse collection_pictures table with picture_id for videos)
try {
// First, remove all existing collection associations
await supabase
.from('collection_pictures')
.delete()
.eq('picture_id', videoId);
// Then add new associations
if (selectedCollections.size > 0) {
const collectionInserts = Array.from(selectedCollections).map(collectionId => ({
collection_id: collectionId,
picture_id: videoId,
}));
await supabase
.from('collection_pictures')
.insert(collectionInserts);
}
} catch (collectionError) {
console.error('Collection update failed:', collectionError);
toast.error(translate('Failed to update collections'));
// Don't fail the whole update if collections fail
}
toast.success(translate('Video updated successfully'));
onUpdateSuccess();
onOpenChange(false);
} catch (error) {
console.error('Error updating video:', error);
toast.error(translate('Failed to update video'));
} finally {
setUpdating(false);
}
};
const handleMicrophone = async (field: 'title' | 'description') => {
if (isRecording) {
// Stop recording
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
} else {
// Start recording
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
// Stop all tracks
stream.getTracks().forEach(track => track.stop());
// Transcribe audio
setIsTranscribing(true);
try {
const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' });
const transcription = await transcribeAudio(audioFile);
if (transcription) {
if (field === 'title') {
form.setValue('title', transcription);
} else {
const currentDesc = form.getValues('description') || '';
form.setValue('description', currentDesc ? `${currentDesc}\n\n${transcription}` : transcription);
}
toast.success(translate('Voice transcribed successfully!'));
}
} catch (error) {
console.error('Error transcribing audio:', error);
toast.error(translate('Failed to transcribe audio'));
} finally {
setIsTranscribing(false);
}
};
mediaRecorder.start();
setIsRecording(true);
toast.info(translate('Recording... Click again to stop'));
} catch (error) {
console.error('Error starting recording:', error);
toast.error(translate('Failed to start recording. Please check microphone permissions.'));
}
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle><T>Edit Video</T></DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<Tabs defaultValue="metadata" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="metadata"><T>Metadata</T></TabsTrigger>
<TabsTrigger value="collections">
<Bookmark className="h-4 w-4 mr-1" />
<T>Collections</T>
</TabsTrigger>
</TabsList>
<TabsContent value="metadata" className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel><T>Title</T></FormLabel>
<div className="relative">
<FormControl>
<Input placeholder={translate('Video title')} {...field} />
</FormControl>
<button
type="button"
onClick={() => handleMicrophone('title')}
disabled={isTranscribing}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-accent transition-colors"
>
{isTranscribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isRecording ? (
<MicOff className="h-4 w-4 text-red-500" />
) : (
<Mic className="h-4 w-4" />
)}
</button>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel><T>Description</T></FormLabel>
<div className="relative">
<FormControl>
<MarkdownEditor
value={field.value || ''}
onChange={field.onChange}
placeholder={translate('Add a description (supports Markdown)')}
className="min-h-[120px]"
/>
</FormControl>
<button
type="button"
onClick={() => handleMicrophone('description')}
disabled={isTranscribing}
className="absolute right-2 top-2 p-1.5 rounded-md hover:bg-accent transition-colors z-10"
>
{isTranscribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isRecording ? (
<MicOff className="h-4 w-4 text-red-500" />
) : (
<Mic className="h-4 w-4" />
)}
</button>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="visible"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base"><T>Visibility</T></FormLabel>
<div className="text-sm text-muted-foreground">
<T>Make this video visible to others</T>
</div>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="collections" className="space-y-4">
{loadingCollections ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : collections.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Bookmark className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p><T>No collections yet</T></p>
<p className="text-sm"><T>Create a collection first to organize your videos</T></p>
</div>
) : (
<div className="space-y-2">
{collections.map((collection) => (
<Card key={collection.id} className="cursor-pointer hover:bg-accent/50 transition-colors">
<CardContent className="p-4">
<div className="flex items-center space-x-3">
<Checkbox
checked={selectedCollections.has(collection.id)}
onCheckedChange={() => handleCollectionToggle(collection.id)}
/>
<div className="flex-1">
<h4 className="font-medium">{collection.name}</h4>
{collection.description && (
<p className="text-sm text-muted-foreground line-clamp-1">
{collection.description}
</p>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
<div className="flex justify-end space-x-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<T>Cancel</T>
</Button>
<Button type="submit" disabled={updating}>
{updating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<T>Updating...</T>
</>
) : (
<T>Save Changes</T>
)}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
export default EditVideoModal;

View File

@ -0,0 +1,190 @@
import React, { useRef, useState } from 'react';
import { usePublisher, insertImage$ } from '@mdxeditor/editor';
import { ImageIcon, Mic, MicOff, Loader2, Maximize, Minimize, Save } from 'lucide-react';
import { toast } from 'sonner';
import { T, translate } from '@/i18n';
import { transcribeAudio } from '@/lib/openai';
import { useLog } from '@/contexts/LogContext';
interface InsertCustomImageProps {
onRequestImage: () => Promise<string>;
}
export function InsertCustomImage({ onRequestImage }: InsertCustomImageProps) {
const insertImage = usePublisher(insertImage$);
const { addLog } = useLog();
const handleClick = async () => {
try {
addLog('info', 'InsertCustomImage: Requesting image...');
const imageUrl = await onRequestImage();
addLog('info', `InsertCustomImage: Received imageUrl: ${imageUrl}`);
if (imageUrl) {
addLog('success', 'InsertCustomImage: Inserting image into editor');
insertImage({
src: imageUrl,
altText: 'Selected image'
});
} else {
addLog('error', 'InsertCustomImage: imageUrl is empty/undefined');
}
} catch (error) {
addLog('error', `InsertCustomImage: Failed to insert image - ${error}`);
}
};
return (
<button
onClick={handleClick}
className="flex items-center justify-center p-1 rounded hover:bg-accent transition-colors"
title="Insert image from gallery"
>
<ImageIcon className="h-4 w-4" />
</button>
)
}
interface MicrophoneTranscribeProps {
onTranscribed: (text: string) => void;
}
export function MicrophoneTranscribe({ onTranscribed }: MicrophoneTranscribeProps) {
const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const { addLog } = useLog();
const handleToggle = async () => {
if (isRecording) {
// Stop recording
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
addLog('info', 'MicrophoneTranscribe: Stopping recording...');
mediaRecorderRef.current.stop();
}
return;
}
// Start recording
try {
addLog('info', 'MicrophoneTranscribe: Starting audio recording...');
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
setIsRecording(false);
setIsTranscribing(true);
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' });
try {
addLog('info', 'MicrophoneTranscribe: Transcribing audio with Whisper...');
const transcription = await transcribeAudio(audioFile, 'whisper-1');
if (transcription) {
addLog('success', `MicrophoneTranscribe: Transcription successful (${transcription.length} chars)`);
onTranscribed(transcription);
toast.success(translate('Audio transcribed successfully!'));
} else {
addLog('error', 'MicrophoneTranscribe: Transcription returned empty result');
toast.error(translate('Failed to transcribe audio'));
}
} catch (error: any) {
addLog('error', `MicrophoneTranscribe: Transcription failed - ${error.message}`);
toast.error(translate('Error transcribing audio'));
} finally {
setIsTranscribing(false);
// Stop all tracks
stream.getTracks().forEach(track => track.stop());
}
};
mediaRecorder.start();
setIsRecording(true);
addLog('success', 'MicrophoneTranscribe: Recording started');
toast.info(translate('Recording... Click again to stop'));
} catch (error: any) {
addLog('error', `MicrophoneTranscribe: Could not access microphone - ${error.message}`);
toast.error(translate('Could not access microphone'));
setIsRecording(false);
}
};
return (
<button
onClick={handleToggle}
disabled={isTranscribing}
className={`flex items-center justify-center p-1 rounded transition-colors ${
isRecording
? 'bg-red-100 text-red-600 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400'
: 'hover:bg-accent'
}`}
title={isTranscribing ? 'Transcribing...' : isRecording ? 'Stop recording' : 'Record audio'}
>
{isTranscribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isRecording ? (
<MicOff className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
</button>
);
}
interface FullscreenToggleProps {
isFullscreen: boolean;
onToggle: () => void;
}
export function FullscreenToggle({ isFullscreen, onToggle }: FullscreenToggleProps) {
return (
<button
onClick={onToggle}
className="flex items-center justify-center p-1 rounded hover:bg-accent transition-colors"
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{isFullscreen ? (
<Minimize className="h-4 w-4" />
) : (
<Maximize className="h-4 w-4" />
)}
</button>
);
}
interface SaveButtonProps {
onSave?: () => void;
}
export function SaveButton({ onSave }: SaveButtonProps) {
const { addLog } = useLog();
if (!onSave) return null;
const handleClick = () => {
addLog('info', 'SaveButton: Saving content...');
onSave();
addLog('success', 'SaveButton: Content saved successfully');
toast.success(translate('Content saved!'));
};
return (
<button
onClick={handleClick}
className="flex items-center justify-center p-1 rounded hover:bg-accent transition-colors"
title="Save content (Ctrl+S)"
>
<Save className="h-4 w-4" />
</button>
);
}

View File

@ -0,0 +1,203 @@
import { MediaGrid, PhotoGrid } from "./PhotoGrid";
import MediaCard from "./MediaCard";
import React, { useEffect, useState, useRef } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "react-router-dom";
import { useProfiles } from "@/contexts/ProfilesContext";
import { usePostNavigation } from "@/hooks/usePostNavigation";
import { useOrganization } from "@/contexts/OrganizationContext";
import { useFeedData } from "@/hooks/useFeedData";
import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry";
import { UserProfile } from '../pages/Post/types';
import * as db from '../pages/Post/db';
import type { MediaType } from "@/types";
import { supabase } from "@/integrations/supabase/client";
// Duplicate types for now or we could reuse specific generic props
// To minimalize refactoring PhotoGrid, I'll copy the logic but use the Feed variant
interface MediaItemType {
id: string;
picture_id?: string;
title: string;
description: string | null;
image_url: string;
thumbnail_url: string | null;
type: MediaType;
meta: any | null;
likes_count: number;
created_at: string;
user_id: string;
comments: { count: number }[];
author_profile?: UserProfile;
job?: any;
responsive?: any;
}
import type { FeedSortOption } from '@/hooks/useFeedData';
interface GalleryLargeProps {
customPictures?: MediaItemType[];
customLoading?: boolean;
navigationSource?: 'home' | 'collection' | 'tag' | 'user';
navigationSourceId?: string;
sortBy?: FeedSortOption;
}
const GalleryLarge = ({
customPictures,
customLoading,
navigationSource = 'home',
navigationSourceId,
sortBy = 'latest'
}: GalleryLargeProps) => {
const { user } = useAuth();
const navigate = useNavigate();
const { setNavigationData, navigationData } = usePostNavigation();
const { orgSlug, isOrgContext } = useOrganization();
const [mediaItems, setMediaItems] = useState<MediaItemType[]>([]);
const [userLikes, setUserLikes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
// 1. Data Fetching
const { posts: feedPosts, loading: feedLoading } = useFeedData({
source: navigationSource,
sourceId: navigationSourceId,
isOrgContext,
orgSlug,
sortBy,
// Disable hook if we have custom pictures
enabled: !customPictures
});
// 2. State & Effects
useEffect(() => {
let finalMedia: MediaItemType[] = [];
if (customPictures) {
finalMedia = customPictures;
setLoading(customLoading || false);
} else {
// Map FeedPost[] -> MediaItemType[]
finalMedia = db.mapFeedPostsToMediaItems(feedPosts as any, sortBy);
setLoading(feedLoading);
}
setMediaItems(finalMedia || []);
// Update Navigation Data
if (finalMedia && finalMedia.length > 0) {
const navData = {
posts: finalMedia.map(item => ({
id: item.id,
title: item.title,
image_url: item.image_url,
user_id: item.user_id,
type: normalizeMediaType(item.type)
})),
currentIndex: 0,
source: navigationSource,
sourceId: navigationSourceId
};
setNavigationData(navData);
}
}, [feedPosts, feedLoading, customPictures, customLoading, navigationSource, navigationSourceId, setNavigationData, sortBy]);
const fetchUserLikes = async () => {
if (!user || mediaItems.length === 0) return;
try {
// Collect IDs to check (picture_id for feed, id for collection/direct pictures)
const targetIds = mediaItems
.map(item => item.picture_id || item.id)
.filter(Boolean) as string[];
if (targetIds.length === 0) return;
// Fetch likes only for the displayed items
const { data: likesData, error } = await supabase
.from('likes')
.select('picture_id')
.eq('user_id', user.id)
.in('picture_id', targetIds);
if (error) throw error;
// Merge new likes with existing set
setUserLikes(prev => {
const newSet = new Set(prev);
likesData?.forEach(l => newSet.add(l.picture_id));
return newSet;
});
} catch (error) {
console.error('Error fetching user likes:', error);
}
};
const handleError = () => {
window.location.reload();
}
if (loading) {
return (
<div className="py-8">
<div className="text-center text-muted-foreground">
Loading gallery...
</div>
</div>
);
}
if (!mediaItems || mediaItems.length === 0) {
return (
<div className="py-8">
<div className="text-center text-muted-foreground">
<p className="text-lg">No media yet!</p>
<p>Be the first to share content with the community.</p>
</div>
</div>
)
}
return (
<div className="w-full relative max-w-4xl mx-auto px-4 pb-20 pt-4">
<div className="flex flex-col gap-12">
{mediaItems.map((item, index) => {
const itemType = normalizeMediaType(item.type);
const isVideo = isVideoType(itemType);
const displayUrl = item.image_url;
return (
<MediaCard
key={item.id}
id={item.id}
pictureId={item.picture_id}
url={displayUrl}
thumbnailUrl={item.thumbnail_url}
title={item.title}
author={undefined as any}
authorAvatarUrl={undefined}
authorId={item.user_id}
likes={item.likes_count || 0}
comments={item.comments[0]?.count || 0}
isLiked={userLikes.has(item.picture_id || item.id)}
description={item.description}
type={itemType}
meta={item.meta}
onClick={() => navigate(`/post/${item.id}`)}
onLike={fetchUserLikes}
onDelete={handleError}
created_at={item.created_at}
job={item.job}
responsive={item.responsive}
variant="feed"
/>
);
})}
</div>
</div>
);
};
export default GalleryLarge;

View File

@ -0,0 +1,213 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { set } from 'idb-keyval';
import { toast } from 'sonner';
import { Upload } from 'lucide-react';
import { T, translate } from '@/i18n';
const GlobalDragDrop = () => {
const navigate = useNavigate();
const [isDragging, setIsDragging] = useState(false);
const dragCounter = React.useRef(0);
useEffect(() => {
const isValidDrag = (e: DragEvent) => {
const types = e.dataTransfer?.types || [];
if (types.includes('polymech/internal')) return false;
// Allow Files or Links (text/uri-list)
return types.includes('Files') || types.includes('text/uri-list');
};
const handleDragEnter = (e: DragEvent) => {
if (!isValidDrag(e)) return;
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
if (e.dataTransfer?.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
};
const handleDragLeave = (e: DragEvent) => {
if (!isValidDrag(e)) return;
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
if (dragCounter.current === 0) {
setIsDragging(false);
}
};
const handleDragOver = (e: DragEvent) => {
if (!isDragging) return;
e.preventDefault();
e.stopPropagation();
};
const handleDrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounter.current = 0;
const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
const url = e.dataTransfer?.getData('text/uri-list') || e.dataTransfer?.getData('text/plain');
// Filter for supported types (images and videos)
const supportedFiles = files.filter(f => f.type.startsWith('image/') || f.type.startsWith('video/'));
// Check if it's a valid URL (simplistic check)
const isUrl = url && (url.startsWith('http://') || url.startsWith('https://'));
if (supportedFiles.length === 0 && !isUrl) {
// Only error if we really expected something and got nothing valid
// But since we pre-filter on dragEnter, maybe we should just return silently or notify?
// If files were dropped but unsupported type:
if (files.length > 0) {
toast.error("Unsupported file type. Please drop images or videos.");
}
return;
}
try {
let droppedItems = [];
let text = '';
let title = '';
if (supportedFiles.length > 0) {
// Normal file workflow
droppedItems = supportedFiles;
} else if (isUrl) {
// URL Workflow
toast.info(translate('Processing link...'));
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333';
try {
const response = await fetch(`${serverUrl}/api/serving/site-info?url=${encodeURIComponent(url)}`);
if (!response.ok) throw new Error('Failed to fetch site info');
const siteInfo = await response.json();
// Construct a "Virtual File" object to pass to the wizard
// It won't have a real File object, but validation in CreationWizardPopup will need to handle this
const virtualItem = {
id: crypto.randomUUID(),
path: url,
src: siteInfo.page?.image || 'https://picsum.photos/800/600', // Pseudo picture
title: siteInfo.page?.title || siteInfo.title || url,
description: siteInfo.page?.description || siteInfo.description || '',
type: 'page-external', // New type
meta: siteInfo,
file: null, // No physical file
selected: true
};
// We'll pass this array. The IDB structure expects "files" usually,
// but we can adapt CreationWizardPopup to handle objects too.
// Or we hack it by putting it in a special property.
// Let's stick to the 'share-target' schema but adapt the payload.
// Hack: Store it in a custom format that we'll parse in App.tsx / CreationWizard
await set('share-target', {
items: [virtualItem], // prefer 'items' over 'files' moving forward
files: [], // Keep empty to avoid confusion if we used 'files' for real File objects
title: virtualItem.title,
text: virtualItem.description,
url: url,
timestamp: Date.now(),
type: 'external_link'
});
navigate('/new?shared=true');
return;
} catch (err) {
console.error("Failed to fetch site info:", err);
toast.error(translate("Failed to get link info. Using raw URL."));
// Fallback to raw URL
await set('share-target', {
items: [{
id: crypto.randomUUID(),
path: url,
src: 'https://picsum.photos/800/600',
title: url,
description: '',
type: 'page-external',
meta: { url },
file: null,
selected: true
}],
files: [],
title: '',
text: '',
url: url,
timestamp: Date.now(),
type: 'external_link'
});
navigate('/new?shared=true');
return;
}
}
// File Fallback (existing logic)
if (supportedFiles.length > 0) {
await set('share-target', {
files: supportedFiles,
title: '',
text: '',
url: isUrl ? url : '',
timestamp: Date.now()
});
navigate('/new?shared=true');
}
} catch (error) {
console.error("Error processing dropped files/links:", error);
toast.error("Failed to process drop.");
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsDragging(false);
dragCounter.current = 0;
}
};
window.addEventListener('dragenter', handleDragEnter);
window.addEventListener('dragleave', handleDragLeave);
window.addEventListener('dragover', handleDragOver);
window.addEventListener('drop', handleDrop);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('dragenter', handleDragEnter);
window.removeEventListener('dragleave', handleDragLeave);
window.removeEventListener('dragover', handleDragOver);
window.removeEventListener('drop', handleDrop);
window.removeEventListener('keydown', handleKeyDown);
};
}, [navigate, isDragging]);
if (!isDragging) return null;
return (
<div className="fixed inset-0 z-[99999] bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center border-4 border-dashed border-primary m-4 rounded-xl pointer-events-none">
<div className="bg-background p-8 rounded-full shadow-lg mb-4 animate-bounce">
<Upload className="w-12 h-12 text-primary" />
</div>
<h2 className="text-2xl font-bold text-foreground">
<T>Drop files to upload</T>
</h2>
<p className="text-muted-foreground mt-2">
<T>Drag images or videos anywhere to start</T>
</p>
</div>
);
};
export default GlobalDragDrop;

View File

@ -0,0 +1,52 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { parseHashtagContent, type ContentSegment } from '@/utils/tagUtils';
interface HashtagTextProps {
children: string;
className?: string;
onTagClick?: (tag: string) => void;
}
const HashtagText: React.FC<HashtagTextProps> = ({
children,
className = '',
onTagClick
}) => {
const navigate = useNavigate();
const handleTagClick = (tag: string) => {
if (onTagClick) {
onTagClick(tag);
} else {
navigate(`/tags/${tag}`);
}
};
const segments = parseHashtagContent(children);
return (
<span className={className}>
{segments.map((segment: ContentSegment, index: number) => {
if (segment.type === 'hashtag') {
return (
<button
key={`tag-${index}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleTagClick(segment.content);
}}
className="text-primary hover:text-primary/80 font-medium hover:underline cursor-pointer transition-colors"
>
#{segment.content}
</button>
);
}
return <span key={`text-${index}`}>{segment.content}</span>;
})}
</span>
);
};
export default HashtagText;

View File

@ -0,0 +1,110 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Camera, Search, Heart, User, Upload, Bell, LogOut, ListFilter } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { Link } from "react-router-dom";
import UploadModal from "./UploadModal";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useLog } from "@/contexts/LogContext";
const Header = () => {
const { user, signOut } = useAuth();
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const { isLoggerVisible, setLoggerVisible } = useLog();
return (
<>
<header className="fixed top-0 w-full z-50 bg-glass border-b border-glass backdrop-blur-glass">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
{/* Logo */}
<Link to="/" className="flex items-center space-x-2">
<div className="p-2 bg-gradient-primary rounded-xl shadow-glow">
<Camera className="h-6 w-6 text-white" />
</div>
<h1 className="text-xl font-bold bg-gradient-primary bg-clip-text text-transparent">
TauriPics
</h1>
</Link>
{/* Search Bar */}
<div className="hidden md:flex items-center max-w-md w-full mx-8">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search photos, users, collections..."
className="w-full pl-10 pr-4 py-2 bg-muted border border-border rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Navigation */}
<div className="flex items-center space-x-2">
<ThemeToggle />
<Button
variant="ghost"
size="sm"
onClick={() => setLoggerVisible(!isLoggerVisible)}
className={isLoggerVisible ? "text-primary" : ""}
title="Toggle Logger"
>
<ListFilter className="h-4 w-4" />
</Button>
{user ? (
<>
<Button variant="ghost" size="sm" className="hidden md:flex">
<Heart className="h-4 w-4 mr-2" />
Favorites
</Button>
<Button variant="ghost" size="sm">
<Bell className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setUploadModalOpen(true)}
>
<Upload className="h-4 w-4 mr-2" />
Upload
</Button>
<Link to="/profile">
<Button variant="ghost" size="sm">
<User className="h-4 w-4 mr-2" />
Profile
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={signOut}
className="text-red-500 hover:text-red-600"
>
<LogOut className="h-4 w-4" />
</Button>
</>
) : (
<Link to="/auth">
<Button size="sm" className="bg-gradient-primary text-white border-0 hover:opacity-90">
<User className="h-4 w-4 mr-2" />
Sign In
</Button>
</Link>
)}
</div>
</div>
</div>
</header>
{user && (
<UploadModal
open={uploadModalOpen}
onOpenChange={setUploadModalOpen}
onUploadSuccess={() => window.location.reload()}
/>
)}
</>
);
};
export default Header;

View File

@ -0,0 +1,117 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Camera, Upload, Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { Link } from "react-router-dom";
import UploadModal from "./UploadModal";
import heroImage from "@/assets/hero-image.jpg";
const HeroSection = () => {
const { user } = useAuth();
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const handleStartSharing = () => {
if (user) {
setUploadModalOpen(true);
}
};
return (
<>
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
{/* Background Image */}
<div className="absolute inset-0 z-0">
<img
src={heroImage}
alt="Photography background"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-hero"></div>
</div>
{/* Floating Elements */}
<div className="absolute inset-0 z-10">
<div className="absolute top-20 left-10 w-20 h-20 bg-gradient-primary rounded-full opacity-20 animate-pulse"></div>
<div className="absolute top-40 right-20 w-32 h-32 bg-gradient-secondary rounded-full opacity-30 animate-pulse delay-1000"></div>
<div className="absolute bottom-40 left-20 w-16 h-16 bg-accent rounded-full opacity-25 animate-pulse delay-500"></div>
</div>
{/* Content */}
<div className="relative z-20 text-center max-w-4xl mx-auto px-4">
<div className="mb-6">
<div className="inline-flex items-center space-x-2 bg-glass border border-glass rounded-full px-4 py-2 backdrop-blur-glass mb-6">
<Sparkles className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Share your world through photos</span>
</div>
</div>
<h1 className="text-5xl md:text-7xl font-bold mb-6 leading-tight">
<span className="bg-gradient-primary bg-clip-text text-transparent">
Capture
</span>{" "}
<span className="text-foreground">& Share</span>
<br />
<span className="text-foreground">Your</span>{" "}
<span className="bg-gradient-secondary bg-clip-text text-transparent">
Stories
</span>
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto">
Join millions of photographers sharing their passion. Discover breathtaking moments,
connect with creators, and showcase your unique perspective to the world.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
{user ? (
<Button
size="lg"
className="bg-gradient-primary text-white border-0 hover:opacity-90 shadow-glow"
onClick={handleStartSharing}
>
<Upload className="h-5 w-5 mr-2" />
Start Sharing
</Button>
) : (
<Link to="/auth">
<Button size="lg" className="bg-gradient-primary text-white border-0 hover:opacity-90 shadow-glow">
<Upload className="h-5 w-5 mr-2" />
Start Sharing
</Button>
</Link>
)}
<Button variant="outline" size="lg" className="bg-glass border-glass backdrop-blur-glass hover:bg-muted">
<Camera className="h-5 w-5 mr-2" />
Explore Photos
</Button>
</div>
<div className="mt-12 flex items-center justify-center space-x-8 text-sm text-muted-foreground">
<div className="text-center">
<div className="text-2xl font-bold text-foreground">10M+</div>
<div>Photos Shared</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-foreground">500K+</div>
<div>Active Users</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-foreground">1M+</div>
<div>Daily Views</div>
</div>
</div>
</div>
</section>
{user && (
<UploadModal
open={uploadModalOpen}
onOpenChange={setUploadModalOpen}
onUploadSuccess={() => window.location.reload()}
/>
)}
</>
);
};
export default HeroSection;

View File

@ -0,0 +1,505 @@
import React, { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { RotateCw, RotateCcw, Crop as CropIcon, Download, X, Check, Save, Image as ImageIcon, Sliders } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/components/ui/use-toast';
import { uploadImage } from '@/lib/uploadUtils';
import { Slider } from '@/components/ui/slider';
interface ImageEditorProps {
imageUrl: string;
pictureId: string;
onSave?: (newUrl: string) => void;
onClose?: () => void;
}
export const ImageEditor = ({ imageUrl, pictureId, onSave, onClose }: ImageEditorProps) => {
const [previewUrl, setPreviewUrl] = useState<string | null>(imageUrl);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const { user } = useAuth();
const imageRef = useRef<HTMLImageElement>(null);
// Edit State
const [mode, setMode] = useState<'view' | 'crop' | 'adjust'>('view');
const [brightness, setBrightness] = useState(1);
const [contrast, setContrast] = useState(1);
// Reset adjustments when image changes or mode resets (optional, depends on UX)
// For now, these are "pending" adjustments locally until applied.
useEffect(() => {
setPreviewUrl(imageUrl);
// Reset local edits on new image
setBrightness(1);
setContrast(1);
setMode('view');
}, [imageUrl]);
const executeOperation = async (ops: any[]) => {
if (!previewUrl) return;
setLoading(true);
try {
const response = await fetch(previewUrl);
const blob = await response.blob();
const file = new File([blob], "source.jpg", { type: blob.type });
const formData = new FormData();
formData.append('file', file);
formData.append('operations', JSON.stringify(ops));
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://192.168.1.11:3333';
const res = await fetch(`${serverUrl}/api/images/transform`, {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error(await res.text());
const resultBlob = await res.blob();
const resultUrl = URL.createObjectURL(resultBlob);
setPreviewUrl(resultUrl);
toast({ title: "Applied" });
} catch (err: any) {
toast({
title: "Operation failed",
description: err.message,
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const handleRotate = (angle: number) => {
executeOperation([{ type: 'rotate', angle }]);
};
const handleSave = async () => {
if (!previewUrl || !pictureId || !user) return;
setLoading(true);
try {
const response = await fetch(previewUrl);
const blob = await response.blob();
const file = new File([blob], "edited.jpg", { type: "image/jpeg" });
const { publicUrl } = await uploadImage(file, user.id);
const { error: dbError } = await supabase
.from('pictures')
.update({
image_url: publicUrl,
updated_at: new Date().toISOString()
})
.eq('id', pictureId);
if (dbError) throw dbError;
toast({ title: "Image Saved", description: "Source image updated successfully." });
onSave?.(publicUrl);
} catch (err: any) {
console.error(err);
toast({
title: "Save failed",
description: err.message,
variant: "destructive"
});
} finally {
setLoading(false);
}
};
// --- Visual Cropper State ---
const [cropRect, setCropRect] = useState<{ x: number, y: number, w: number, h: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<{ x: number, y: number } | null>(null);
const [dragAction, setDragAction] = useState<'move' | 'nw' | 'ne' | 'sw' | 'se' | 'create' | null>(null);
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
const PRESETS = [
{ label: 'Free', value: null },
{ label: 'Original', value: 'original' },
{ label: 'Square', value: 1 },
{ label: '9:16', value: 9 / 16 },
{ label: '16:9', value: 16 / 9 },
{ label: '4:5', value: 4 / 5 },
{ label: '5:4', value: 5 / 4 },
{ label: '3:4', value: 3 / 4 },
{ label: '4:3', value: 4 / 3 },
];
useEffect(() => {
if (mode === 'crop' && imageRef.current) {
const { width, height } = imageRef.current.getBoundingClientRect();
const w = width * 0.8;
const h = height * 0.8;
setCropRect({
x: (width - w) / 2,
y: (height - h) / 2,
w, h
});
}
}, [mode]);
const getPointerPos = (e: React.PointerEvent | PointerEvent) => {
if (!imageRef.current) return { x: 0, y: 0 };
const rect = imageRef.current.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
};
const handlePointerDown = (e: React.PointerEvent) => {
if (mode !== 'crop' || !cropRect) return;
e.preventDefault();
e.stopPropagation();
// Capture pointer to ensure we get move/up events even if we leave the div
(e.target as Element).setPointerCapture(e.pointerId);
const { x, y } = getPointerPos(e);
const handleSize = 40; // Increased hit area for easier touch
const r = cropRect;
if (Math.abs(x - r.x) < handleSize && Math.abs(y - r.y) < handleSize) setDragAction('nw');
else if (Math.abs(x - (r.x + r.w)) < handleSize && Math.abs(y - r.y) < handleSize) setDragAction('ne');
else if (Math.abs(x - r.x) < handleSize && Math.abs(y - (r.y + r.h)) < handleSize) setDragAction('sw');
else if (Math.abs(x - (r.x + r.w)) < handleSize && Math.abs(y - (r.y + r.h)) < handleSize) setDragAction('se');
else if (x > r.x && x < r.x + r.w && y > r.y && y < r.y + r.h) setDragAction('move');
else setDragAction('create');
setIsDragging(true);
setDragStart({ x, y });
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDragging || !dragStart || !cropRect || !imageRef.current) return;
const { x, y } = getPointerPos(e);
const dx = x - dragStart.x;
const dy = y - dragStart.y;
const imgW = imageRef.current.offsetWidth;
const imgH = imageRef.current.offsetHeight;
setCropRect(prev => {
if (!prev) return null;
let next = { ...prev };
if (dragAction === 'move') {
next.x = Math.max(0, Math.min(imgW - next.w, prev.x + dx));
next.y = Math.max(0, Math.min(imgH - next.h, prev.y + dy));
} else {
let newW = next.w;
let newH = next.h;
let newX = next.x;
let newY = next.y;
if (dragAction === 'se') {
newW = Math.max(20, prev.w + dx);
newH = Math.max(20, prev.h + dy);
} else if (dragAction === 'sw') {
newW = prev.w - dx;
newX = Math.min(prev.x + prev.w - 20, prev.x + dx);
newH = Math.max(20, prev.h + dy);
} else if (dragAction === 'ne') {
newW = Math.max(20, prev.w + dx);
newH = prev.h - dy;
newY = Math.min(prev.y + prev.h - 20, prev.y + dy);
} else if (dragAction === 'nw') {
newW = prev.w - dx;
newX = Math.min(prev.x + prev.w - 20, prev.x + dx);
newH = prev.h - dy;
newY = Math.min(prev.y + prev.h - 20, prev.y + dy);
}
if (aspectRatio !== null) {
if (dragAction === 'se' || dragAction === 'sw') {
newH = newW / aspectRatio;
} else {
newW = newH * aspectRatio;
if (dragAction === 'nw') newX = prev.x + prev.w - newW;
}
newH = newW / aspectRatio;
if (dragAction === 'nw') {
newY = prev.y + prev.h - newH;
newX = prev.x + prev.w - newW;
} else if (dragAction === 'ne') {
newY = prev.y + prev.h - newH;
}
}
next.w = Math.max(20, newW);
next.h = Math.max(20, newH);
next.x = newX;
next.y = newY;
}
return next;
});
setDragStart({ x, y });
};
const handlePointerUp = (e: React.PointerEvent) => {
setIsDragging(false);
setDragAction(null);
(e.target as Element).releasePointerCapture(e.pointerId);
};
const applyCrop = () => {
if (!cropRect || !imageRef.current) return;
const displayRect = imageRef.current.getBoundingClientRect();
const scaleX = imageRef.current.naturalWidth / displayRect.width;
const scaleY = imageRef.current.naturalHeight / displayRect.height;
let realX = Math.round(cropRect.x * scaleX);
let realY = Math.round(cropRect.y * scaleY);
let realW = Math.round(cropRect.w * scaleX);
let realH = Math.round(cropRect.h * scaleY);
realX = Math.max(0, realX);
realY = Math.max(0, realY);
realW = Math.max(1, Math.min(realW, imageRef.current.naturalWidth - realX));
realH = Math.max(1, Math.min(realH, imageRef.current.naturalHeight - realY));
const realCrop = {
x: realX,
y: realY,
width: realW,
height: realH
};
executeOperation([{ type: 'crop', ...realCrop }]);
setMode('view');
};
const applyAdjust = () => {
// Send adjust op
executeOperation([{
type: 'adjust',
brightness: brightness !== 1 ? brightness : undefined,
contrast: contrast !== 1 ? contrast : undefined
}]);
setMode('view');
// Reset sliders as they are applied to the "base" image now
setBrightness(1);
setContrast(1);
};
const cancelAdjust = () => {
setBrightness(1);
setContrast(1);
setMode('view');
};
const downloadImage = () => {
if (previewUrl) {
const a = document.createElement('a');
a.href = previewUrl;
a.download = 'edited-image.jpg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
};
return (
<div className="flex flex-col h-full w-full bg-background relative overflow-hidden">
{/* Close Button if provided */}
{onClose && (
<div className="absolute top-4 right-4 z-20">
<Button variant="ghost" size="icon" onClick={onClose} className="rounded-full bg-background/50 hover:bg-background/80">
<X className="w-6 h-6" />
</Button>
</div>
)}
<div
className="flex-1 flex items-center justify-center p-8 bg-zinc-950/50 relative select-none touch-none"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
{previewUrl ? (
<div className="relative group">
<img
ref={imageRef}
src={previewUrl}
alt="Work"
className="max-h-[80vh] max-w-full object-contain pointer-events-none transition-all duration-200"
style={{
filter: mode === 'adjust' ? `brightness(${brightness}) contrast(${contrast})` : undefined
}}
draggable={false}
onDragStart={(e) => e.preventDefault()}
/>
{mode === 'crop' && cropRect && (
<div
className="absolute border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)] cursor-move"
style={{
left: cropRect.x,
top: cropRect.y,
width: cropRect.w,
height: cropRect.h,
touchAction: 'none'
}}
onPointerDown={handlePointerDown}
>
<div className="absolute -top-3 -left-3 w-6 h-6 bg-white border border-black cursor-nw-resize rounded-full shadow-sm" />
<div className="absolute -top-3 -right-3 w-6 h-6 bg-white border border-black cursor-ne-resize rounded-full shadow-sm" />
<div className="absolute -bottom-3 -left-3 w-6 h-6 bg-white border border-black cursor-sw-resize rounded-full shadow-sm" />
<div className="absolute -bottom-3 -right-3 w-6 h-6 bg-white border border-black cursor-se-resize rounded-full shadow-sm" />
<div className="absolute top-1/3 left-0 right-0 h-px bg-white/30 pointer-events-none" />
<div className="absolute top-2/3 left-0 right-0 h-px bg-white/30 pointer-events-none" />
<div className="absolute left-1/3 top-0 bottom-0 w-px bg-white/30 pointer-events-none" />
<div className="absolute left-2/3 top-0 bottom-0 w-px bg-white/30 pointer-events-none" />
</div>
)}
</div>
) : (
<div className="text-muted-foreground flex flex-col items-center">
<ImageIcon className="w-16 h-16 mb-4 opacity-20" />
<p>No image loaded</p>
</div>
)}
</div>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 flex flex-col gap-2 items-center w-full max-w-2xl px-4 pointer-events-none">
{/* Controls Container - enable pointer events for children */}
<div className="pointer-events-auto flex flex-col gap-2 items-center w-full">
{mode === 'crop' && (
<div className="bg-background/80 backdrop-blur-md border rounded-full shadow-xl px-2 py-1 flex items-center gap-1 overflow-x-auto max-w-full scrollbar-hide">
{PRESETS.map(p => (
<Button
key={p.label}
variant={aspectRatio === (p.value === 'original' ? (imageRef.current ? imageRef.current.width / imageRef.current.height : null) : p.value) ? "secondary" : "ghost"}
size="sm"
onClick={() => {
if (p.value === 'original' && imageRef.current) {
setAspectRatio(imageRef.current.width / imageRef.current.height);
} else {
setAspectRatio(p.value as number | null);
}
if (cropRect && imageRef.current) {
const ratio = p.value === 'original' ? (imageRef.current.width / imageRef.current.height) : p.value as number;
if (ratio) {
const newH = cropRect.w / ratio;
setCropRect({ ...cropRect, h: newH });
}
}
}}
className="text-xs h-8 px-2 whitespace-nowrap"
>
{p.label}
</Button>
))}
</div>
)}
{mode === 'adjust' && (
<div className="bg-background/80 backdrop-blur-md border rounded-xl shadow-xl px-4 py-4 w-full max-w-sm space-y-4">
<div className="space-y-2">
<div className="flex justify-between text-xs font-medium">
<span>Brightness</span>
<span>{Math.round(brightness * 100)}%</span>
</div>
<Slider
value={[brightness]}
min={0.5}
max={1.5}
step={0.05}
onValueChange={([v]) => setBrightness(v)}
/>
</div>
<div className="space-y-2">
<div className="flex justify-between text-xs font-medium">
<span>Contrast</span>
<span>{Math.round(contrast * 100)}%</span>
</div>
<Slider
value={[contrast]}
min={0.5}
max={1.5}
step={0.05}
onValueChange={([v]) => setContrast(v)}
/>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button variant="ghost" size="sm" onClick={cancelAdjust}>Cancel</Button>
<Button size="sm" onClick={applyAdjust}>Apply</Button>
</div>
</div>
)}
<div className="bg-background/80 backdrop-blur-md border rounded-full shadow-xl px-4 py-2 flex items-center gap-2">
{mode === 'view' ? (
<>
<Button variant="ghost" size="icon" onClick={() => setMode('crop')} title="Crop">
<CropIcon className="w-5 h-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setMode('adjust')} title="Adjust">
<Sliders className="w-5 h-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleRotate(-90)} title="Rotate CCW">
<RotateCcw className="w-5 h-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleRotate(90)} title="Rotate CW">
<RotateCw className="w-5 h-5" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button variant="ghost" size="icon" onClick={handleSave} disabled={!pictureId} title="Save (Overwrite)">
<Save className="w-5 h-5" />
</Button>
<Button variant="ghost" size="icon" onClick={downloadImage} title="Download">
<Download className="w-5 h-5" />
</Button>
</>
) : mode === 'crop' ? (
<>
<Button variant="ghost" size="icon" onClick={() => setMode('view')} className="text-muted-foreground">
<X className="w-5 h-5" />
</Button>
<span className="text-sm font-medium px-2">Crop Mode</span>
<Button variant="default" size="icon" className="rounded-full" onClick={applyCrop}>
<Check className="w-5 h-5" />
</Button>
</>
) : (
// Adjust Mode Toolbar
<>
<Button variant="ghost" size="icon" onClick={cancelAdjust} className="text-muted-foreground">
<X className="w-5 h-5" />
</Button>
<span className="text-sm font-medium px-2">Adjust Mode</span>
<Button variant="default" size="icon" className="rounded-full" onClick={applyAdjust}>
<Check className="w-5 h-5" />
</Button>
</>
)}
</div>
</div>
</div>
{
loading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center z-50 backdrop-blur-sm">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
</div >
);
};

View File

@ -0,0 +1,559 @@
import { useState, useEffect, useRef } from 'react';
import { ImageFile } from '../types';
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
import { ArrowUp } from 'lucide-react';
import { downloadImage, generateFilename } from '@/utils/downloadUtils';
import { toast } from 'sonner';
import { translate } from '@/i18n';
interface ImageGalleryProps {
images: ImageFile[];
onImageSelection?: (imagePath: string, isMultiSelect: boolean) => void;
onImageRemove?: (imagePath: string) => void;
onImageDelete?: (imagePath: string) => void;
onImageSaveAs?: (imagePath: string) => void;
showSelection?: boolean;
currentIndex: number;
setCurrentIndex: (index: number) => void;
onDoubleClick?: (imagePath: string) => void;
onLightboxPromptSubmit?: (prompt: string, imagePath: string) => void;
promptHistory?: string[];
historyIndex?: number;
navigateHistory?: (direction: 'up' | 'down') => void;
isGenerating?: boolean;
errorMessage?: string | null;
setErrorMessage?: (message: string | null) => void;
}
export default function ImageGallery({
images,
onImageSelection,
onImageRemove,
onImageDelete,
onImageSaveAs,
showSelection = false,
currentIndex,
setCurrentIndex,
onDoubleClick,
onLightboxPromptSubmit,
promptHistory = [],
historyIndex = -1,
navigateHistory,
isGenerating = false,
errorMessage,
setErrorMessage
}: ImageGalleryProps) {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxLoaded, setLightboxLoaded] = useState(false);
const [isPanning, setIsPanning] = useState(false);
const [lightboxPrompt, setLightboxPrompt] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [skipDeleteConfirm, setSkipDeleteConfirm] = useState(false);
const [rememberChoice, setRememberChoice] = useState(false);
const panStartRef = useRef<{ x: number; y: number } | null>(null);
// Sync lightbox prompt with history navigation
useEffect(() => {
if (lightboxOpen && historyIndex >= 0 && historyIndex < promptHistory.length) {
setLightboxPrompt(promptHistory[historyIndex]);
}
}, [historyIndex, promptHistory, lightboxOpen]);
// Handle keyboard events for lightbox
useEffect(() => {
if (!lightboxOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Delete' && !isGenerating && onImageDelete) {
e.preventDefault();
const safeIndex = Math.max(0, Math.min(currentIndex, images.length - 1));
if (images[safeIndex]) {
handleDeleteImage(images[safeIndex].path);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [lightboxOpen, currentIndex, images, isGenerating, onImageDelete]);
const handleDeleteImage = (imagePath: string) => {
if (skipDeleteConfirm) {
// Skip confirmation and delete immediately
onImageDelete?.(imagePath);
// Close lightbox if this was the last image or adjust index
if (images.length <= 1) {
setLightboxOpen(false);
} else {
// Adjust current index if needed
const deletingIndex = images.findIndex(img => img.path === imagePath);
if (deletingIndex === currentIndex && currentIndex >= images.length - 1) {
setCurrentIndex(Math.max(0, currentIndex - 1));
}
}
} else {
// Show confirmation dialog
setShowDeleteConfirm(true);
}
};
const handleDownloadImage = async (image: ImageFile) => {
try {
const filename = generateFilename(image.path.split(/[/\\]/).pop() || 'image');
await downloadImage(image.src, filename);
toast.success(translate('Image downloaded successfully'));
} catch (error) {
console.error('Error downloading image:', error);
toast.error(translate('Failed to download image'));
}
};
const confirmDelete = (remember: boolean) => {
const safeIndex = Math.max(0, Math.min(currentIndex, images.length - 1));
if (images[safeIndex]) {
if (remember) {
setSkipDeleteConfirm(true);
}
onImageDelete?.(images[safeIndex].path);
// Close lightbox if this was the last image or adjust index
if (images.length <= 1) {
setLightboxOpen(false);
} else {
// Adjust current index if needed
if (currentIndex >= images.length - 1) {
setCurrentIndex(Math.max(0, currentIndex - 1));
}
}
}
setShowDeleteConfirm(false);
};
// Reset current index if images change, preventing out-of-bounds errors
useEffect(() => {
if (images.length > 0 && currentIndex >= images.length) {
setCurrentIndex(Math.max(0, images.length - 1));
}
}, [images.length, currentIndex, setCurrentIndex]);
// ESC key handler for lightbox
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!lightboxOpen) return;
if (event.key === 'Escape') {
setLightboxOpen(false);
} else if (event.key === 'ArrowRight' && currentIndex < images.length - 1) {
const newIndex = currentIndex + 1;
setCurrentIndex(newIndex);
preloadImage(newIndex);
} else if (event.key === 'ArrowLeft' && currentIndex > 0) {
const newIndex = currentIndex - 1;
setCurrentIndex(newIndex);
preloadImage(newIndex);
}
};
if (lightboxOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [lightboxOpen, currentIndex, images.length, setCurrentIndex]);
const preloadImage = (index: number) => {
if (images.length === 0 || index < 0 || index >= images.length) return;
setLightboxLoaded(false);
const img = new Image();
img.src = images[index].src;
img.onload = () => {
setLightboxLoaded(true);
};
img.onerror = () => {
setLightboxLoaded(true); // Show even if failed to load
};
};
const openLightbox = (index: number) => {
setCurrentIndex(index);
setLightboxOpen(true);
preloadImage(index);
};
const handleThumbnailClick = (event: React.MouseEvent<HTMLButtonElement>, imagePath: string, index: number) => {
const isMultiSelect = event.ctrlKey || event.metaKey;
if (showSelection && onImageSelection) {
onImageSelection(imagePath, isMultiSelect);
}
if (!isMultiSelect) {
setCurrentIndex(index);
}
};
if (images.length === 0) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="w-64 h-64 bg-gradient-to-br from-muted/50 to-muted rounded-xl border-2 border-dashed border-muted-foreground/30 flex items-center justify-center">
<div className="text-muted-foreground">
<svg className="w-16 h-16 mx-auto mb-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
</svg>
<p className="text-lg font-medium">No images yet</p>
<p className="text-sm">Upload images to get started</p>
</div>
</div>
</div>
);
}
// Safeguard against rendering with an invalid index after a delete/remove operation
const safeIndex = Math.max(0, Math.min(currentIndex, images.length - 1));
const currentImage = images.length > 0 ? images[safeIndex] : null;
if (!currentImage) {
// This should theoretically not be reached if the length check above is sound,
// but it's an extra layer of protection.
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="w-64 h-64 bg-muted rounded-xl flex items-center justify-center">
<p>Loading...</p>
</div>
</div>
);
}
const isGenerated = !!currentImage.isGenerated;
const isSelected = currentImage.selected || false;
return (
<div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column: Main Image Display */}
<div className="lg:col-span-2">
<div className="flex items-center justify-center rounded-lg">
<div className="relative w-full h-[300px] flex items-center justify-center">
<img
src={currentImage.src}
alt={currentImage.path}
className={`max-h-[300px] max-w-full object-contain shadow-lg border-2 transition-all duration-300 cursor-pointer ${
isSelected
? 'border-primary shadow-primary/30'
: isGenerated
? 'border-green-300'
: 'border-border'
}`}
onDoubleClick={() => openLightbox(safeIndex)}
title="Double-click for fullscreen"
/>
<div className="absolute top-2 left-2 flex flex-col gap-2">
{/* Compact overlays */}
{isGenerated && isSelected && (
<div className="bg-primary text-primary-foreground px-2 py-1 rounded text-xs font-semibold shadow-lg flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
{isGenerated && !isSelected && (
<div className="bg-green-500 text-white px-2 py-1 rounded text-xs font-semibold shadow-lg">
</div>
)}
</div>
</div>
</div>
{/* Image Info */}
<div className="text-center mt-3">
<p className="text-sm text-muted-foreground">
{currentImage.path.split(/[/\\]/).pop()} {safeIndex + 1}/{images.length}
</p>
</div>
</div>
{/* Right column: Thumbnails */}
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Images ({images.length})</h4>
<div className="grid grid-cols-2 gap-2">
{images.map((image, index) => {
const thumbIsGenerating = image.path.startsWith('generating_');
const thumbIsGenerated = !!image.isGenerated;
const thumbIsSelected = image.selected || false;
return (
<button
type="button"
key={image.path}
onClick={(e) => handleThumbnailClick(e, image.path, index)}
onDoubleClick={() => {
if (onDoubleClick) {
onDoubleClick(image.path);
} else {
openLightbox(index);
}
}}
className={`group relative aspect-square overflow-hidden transition-all duration-300 border-2 ${
currentIndex === index
? 'ring-2 ring-primary border-primary'
: thumbIsSelected
? 'border-primary ring-2 ring-primary/30'
: thumbIsGenerated
? 'border-green-300 hover:border-primary/50'
: 'border-border hover:border-border/80'
}`}
title={
thumbIsGenerated
? "Generated image - click to select/view"
: "Click to view"
}
>
<img
src={image.src}
alt={image.path}
className="w-full h-full object-cover"
/>
{/* Selection indicator */}
{thumbIsGenerated && thumbIsSelected && (
<div className="absolute top-1 left-1 bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
{/* Generated indicator */}
{thumbIsGenerated && !thumbIsSelected && (
<div className="absolute top-1 left-1 bg-green-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs">
</div>
)}
{/* Save button */}
{!thumbIsGenerating && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDownloadImage(image);
}}
className="absolute bottom-1 left-1 bg-primary/70 hover:bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center text-xs transition-all duration-200"
title="Download Image"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
)}
{/* Delete Button */}
{!thumbIsGenerating && onImageDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (window.confirm('Are you sure you want to permanently delete this file? This action cannot be undone.')) {
onImageDelete(image.path);
}
}}
className="absolute bottom-1 right-1 bg-destructive/80 hover:bg-destructive text-destructive-foreground rounded-full w-5 h-5 flex items-center justify-center text-xs transition-all duration-200"
title="Delete File Permanently"
>
×
</button>
)}
</button>
);
})}
</div>
</div>
</div>
{/* Lightbox Modal */}
{lightboxOpen && (
<div
className="fixed inset-0 bg-black/95 z-[9999] flex items-center justify-center"
onMouseDown={(e) => {
panStartRef.current = { x: e.clientX, y: e.clientY };
setIsPanning(false);
}}
onMouseMove={(e) => {
if (panStartRef.current) {
const distance = Math.sqrt(
Math.pow(e.clientX - panStartRef.current.x, 2) +
Math.pow(e.clientY - panStartRef.current.y, 2)
);
if (distance > 5) { // 5px threshold for pan detection
setIsPanning(true);
}
}
}}
onMouseUp={() => {
panStartRef.current = null;
}}
onClick={(e) => {
// Only close if not panning and clicking on background
if (!isPanning && e.target === e.currentTarget) {
setLightboxOpen(false);
}
setIsPanning(false);
}}
>
<div className="relative w-full h-full flex items-center justify-center">
{lightboxLoaded ? (
<TransformWrapper
initialScale={1}
minScale={0.1}
maxScale={10}
centerOnInit={true}
wheel={{ step: 0.1 }}
doubleClick={{ disabled: false, step: 0.7 }}
pinch={{ step: 5 }}
>
<TransformComponent
wrapperClass="w-full h-full flex items-center justify-center"
contentClass="max-w-full max-h-full"
>
<img
src={images[safeIndex].src}
alt={images[safeIndex].path}
className="max-w-full max-h-full object-contain cursor-grab active:cursor-grabbing"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
</TransformComponent>
</TransformWrapper>
) : (
<div className="flex items-center justify-center">
<div className="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin"></div>
</div>
)}
{/* Close Button */}
<button
onClick={(e) => {
e.stopPropagation();
setLightboxOpen(false);
}}
className="absolute top-4 right-4 text-white text-2xl p-4 bg-black/75 rounded-lg hover:bg-black/90 transition-all duration-200"
title="Close (ESC)"
>
×
</button>
{/* Navigation Buttons */}
{safeIndex > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setCurrentIndex(safeIndex - 1);
setLightboxLoaded(false);
const img = new Image();
img.src = images[safeIndex - 1].src;
img.onload = () => setLightboxLoaded(true);
}}
className="absolute left-4 top-1/2 transform -translate-y-1/2 p-4 text-white text-3xl bg-black/75 rounded-lg hover:bg-black/90 transition-all duration-200"
title="Previous (←)"
>
</button>
)}
{safeIndex < images.length - 1 && (
<button
onClick={(e) => {
e.stopPropagation();
setCurrentIndex(safeIndex + 1);
setLightboxLoaded(false);
const img = new Image();
img.src = images[safeIndex + 1].src;
img.onload = () => setLightboxLoaded(true);
}}
className="absolute right-4 top-1/2 transform -translate-y-1/2 p-4 text-white text-3xl bg-black/75 rounded-lg hover:bg-black/90 transition-all duration-200"
title="Next (→)"
>
</button>
)}
{/* Info */}
{lightboxLoaded && (
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black/75 text-white px-4 py-2 rounded-lg text-sm">
{`${images[safeIndex].path.split(/[/\\]/).pop()}${safeIndex + 1} of ${images.length} • Del: delete • ESC to close`}
</div>
)}
{/* Delete Confirmation Dialog */}
{showDeleteConfirm && (
<div
className="absolute inset-0 bg-black/90 flex items-center justify-center z-50"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
e.preventDefault();
confirmDelete(rememberChoice);
setRememberChoice(false);
} else if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
setShowDeleteConfirm(false);
setRememberChoice(false);
}
}}
tabIndex={0}
autoFocus
>
<div className="bg-background rounded-xl p-6 max-w-md mx-4 shadow-2xl">
<h3 className="text-lg font-semibold text-foreground mb-2">
Delete Image?
</h3>
<p className="text-muted-foreground mb-2">
Are you sure you want to delete "{images[Math.max(0, Math.min(currentIndex, images.length - 1))]?.path.split(/[/\\]/).pop()}"?
</p>
<p className="text-sm text-muted-foreground mb-4">
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-xs">Enter</kbd> to confirm or <kbd className="px-1.5 py-0.5 bg-muted rounded text-xs">Escape</kbd> to cancel
</p>
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
className="rounded"
checked={rememberChoice}
onChange={(e) => setRememberChoice(e.target.checked)}
/>
Remember for this session (skip confirmation)
</label>
<div className="flex gap-3 justify-end">
<button
onClick={() => {
setShowDeleteConfirm(false);
setRememberChoice(false);
}}
className="px-4 py-2 text-muted-foreground hover:bg-muted rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={() => {
confirmDelete(rememberChoice);
setRememberChoice(false);
}}
className="px-4 py-2 bg-destructive hover:bg-destructive/90 text-destructive-foreground rounded-lg transition-colors"
>
Delete
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,872 @@
import { useState, useEffect, useRef } from 'react';
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
import { ArrowUp, ArrowDown, Upload, Info, FileText, Sparkles, Mic, MicOff, Plus, Trash2, Save, History, Wand2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import PublishDialog from '@/components/PublishDialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { T, translate } from "@/i18n";
import { QuickAction, DEFAULT_QUICK_ACTIONS } from "@/constants";
import { StylePresetSelector } from "./StylePresetSelector";
interface ImageLightboxProps {
isOpen: boolean;
onClose: () => void;
imageUrl: string;
imageTitle: string;
originalImageId?: string;
onPromptSubmit?: (prompt: string) => void;
onPublish?: (option: 'overwrite' | 'new' | 'version', imageUrl: string, title: string, description?: string, parentId?: string, collectionIds?: string[]) => void;
isGenerating?: boolean;
isPublishing?: boolean;
showPrompt?: boolean;
showPublish?: boolean;
generatedImageUrl?: string;
// Navigation props
currentIndex?: number;
totalCount?: number;
onNavigate?: (direction: 'prev' | 'next') => void;
onPreload?: (direction: 'prev' | 'next') => void;
// Wizard features (optional)
showWizardFeatures?: boolean;
promptTemplates?: Array<{ name: string; template: string }>;
onApplyTemplate?: (template: string) => void;
onSaveTemplate?: () => void;
onDeleteTemplate?: (index: number) => void;
onOptimizePrompt?: () => void;
isOptimizing?: boolean;
onMicrophoneToggle?: () => void;
isRecording?: boolean;
isTranscribing?: boolean;
showQuickPublish?: boolean;
onQuickPublish?: () => void;
prompt?: string;
onPromptChange?: (value: string) => void;
quickActions?: QuickAction[];
// Prompt history
promptHistory?: string[];
historyIndex?: number;
onNavigateHistory?: (direction: 'up' | 'down') => void;
onManualPromptEdit?: () => void; // Callback to reset history index when user manually types
// Wizard navigation
onOpenInWizard?: () => void; // Open current image in full wizard
}
import ResponsiveImage from './ResponsiveImage';
export default function ImageLightbox({
isOpen,
onClose,
imageUrl,
imageTitle,
originalImageId,
onPromptSubmit,
onPublish,
isGenerating = false,
isPublishing = false,
showPrompt = true,
showPublish = false,
generatedImageUrl,
currentIndex,
totalCount,
onNavigate,
onPreload,
// Wizard features
showWizardFeatures = false,
promptTemplates = [],
onApplyTemplate,
onSaveTemplate,
onDeleteTemplate,
onOptimizePrompt,
isOptimizing = false,
onMicrophoneToggle,
isRecording = false,
isTranscribing = false,
showQuickPublish = false,
onQuickPublish,
prompt: externalPrompt,
onPromptChange,
// Prompt history
promptHistory = [],
historyIndex = -1,
onNavigateHistory,
onManualPromptEdit,
// Wizard navigation
onOpenInWizard,
quickActions = DEFAULT_QUICK_ACTIONS
}: ImageLightboxProps) {
const [lightboxLoaded, setLightboxLoaded] = useState(false);
const [isPanning, setIsPanning] = useState(false);
const [scale, setScale] = useState(1);
const [internalPrompt, setInternalPrompt] = useState('');
const [showPublishDialog, setShowPublishDialog] = useState(false);
const [showPromptField, setShowPromptField] = useState(false);
const [showInfoPopup, setShowInfoPopup] = useState(false);
const panStartRef = useRef<{ x: number; y: number } | null>(null);
const tapTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastTapRef = useRef<number>(0);
const swipeStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
const isSwipingRef = useRef(false);
const isPanningRef = useRef(false);
// Use external prompt if provided (controlled), otherwise internal state (uncontrolled)
const lightboxPrompt = externalPrompt !== undefined ? externalPrompt : internalPrompt;
const setLightboxPrompt = (value: string) => {
if (onPromptChange) {
onPromptChange(value);
} else {
setInternalPrompt(value);
}
};
// Preload image when lightbox opens
useEffect(() => {
if (isOpen) {
// Ensure prompt field is hidden by default when opening
setShowPromptField(false);
}
}, [isOpen]);
// Handle keyboard events
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
// Check if user is typing in the textarea or any input field
const target = event.target as HTMLElement;
const isTypingInInput = target instanceof HTMLTextAreaElement ||
target instanceof HTMLInputElement ||
target?.contentEditable === 'true' ||
target?.tagName === 'TEXTAREA' ||
target?.tagName === 'INPUT';
if (event.key === 'Escape') {
if (isTypingInInput) {
// If typing in input, ESC should hide prompt and clear text (handled in textarea onKeyDown)
return;
}
onClose();
} else if (event.key === 'ArrowUp' && event.ctrlKey && onNavigateHistory) {
// Ctrl+ArrowUp for prompt history navigation
event.preventDefault();
onNavigateHistory('up');
} else if (event.key === 'ArrowDown' && event.ctrlKey && onNavigateHistory) {
// Ctrl+ArrowDown for prompt history navigation
event.preventDefault();
onNavigateHistory('down');
} else if (event.key === 'ArrowLeft' && !isTypingInInput && onNavigate) {
event.preventDefault();
onNavigate('prev');
} else if (event.key === 'ArrowRight' && !isTypingInInput && onNavigate) {
event.preventDefault();
onNavigate('next');
} else if (event.key === ' ' && !isTypingInInput && showPrompt) {
// Spacebar to toggle prompt field (only when not typing)
event.preventDefault();
setShowPromptField(!showPromptField);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, onNavigate, currentIndex, totalCount, showPrompt, showPromptField, onNavigateHistory]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (tapTimeoutRef.current) {
clearTimeout(tapTimeoutRef.current);
}
};
}, []);
// Swipe detection functions
const handleSwipeStart = (clientX: number, clientY: number) => {
swipeStartRef.current = { x: clientX, y: clientY, time: Date.now() };
isSwipingRef.current = false;
};
const handleSwipeEnd = (clientX: number, clientY: number) => {
if (!swipeStartRef.current || !onNavigate) {
return;
}
const deltaX = clientX - swipeStartRef.current.x;
const deltaY = clientY - swipeStartRef.current.y;
const deltaTime = Date.now() - swipeStartRef.current.time;
// Swipe thresholds
const minSwipeDistance = 50; // Minimum distance for a swipe
const maxSwipeTime = 500; // Maximum time for a swipe (ms)
const maxVerticalDistance = 100; // Maximum vertical movement for horizontal swipe
// Check if this is a valid horizontal swipe
if (
Math.abs(deltaX) > minSwipeDistance &&
Math.abs(deltaY) < maxVerticalDistance &&
deltaTime < maxSwipeTime &&
!isPanning // Don't trigger swipe if user was panning/zooming
) {
if (deltaX > 0) {
// Swipe right = previous image
onNavigate('prev');
} else if (deltaX < 0) {
// Swipe left = next image
onNavigate('next');
}
}
swipeStartRef.current = null;
isSwipingRef.current = false;
};
const handlePromptSubmit = () => {
if (lightboxPrompt.trim() && !isGenerating && onPromptSubmit) {
onPromptSubmit(lightboxPrompt);
// Don't clear the prompt - keep it for reference/reuse
}
};
const handlePublishClick = () => {
setShowPublishDialog(true);
};
const handlePublish = (option: 'overwrite' | 'new' | 'version', title: string, description?: string, parentId?: string, collectionIds?: string[]) => {
if (onPublish) {
const urlToPublish = generatedImageUrl || imageUrl;
onPublish(option, urlToPublish, title, description, parentId, collectionIds);
}
setShowPublishDialog(false);
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/95 z-[9999] flex items-center justify-center"
onClick={(e) => {
// Only close if clicking on the main background (not on image area)
if (e.target === e.currentTarget) {
if (showPrompt) {
setShowPromptField(!showPromptField);
} else {
onClose();
}
}
}}
onTouchStart={(e) => {
// Only handle swipe on the main container, not on child elements
if (e.target === e.currentTarget) {
const touch = e.touches[0];
handleSwipeStart(touch.clientX, touch.clientY);
}
}}
onTouchEnd={(e) => {
// Only handle swipe on the main container, not on child elements
if (e.target === e.currentTarget) {
const touch = e.changedTouches[0];
handleSwipeEnd(touch.clientX, touch.clientY);
}
}}
>
<div className="relative w-full h-full flex items-center justify-center">
<div className="relative w-full h-full flex items-center justify-center">
<TransformWrapper
initialScale={1}
minScale={1}
maxScale={40}
centerOnInit={true}
centerZoomedOut={true}
limitToBounds={true}
alignmentAnimation={{ animationTime: 200, animationType: 'easeOut' }}
wheel={{ step: 1 }}
doubleClick={{ disabled: false, step: 0.7 }}
pinch={{ step: 20 }}
onTransformed={(e) => setScale(e.state.scale)}
>
<TransformComponent
wrapperClass="w-full h-full"
contentClass=""
>
<ResponsiveImage
src={generatedImageUrl || imageUrl}
alt={imageTitle}
sizes={`${Math.ceil(scale * 100)}vw`}
responsiveSizes={[640, 1024, 2048]}
imgClassName="max-w-[90vw] max-h-[90vh] object-contain cursor-grab active:cursor-grabbing pointer-events-auto"
className="w-full h-full flex items-center justify-center"
loading="eager"
draggable={false}
onLoad={() => setLightboxLoaded(true)}
onClick={(e: React.MouseEvent) => {
// Only toggle controls if we haven't been panning
if (!isPanningRef.current && showPrompt) {
e.stopPropagation();
setShowPromptField(!showPromptField);
}
}}
/>
</TransformComponent>
</TransformWrapper>
</div>
{!lightboxLoaded && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin"></div>
</div>
)}
{/* Close Button */}
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="absolute top-4 right-4 text-white text-2xl p-4 bg-black/75 rounded-lg hover:bg-black/90 transition-all duration-200 z-20"
title="Close (ESC)"
>
×
</button>
{/* Navigation Buttons - show if navigation function exists and we have valid navigation data */}
{onNavigate && currentIndex !== undefined && totalCount !== undefined && totalCount > 1 && (
<>
{currentIndex > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
onNavigate('prev');
}}
className="absolute left-4 top-1/2 transform -translate-y-1/2 p-4 text-white text-3xl bg-black/75 rounded-lg hover:bg-black/90 transition-all duration-200"
style={{ zIndex: 50 }}
title="Previous (←)"
>
</button>
)}
{currentIndex < totalCount - 1 && (
<button
onClick={(e) => {
e.stopPropagation();
onNavigate('next');
}}
className="absolute right-4 top-1/2 transform -translate-y-1/2 p-4 text-white text-3xl bg-black/75 rounded-lg hover:bg-black/90 transition-all duration-200"
style={{ zIndex: 50 }}
title="Next (→)"
>
</button>
)}
</>
)}
{/* Quick Prompt Field */}
{showPrompt && showPromptField && lightboxLoaded && (
<div
className="absolute bottom-4 left-1/2 transform -translate-x-1/2 w-[90vw] md:w-[80vw] max-w-4xl z-40"
onClick={(e) => {
// Allow clicking outside the prompt container to hide it
if (e.target === e.currentTarget) {
setShowPromptField(false);
}
}}
>
<div
className="bg-black/80 backdrop-blur-sm rounded-xl p-3 md:p-4 shadow-2xl border border-white/20"
onClick={(e) => e.stopPropagation()}
>
{showWizardFeatures ? (
/* Wizard Mode: Textarea + Separate Actions Row */
<>
{/* Prompt History Indicator */}
{promptHistory.length > 0 && (
<div className="flex items-center justify-between mb-2 text-xs">
<span className="text-white/60">
History: {historyIndex >= 0 ? `${historyIndex + 1}/${promptHistory.length}` : 'Current'}
</span>
<div className="flex items-center gap-1">
{/* History Picker Dropdown */}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
onClick={(e) => e.stopPropagation()}
className="h-6 w-6 p-0 text-white hover:text-primary"
title="Browse prompt history"
>
<History className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 max-h-64 overflow-y-auto z-[10000]">
{promptHistory.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
<T>No history yet</T>
</div>
) : (
<>
{[...promptHistory].reverse().map((historyPrompt, reverseIndex) => {
const actualIndex = promptHistory.length - 1 - reverseIndex;
return (
<DropdownMenuItem
key={actualIndex}
onSelect={() => {
setLightboxPrompt(historyPrompt);
}}
className={`flex flex-col items-start gap-1 py-2 ${historyIndex === actualIndex ? 'bg-primary/10' : ''
}`}
>
<div className="text-xs text-muted-foreground">
#{promptHistory.length - reverseIndex}
{historyIndex === actualIndex && ' (current)'}
</div>
<div className="text-sm line-clamp-2 w-full">
{historyPrompt}
</div>
</DropdownMenuItem>
);
})}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
onNavigateHistory?.('up');
}}
disabled={promptHistory.length === 0 || historyIndex === promptHistory.length - 1}
className="h-6 w-6 p-0 text-white hover:text-primary disabled:opacity-30"
title="Previous prompt (Ctrl+↑)"
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
onNavigateHistory?.('down');
}}
disabled={historyIndex === -1}
className="h-6 w-6 p-0 text-white hover:text-primary disabled:opacity-30"
title="Next prompt (Ctrl+↓)"
>
<ArrowUp className="h-3 w-3 rotate-180" />
</Button>
</div>
</div>
)}
{/* Input Row */}
<div className="flex gap-2 mb-2 md:mb-0">
<div className="flex-1 relative">
<Textarea
value={lightboxPrompt}
onChange={(e) => {
setLightboxPrompt(e.target.value);
onManualPromptEdit?.(); // Reset history index when manually typing
}}
placeholder={translate("Quick edit prompt...")}
disabled={isGenerating}
rows={2}
className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 pr-10 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent backdrop-blur-sm disabled:opacity-50 resize-none text-sm md:text-base"
onKeyDown={(e) => {
if ((e.key === 'Enter' && e.ctrlKey) || (e.key === 'Enter' && !e.shiftKey && lightboxPrompt.trim() && !isGenerating)) {
e.stopPropagation();
e.preventDefault();
handlePromptSubmit();
if (!e.ctrlKey) {
setLightboxPrompt('');
}
} else if (e.key === 'ArrowUp' && e.ctrlKey) {
e.stopPropagation();
e.preventDefault();
onNavigateHistory?.('up');
} else if (e.key === 'ArrowDown' && e.ctrlKey) {
e.stopPropagation();
e.preventDefault();
onNavigateHistory?.('down');
} else if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
setLightboxPrompt('');
} else if (e.key.startsWith('Arrow') || e.key === 'Delete' || e.key === 'Backspace') {
e.stopPropagation();
}
}}
onClick={(e) => e.stopPropagation()}
/>
<button
onClick={(e) => {
e.stopPropagation();
onMicrophoneToggle?.();
}}
disabled={isTranscribing}
className={`absolute right-2 bottom-2 p-1 rounded-md transition-colors ${isRecording
? 'bg-red-600/80 text-white hover:bg-red-700'
: 'text-white/60 hover:text-white hover:bg-white/10'
}`}
title={isRecording ? 'Stop recording' : 'Record audio'}
>
{isTranscribing ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
) : isRecording ? (
<MicOff size={16} />
) : (
<Mic size={16} />
)}
</button>
</div>
</div>
{/* Style Presets - Minimal Row */}
<div className="mb-2 mt-2">
<StylePresetSelector
presets={quickActions}
onSelect={(preset) => {
const current = lightboxPrompt || '';
const trimmed = current.trim();
const separator = trimmed ? ', ' : '';
setLightboxPrompt(`${trimmed}${separator}${preset.prompt}`);
}}
variant="minimal"
disabled={isGenerating}
/>
</div>
{/* Actions Row - Separate on mobile */}
<div className="flex gap-2 flex-wrap md:flex-nowrap">
{/* Templates Dropdown */}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
onClick={(e) => e.stopPropagation()}
variant="ghost"
className="text-white hover:text-primary disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0"
title={translate("Prompt Templates")}
>
<FileText size={20} className="md:w-5 md:h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 z-[10000]">
{promptTemplates.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
<T>No templates saved yet</T>
</div>
) : (
<>
{promptTemplates.map((template, index) => (
<DropdownMenuItem
key={index}
onSelect={() => onApplyTemplate?.(template.template)}
className="flex items-center justify-between group"
>
<span className="flex-1 truncate">{template.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteTemplate?.(index);
}}
className="opacity-0 group-hover:opacity-100 ml-2 p-1 hover:bg-destructive/20 rounded"
title={translate("Delete template")}
>
<Trash2 className="h-3 w-3 text-destructive" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onSelect={() => onSaveTemplate?.()}>
<Plus className="h-3 w-3 mr-2" />
<T>Save current as template</T>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onOpenInWizard && (
<Button
onClick={(e) => {
e.stopPropagation();
onOpenInWizard();
}}
variant="ghost"
className="text-white hover:text-purple-400 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0"
title={translate("Open in AI Wizard for advanced editing")}
>
<Wand2 size={20} className="md:w-5 md:h-5" />
</Button>
)}
<Button
onClick={(e) => {
e.stopPropagation();
onOptimizePrompt?.();
}}
disabled={isOptimizing || !lightboxPrompt.trim()}
variant="ghost"
className="text-white hover:text-primary disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0"
title={translate("Optimize prompt with AI")}
>
{isOptimizing ? (
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
) : (
<Sparkles size={20} className="md:w-5 md:h-5" />
)}
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
if (lightboxPrompt.trim() && !isGenerating) {
handlePromptSubmit();
setLightboxPrompt('');
}
}}
disabled={!lightboxPrompt.trim() || isGenerating}
variant="ghost"
className="text-primary hover:text-primary/80 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0"
title="Generate (Enter to submit)"
>
{isGenerating ? (
<div className="animate-spin rounded-full h-5 w-5 border-2 border-primary border-t-transparent"></div>
) : (
<ArrowUp size={20} className="md:w-5 md:h-5" />
)}
</Button>
{showQuickPublish && (
<Button
onClick={(e) => {
e.stopPropagation();
onQuickPublish?.();
}}
disabled={isPublishing}
variant="ghost"
className="text-green-500 hover:text-green-400 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0"
title="Quick Publish with prompt as description"
>
{isPublishing ? (
<div className="animate-spin rounded-full h-5 w-5 border-2 border-green-500 border-t-transparent"></div>
) : (
<Upload size={20} className="md:w-5 md:h-5" />
)}
</Button>
)}
{showPublish && (
<Button
onClick={(e) => {
e.stopPropagation();
handlePublishClick();
}}
disabled={isPublishing}
variant="ghost"
className="text-green-500 hover:text-green-400 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0"
title="Publish to gallery with options"
>
{isPublishing ? (
<div className="animate-spin rounded-full h-5 w-5 border-2 border-green-500 border-t-transparent"></div>
) : (
<Save size={20} className="md:w-5 md:h-5" />
)}
</Button>
)}
<Button
onClick={(e) => {
e.stopPropagation();
setShowPromptField(false);
}}
variant="ghost"
className="text-white/60 hover:text-white p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0 md:ml-auto"
title={translate("Hide prompt")}
type="button"
>
<ArrowDown size={20} className="md:w-5 md:h-5" />
</Button>
</div>
</>
) : (
/* Simple Mode: Same layout as wizard, fewer buttons */
<>
{/* Input Row */}
<div className="flex gap-2 mb-2 md:mb-0">
<div className="flex-1 relative">
<Textarea
value={lightboxPrompt}
onChange={(e) => {
setLightboxPrompt(e.target.value);
onManualPromptEdit?.(); // Reset history index when manually typing
}}
placeholder={translate("Quick edit prompt...")}
disabled={isGenerating}
rows={2}
className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent backdrop-blur-sm disabled:opacity-50 resize-none text-sm md:text-base"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && lightboxPrompt.trim() && !isGenerating) {
e.stopPropagation();
e.preventDefault();
handlePromptSubmit();
} else if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
setLightboxPrompt('');
} else if (e.key.startsWith('Arrow') || e.key === 'Delete' || e.key === 'Backspace') {
e.stopPropagation();
}
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
{/* Style Presets - Minimal Row (Simple Mode) */}
<div className="mb-2 mt-2">
<StylePresetSelector
presets={quickActions}
onSelect={(preset) => {
const current = lightboxPrompt || '';
const trimmed = current.trim();
const separator = trimmed ? ', ' : '';
setLightboxPrompt(`${trimmed}${separator}${preset.prompt}`);
}}
variant="minimal"
disabled={isGenerating}
/>
</div>
{/* Actions Row - Simple mode */}
<div className="flex gap-2">
<Button
onClick={(e) => {
e.stopPropagation();
if (lightboxPrompt.trim() && !isGenerating) {
handlePromptSubmit();
}
}}
disabled={!lightboxPrompt.trim() || isGenerating}
variant="ghost"
className="text-primary hover:text-primary/80 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0"
title="Generate (Enter to submit)"
>
{isGenerating ? (
<div className="animate-spin rounded-full h-5 w-5 border-2 border-primary border-t-transparent"></div>
) : (
<ArrowUp size={20} className="md:w-5 md:h-5" />
)}
</Button>
{showPublish && (
<Button
onClick={(e) => {
e.stopPropagation();
handlePublishClick();
}}
disabled={isPublishing}
variant="ghost"
className="text-green-500 hover:text-green-400 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0"
title="Publish to gallery"
>
{isPublishing ? (
<div className="animate-spin rounded-full h-5 w-5 border-2 border-green-500 border-t-transparent"></div>
) : (
<Save size={20} className="md:w-5 md:h-5" />
)}
</Button>
)}
<Button
onClick={(e) => {
e.stopPropagation();
setShowPromptField(false);
}}
variant="ghost"
className="text-white/60 hover:text-white p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0"
title={translate("Hide prompt")}
type="button"
>
<ArrowDown size={20} className="md:w-5 md:h-5" />
</Button>
</div>
</>
)}
</div>
</div>
)
}
{/* Prompt Toggle Button - shown when prompt is hidden */}
{
showPrompt && !showPromptField && lightboxLoaded && (
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 z-50">
<button
onClick={(e) => {
e.stopPropagation();
setShowPromptField(true);
}}
className="bg-primary/80 hover:bg-primary text-primary-foreground p-2 rounded-full shadow-lg transition-all duration-200 hover:scale-110"
title="Show prompt field (Space)"
>
<Wand2 size={20} />
</button>
</div>
)
}
{/* Info Popup */}
{
showInfoPopup && lightboxLoaded && showPromptField && (
<div
className="absolute bottom-16 left-1/2 transform -translate-x-1/2 bg-black/80 backdrop-blur-md text-white px-4 py-3 rounded-lg text-sm max-w-md text-center border border-white/20 z-50"
onClick={(e) => e.stopPropagation()}
>
{isPublishing ? (
<div className="flex items-center justify-center gap-2">
<div className="animate-spin rounded-full h-3 w-3 border-2 border-white border-t-transparent"></div>
<span>Publishing... ESC to close</span>
</div>
) : (
<div className="space-y-2">
<div className="font-medium">{imageTitle}</div>
{currentIndex !== undefined && totalCount !== undefined && (
<div className="text-white/80 text-xs">{currentIndex + 1} of {totalCount}</div>
)}
<div className="text-white/70 text-xs space-y-1">
<div> Enter: generate</div>
<div> Space/Click: hide prompt</div>
{showWizardFeatures && promptHistory.length > 0 && (
<div> Ctrl+: prompt history</div>
)}
{onNavigate && totalCount && totalCount > 1 && (
<div> keys or swipe: navigate</div>
)}
<div> ESC: close lightbox</div>
</div>
</div>
)}
{/* Close popup when clicking outside */}
<div
className="fixed inset-0 z-[-1]"
onClick={() => setShowInfoPopup(false)}
/>
</div>
)
}
</div >
<PublishDialog
isOpen={showPublishDialog}
onClose={() => setShowPublishDialog(false)}
onPublish={handlePublish}
originalTitle={imageTitle}
originalImageId={originalImageId}
isPublishing={isPublishing}
/>
</div >
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,115 @@
# ImageWizard Component Structure
## Overview
The ImageWizard is split into focused modules for better maintainability.
## File Structure
```
ImageWizard/
├── types.ts # TypeScript interfaces (ImageFile, ImageWizardProps)
├── utils/
│ └── logger.ts # Logger utility with clean API ✅
├── hooks/
│ └── useImageWizardState.ts # Centralized state management ✅ NEW!
├── handlers/
│ ├── index.ts # Centralized exports ✅
│ ├── imageHandlers.ts # Image upload, selection, deletion ✅
│ ├── generationHandlers.ts # Image generation, optimization, abort ✅
│ ├── publishHandlers.ts # Publishing logic ✅
│ ├── dataHandlers.ts # Data loading/saving (presets, workflows, etc.) ✅
│ ├── voiceHandlers.ts # Voice recording, transcription, voice-to-image ✅
│ ├── agentHandlers.ts # AI Agent mode with tool calling ✅
│ ├── promptHandlers.ts # Prompt splitting & sequential generation ✅
│ ├── dropHandlers.ts # Drag and drop file handling ✅
│ └── settingsHandlers.ts # User settings (templates, history, etc.) ✅
└── ImageWizard.tsx # Main UI component (2,129 lines, down from 2,359)
```
## Recent Refactorings
### 1. ✅ Prompt Splitter Extracted (`promptHandlers.ts`)
- `generateImageSplit` - Sequential multi-prompt generation
- Uses `splitPromptByLines` from `@constants.ts`
### 2. ✅ Logger Refactored (`utils/logger.ts`)
- Changed from `addLog('debug', ...)` to `logger.debug(...)`
- Created `Logger` interface with clean methods: `debug()`, `info()`, `warning()`, `error()`, `success()`, `verbose()`
- All handlers now accept `Logger` instead of raw `addLog` function
- Component name auto-prefixed in logs
### 3. ✅ DEFAULT_QUICK_ACTIONS Moved to `@constants.ts`
- `QuickAction` interface now exported from constants
- Available throughout the codebase
- Removed duplicate from `types.ts`
### 4. ✅ Drag and Drop Extracted (`dropHandlers.ts`)
- `handleDragEnter` - Show drop overlay
- `handleDragOver` - Keep overlay active
- `handleDragLeave` - Debounced hide (prevents flicker)
- `handleDrop` - Process dropped files
## Handler Categories
### 📸 imageHandlers.ts
- `handleFileUpload` - Upload files
- `toggleImageSelection` - Select/deselect images
- `removeImageRequest` - Request image deletion
- `confirmDeleteImage` - Confirm and execute deletion
- `setAsSelected` - Set image as selected version
- `handleDownloadImage` - Download image
### 🎨 generationHandlers.ts
- `handleOptimizePrompt` - Optimize prompt with AI
- `buildFullPrompt` - Build prompt with preset context
- `abortGeneration` - Cancel generation
### 📝 promptHandlers.ts
- `generateImageSplit` - Sequential multi-prompt generation
### 🎤 voiceHandlers.ts
- `handleMicrophone` - Record audio
- `handleVoiceToImage` - Voice-to-image workflow with AI
### 🤖 agentHandlers.ts
- `handleAgentGeneration` - AI Agent mode with tool calling
### 📤 publishHandlers.ts
- `publishImage` - Standard publish
- `quickPublishAsNew` - Quick publish with prompt as description
### 💾 dataHandlers.ts
- `loadFamilyVersions` - Load image version families
- `loadAvailableImages` - Load gallery images
### ⚙️ settingsHandlers.ts
- Template, preset, workflow, history management
### 🎯 dropHandlers.ts
- Drag and drop file handling
## Usage Example
```typescript
import {
handleFileUpload,
toggleImageSelection,
publishImage
} from './handlers';
import { createLogger } from './utils/logger';
// In component
const logger = createLogger(addLog, 'ImageWizard');
handleFileUpload(event, setImages);
logger.info('File uploaded successfully');
```
## Benefits
✅ Easier to find specific logic
✅ Better code organization
✅ Simpler testing
✅ Reduced file size
✅ Clear separation of concerns
✅ Clean logging API
✅ Reusable utilities

View File

@ -0,0 +1,146 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Download, Plus, Edit, X } from 'lucide-react';
import { ImageFile } from '../types';
interface ImageActionButtonsProps {
image: ImageFile;
index: number;
images: ImageFile[];
onDownload: (image: ImageFile) => void;
onSetAsSelected: (imageId: string) => void;
onSaveAsVersion: (image: ImageFile, index: number) => void;
onEdit: (index: number) => void;
onRemove: (imageId: string) => void;
onAddToPost?: () => void;
editingPostId?: string | null;
}
/**
* Image Action Buttons
*
* Displays action buttons below each image in the wizard:
* - Download: Download image to device
* - Set as Selected: Mark this version as the selected one (for saved images)
* - Save as Version: Save as a new version (for generated images)
* - Edit: Open in lightbox for editing
* - Remove: Remove from wizard
* - Add to Post: Add to the current post (if in post editing context)
*/
export const ImageActionButtons: React.FC<ImageActionButtonsProps> = ({
image,
index,
images,
onDownload,
onSetAsSelected,
onSaveAsVersion,
onEdit,
onRemove,
onAddToPost,
editingPostId,
}) => {
const isSavedImage = image.id && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(image.id);
const isGeneratedUnsaved = image.isGenerated && !isSavedImage;
return (
<div className="mt-2 flex gap-1 justify-center flex-wrap">
{/* Download Button */}
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0 touch-manipulation flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
onDownload(image);
}}
title="Download"
>
<Download className="h-3.5 w-3.5" />
</Button>
{/* Set as Selected Button */}
{(isSavedImage || isGeneratedUnsaved) && (
<Button
variant="outline"
size="sm"
className={`h-7 w-7 p-0 touch-manipulation flex-shrink-0 ${image.selected ? 'bg-yellow-100 border-yellow-400 text-yellow-700' : ''
}`}
onClick={(e) => {
e.stopPropagation();
onSetAsSelected(image.id);
}}
title="Set as Selected Version"
>
<span className={image.selected ? 'text-yellow-500 text-xs' : 'text-gray-400 text-xs'}></span>
</Button>
)}
{/* Add to Post Button */}
{editingPostId && (
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0 touch-manipulation flex-shrink-0 text-blue-600 hover:text-blue-700 hover:bg-blue-50 border-blue-200 disabled:opacity-50"
onClick={(e) => {
e.stopPropagation();
onAddToPost?.();
}}
disabled={!!image.realDatabaseId || image.isAddingToPost}
title={image.realDatabaseId ? "Already in database" : "Add to Current Post"}
>
{image.isAddingToPost ? (
<div className="h-3.5 w-3.5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</Button>
)}
{/* Save as Version Button */}
{isGeneratedUnsaved && (
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0 touch-manipulation flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
onSaveAsVersion(image, index);
}}
title="Save as Version"
>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
{/* Edit Button */}
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0 touch-manipulation flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
onEdit(index);
}}
title="Edit"
>
<Edit className="h-3.5 w-3.5" />
</Button>
{/* Remove Button */}
<Button
variant="destructive"
size="sm"
className="h-7 w-7 p-0 touch-manipulation flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
onRemove(image.id);
}}
title="Remove"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
};
export default ImageActionButtons;

View File

@ -0,0 +1,293 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ImageIcon, Upload, Loader2, Users, Trash2 } from "lucide-react";
import { T, translate } from "@/i18n";
import { ImageFile } from "../types";
import { QuickAction } from "@/constants";
import { QuickActionsToolbar } from "./QuickActionsToolbar";
interface ImageGalleryPanelProps {
// Selected images
images: ImageFile[];
onImageClick: (imageId: string, isMultiSelect: boolean) => void;
onImageDoubleClick: (index: number) => void;
onFileUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
onDeleteSelected: () => void;
onDownload: (image: ImageFile) => void;
onSetAsSelected: (imageId: string) => void;
onSaveAsVersion: (image: ImageFile, index: number) => void;
onEdit: (index: number) => void;
onRemove: (imageId: string) => void;
// Gallery
availableImages: ImageFile[];
loadingImages: boolean;
onGalleryImageSelect: (imageId: string, isMultiSelect: boolean) => void;
// Quick actions
quickActions: QuickAction[];
onExecuteAction: (action: QuickAction) => void;
onEditActions: () => void;
isGenerating: boolean;
// Drag & drop
dragIn: boolean;
onDragEnter: (e: React.DragEvent<HTMLDivElement>) => void;
onDragOver: (e: React.DragEvent<HTMLDivElement>) => void;
onDragLeave: (e: React.DragEvent<HTMLDivElement>) => void;
onDrop: (e: React.DragEvent<HTMLDivElement>) => void;
dropZoneRef: React.RefObject<HTMLDivElement>;
// Add to Post
onAddToPost?: (image: ImageFile) => void;
editingPostId?: string | null;
onSettings: (index: number) => void;
}
export const ImageGalleryPanel: React.FC<ImageGalleryPanelProps> = ({
images,
onImageClick,
onImageDoubleClick,
onFileUpload,
onDeleteSelected,
onDownload,
onSetAsSelected,
onSaveAsVersion,
onEdit,
onRemove,
availableImages,
loadingImages,
onGalleryImageSelect,
quickActions,
onExecuteAction,
onEditActions,
isGenerating,
dragIn,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
dropZoneRef,
onAddToPost,
editingPostId,
onSettings,
}) => {
// Handlers for unified toolbar
const handleDownloadSelected = () => {
const selectedImages = images.filter(img => img.selected);
selectedImages.forEach(img => onDownload(img));
};
const handleSetAsSelected = () => {
const selectedImage = images.find(img => img.selected);
if (selectedImage) {
onSetAsSelected(selectedImage.id);
}
};
const handleEditSelected = () => {
const index = images.findIndex(img => img.selected);
if (index !== -1) {
onEdit(index);
}
};
const handleSettingsSelected = () => {
const index = images.findIndex(img => img.selected);
if (index !== -1) {
onSettings(index);
}
};
const handleAddToPostSelected = () => {
const selectedImage = images.find(img => img.selected);
if (selectedImage && onAddToPost) {
onAddToPost(selectedImage);
}
};
const handleSaveAsVersionSelected = () => {
const index = images.findIndex(img => img.selected);
const selectedImage = images[index];
if (selectedImage) {
onSaveAsVersion(selectedImage, index);
}
};
return (
<div
ref={dropZoneRef}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className="lg:col-span-2"
>
<Tabs defaultValue="selected" className="h-full">
<TabsList className="grid w-full grid-cols-2 h-11 md:h-10">
<TabsTrigger value="selected" className="text-sm md:text-base">
<T>Selected Images</T> ({images.filter(img => img.selected).length})
</TabsTrigger>
<TabsTrigger value="gallery" className="text-sm md:text-base">
<Users className="h-4 w-4 mr-1 md:mr-2" />
<T>Gallery</T>
</TabsTrigger>
</TabsList>
<TabsContent value="selected" className="space-y-3 md:space-y-4">
<Card className={`transition-all ${dragIn ? 'ring-4 ring-primary ring-offset-2 bg-primary/5' : ''
}`}>
<CardContent className="p-3 md:p-6">
{/* File Upload & Bulk Actions */}
<div className="mb-3 md:mb-4">
<div className="flex items-center gap-2">
<Button variant="outline" asChild className="h-11 md:h-10 text-base md:text-sm flex-1">
<label htmlFor="file-upload" className="cursor-pointer">
<Upload className="h-5 w-5 md:h-4 md:w-4 mr-2" />
<span className="hidden sm:inline"><T>Choose Files or Drop Here</T></span>
<span className="sm:hidden"><T>Choose or Drop Files</T></span>
<input
id="file-upload"
type="file"
multiple
accept="image/*"
onChange={onFileUpload}
className="hidden"
/>
</label>
</Button>
</div>
</div>
{/* Quick Actions Toolbar */}
<QuickActionsToolbar
quickActions={quickActions}
images={images}
isGenerating={isGenerating}
onExecuteAction={onExecuteAction}
onEditActions={onEditActions}
onDownload={handleDownloadSelected}
onDelete={onDeleteSelected}
onSetAsSelected={handleSetAsSelected}
onEdit={handleEditSelected}
onSettings={handleSettingsSelected}
onAddToPost={handleAddToPostSelected}
onSaveAsVersion={handleSaveAsVersionSelected}
editingPostId={editingPostId}
/>
{/* Selected Images Grid */}
{images.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
{images.map((image, index) => (
<div key={image.id} className="relative group">
<Card className={`overflow-hidden cursor-pointer transition-all ${image.selected ? 'ring-2 ring-primary' : ''
}`}>
<CardContent className="p-0">
<div className="aspect-square relative">
<img
src={image.src}
alt={image.title}
className="w-full h-full object-contain bg-muted cursor-pointer"
onClick={(e) => onImageClick(image.id, e.ctrlKey || e.metaKey)}
onDoubleClick={() => onImageDoubleClick(index)}
/>
{/* Loading spinner overlay */}
{(image.title.includes('Generating') || image.title.includes('Agent working') || image.title.includes('Voice Agent working')) ? (
<div className="absolute inset-0 flex items-center justify-center bg-muted/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-xs font-medium text-foreground">{image.title}</span>
</div>
</div>
) : null}
{/* Selection Checkmark Overlay */}
{image.selected && (
<div className="absolute top-2 right-2 h-6 w-6 bg-primary text-primary-foreground rounded-full flex items-center justify-center shadow-lg transform scale-100 transition-transform">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className="w-3.5 h-3.5">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
</div>
<div className="p-2">
<p className="text-xs font-medium line-clamp-2 break-words">{image.title}</p>
{image.isGenerated && (
<Badge variant="secondary" className="text-xs mt-1">
Generated
</Badge>
)}
{image.aiText && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
"{image.aiText}"
</p>
)}
</div>
</CardContent>
</Card>
</div>
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<ImageIcon className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p><T>No images selected</T></p>
<p className="text-sm"><T>Upload images or select from gallery</T></p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="gallery" className="space-y-3 md:space-y-4">
<Card>
<CardContent className="p-3 md:p-6">
<ScrollArea className="h-64 md:h-96">
{loadingImages ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
{availableImages.map(image => (
<Card
key={image.id}
className="overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary transition-all touch-manipulation"
onClick={(e) => onGalleryImageSelect(image.id, e.ctrlKey || e.metaKey)}
>
<CardContent className="p-0">
<div className="aspect-square">
<img
src={image.src}
alt={image.title}
className="w-full h-full object-contain bg-muted"
/>
</div>
<div className="p-2">
<p className="text-xs truncate font-medium">{image.title}</p>
</div>
</CardContent>
</Card>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
};
export default ImageGalleryPanel;

View File

@ -0,0 +1,100 @@
import React from 'react';
import { T } from '@/i18n';
import { AVAILABLE_MODELS, getModelString } from '@/lib/image-router';
interface ModelSelectorProps {
selectedModel: string;
onChange: (model: string) => void;
label?: string;
showStepNumber?: boolean;
}
/**
* Model Selector Component
*
* Displays a dropdown to select AI image generation models,
* grouped by provider (Google, Replicate, Bria, AIML API).
*/
export const ModelSelector: React.FC<ModelSelectorProps> = ({
selectedModel,
onChange,
label = 'AI Model',
showStepNumber = true,
}) => {
return (
<div>
<label className="text-sm md:text-base font-medium mb-2 block">
{showStepNumber && <span className="text-xs text-muted-foreground mr-2">1.</span>}
<T>{label}</T>
</label>
<select
value={selectedModel}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2.5 md:py-2 border border-border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-background"
>
{/* Google Gemini Models */}
<optgroup label="Google Gemini">
{AVAILABLE_MODELS.filter(m => m.provider === 'google').map((model) => (
<option
key={getModelString(model.provider, model.modelName)}
value={getModelString(model.provider, model.modelName)}
>
{model.displayName.replace('Google ', '')}
</option>
))}
</optgroup>
{/* Replicate Models */}
<optgroup label="Replicate">
{AVAILABLE_MODELS.filter(m => m.provider === 'replicate').map((model) => (
<option
key={getModelString(model.provider, model.modelName)}
value={getModelString(model.provider, model.modelName)}
>
{model.displayName.replace('Replicate ', '')}
</option>
))}
</optgroup>
{/* Bria.ai Models (Commercial Safe) */}
<optgroup label="Bria.ai (Commercial Safe)">
{AVAILABLE_MODELS.filter(m => m.provider === 'bria').map((model) => (
<option
key={getModelString(model.provider, model.modelName)}
value={getModelString(model.provider, model.modelName)}
>
{model.displayName.replace('Bria.ai ', '')}
</option>
))}
</optgroup>
{/* AIML API Models (Multi-Model Gateway) */}
<optgroup label="AIML API (Multi-Model Gateway)">
{AVAILABLE_MODELS.filter(m => m.provider === 'aimlapi').map((model) => (
<option
key={getModelString(model.provider, model.modelName)}
value={getModelString(model.provider, model.modelName)}
>
{model.displayName.replace('AIML API - ', '')}
</option>
))}
</optgroup>
</select>
<p className="text-xs text-muted-foreground mt-1">
<T>Select the AI model for image generation</T>
</p>
</div>
);
};
export default ModelSelector;

View File

@ -0,0 +1,426 @@
import React, { useRef } from 'react';
import { ImageFile } from '../types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Trash2, ArrowUp, ArrowDown, Plus, Bookmark, Settings2, ChevronDown } from 'lucide-react';
import { T, translate } from '@/i18n';
import AddToCollectionModal from '@/components/AddToCollectionModal';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface PostComposerProps {
images: ImageFile[];
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>;
onRemoveImage: (id: string) => void;
onFileUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
dropZoneRef: React.RefObject<HTMLDivElement>;
isDragging: boolean;
onDragEnter: (e: React.DragEvent<HTMLDivElement>) => void;
onDragOver: (e: React.DragEvent<HTMLDivElement>) => void;
onDragLeave: (e: React.DragEvent<HTMLDivElement>) => void;
onDrop: (e: React.DragEvent<HTMLDivElement>) => void;
postTitle: string;
setPostTitle: (value: string) => void;
postDescription: string;
setPostDescription: (value: string) => void;
onPublish: () => void;
onPublishToGallery?: () => void;
onAppendToPost?: () => void;
isPublishing: boolean;
isEditing?: boolean;
postId?: string;
settings: any;
setSettings: (settings: any) => void;
}
const ImageItem = ({
image,
index,
total,
onUpdate,
onRemove,
onMove
}: {
image: ImageFile;
index: number;
total: number;
onUpdate: (id: string, field: 'title' | 'description', value: string) => void;
onRemove: (id: string) => void;
onMove: (index: number, direction: 'up' | 'down') => void;
}) => {
return (
<div className="flex gap-4 p-4 bg-muted/40 rounded-lg border group animate-in fade-in slide-in-from-bottom-2 duration-300">
{/* Reorder Controls */}
<div className="flex flex-col gap-1 justify-center">
<Button
variant="ghost"
size="icon"
onClick={() => onMove(index, 'up')}
disabled={index === 0}
className="h-8 w-8 text-muted-foreground hover:text-foreground disabled:opacity-30"
title="Move Up"
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onMove(index, 'down')}
disabled={index === total - 1}
className="h-8 w-8 text-muted-foreground hover:text-foreground disabled:opacity-30"
title="Move Down"
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
{/* Thumbnail */}
<div className="w-32 h-32 flex-shrink-0 bg-background rounded-md overflow-hidden border relative">
{image.type === 'video' ? (
image.uploadStatus === 'ready' ? (
<>
<img
src={image.src}
alt={image.title}
className="w-full h-full object-cover"
draggable={false}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<div className="w-8 h-8 rounded-full bg-white/90 flex items-center justify-center">
<svg className="w-4 h-4 text-black ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
</div>
</div>
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
{image.uploadStatus === 'error' ? (
<span className="text-destructive text-xs text-center p-1"><T>Upload Failed</T></span>
) : (
<>
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mb-2" />
<span className="text-xs text-muted-foreground">{image.uploadProgress}%</span>
</>
)}
</div>
)
) : (
<img
src={image.src}
alt={image.title}
className="w-full h-full object-cover"
draggable={false}
/>
)}
</div>
{/* Inputs */}
<div className="flex-1 space-y-3">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground"><T>Title</T></label>
<Input
value={image.title}
onChange={(e) => onUpdate(image.id, 'title', e.target.value)}
className="h-9"
placeholder="Image title..."
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground"><T>Caption / Description</T></label>
<Textarea
value={image.description || ''}
onChange={(e) => onUpdate(image.id, 'description', e.target.value)}
className="min-h-[60px] resize-none text-sm"
placeholder="Add a caption..."
/>
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(image.id)}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Remove"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
};
export const PostComposer: React.FC<PostComposerProps> = ({
images,
setImages,
onRemoveImage,
onFileUpload,
dropZoneRef,
isDragging,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
postTitle,
setPostTitle,
postDescription,
setPostDescription,
onPublish,
onPublishToGallery,
onAppendToPost,
isPublishing,
isEditing = false,
postId,
settings,
setSettings
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [showCollectionModal, setShowCollectionModal] = React.useState(false);
const handleUpdateImage = (id: string, field: 'title' | 'description', value: string) => {
setImages(prev => prev.map(img =>
img.id === id ? { ...img, [field]: value } : img
));
};
const handleMoveImage = (index: number, direction: 'up' | 'down') => {
if (direction === 'up' && index > 0) {
setImages(prev => {
const newImages = [...prev];
const temp = newImages[index];
newImages[index] = newImages[index - 1];
newImages[index - 1] = temp;
return newImages;
});
} else if (direction === 'down' && index < images.length - 1) {
setImages(prev => {
const newImages = [...prev];
const temp = newImages[index];
newImages[index] = newImages[index + 1];
newImages[index + 1] = temp;
return newImages;
});
}
};
return (
<div className="h-full flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between pb-2 border-b">
<div>
<h2 className="text-lg font-semibold"><T>{isEditing ? "Update Post" : "Create Post"}</T></h2>
<p className="text-sm text-muted-foreground"><T>Arrange your images and add captions</T></p>
</div>
<div className="flex gap-2">
<Button onClick={() => fileInputRef.current?.click()} size="sm" variant="outline" disabled={isPublishing}>
<Plus className="h-4 w-4 mr-2" />
<T>Add Images</T>
</Button>
{isEditing && postId && (
<Button
onClick={() => setShowCollectionModal(true)}
size="sm"
variant="outline"
className="text-muted-foreground hover:text-primary"
>
<Bookmark className="h-4 w-4 mr-2" />
<T>Collections</T>
</Button>
)}
<div className="flex rounded-md shadow-sm">
<Button
onClick={onPublish}
size="sm"
disabled={isPublishing || images.length === 0}
className="rounded-r-none"
>
{isPublishing ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
<T>{isEditing ? "Updating..." : "Publishing..."}</T>
</>
) : (
<>
<T>{isEditing ? "Update Post" : "Quick Publish (Default)"}</T>
</>
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className="rounded-l-none border-l border-primary-foreground/20 px-2" disabled={isPublishing || images.length === 0}>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onPublish}>
<T>Quick Publish (Default)</T>
</DropdownMenuItem>
{onPublishToGallery && (
<DropdownMenuItem onClick={onPublishToGallery}>
<T>Publish as Picture</T>
</DropdownMenuItem>
)}
{onAppendToPost && !isEditing && (
<DropdownMenuItem onClick={onAppendToPost}>
<T>Append to Existing Post</T>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* Post Metadata */}
<div className="grid grid-cols-1 gap-4 p-4 bg-muted/20 rounded-lg border">
<div className="space-y-2">
<label className="text-sm font-medium"><T>Post Title</T></label>
<Input
value={postTitle}
onChange={(e) => setPostTitle(e.target.value)}
placeholder={translate("My Awesome Post")}
className="font-medium"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"><T>Post Description (Markdown)</T></label>
<Textarea
value={postDescription}
onChange={(e) => setPostDescription(e.target.value)}
placeholder={translate("Describe your post... Supports **bold**, *italic*, lists, etc.")}
className="min-h-[80px]"
/>
</div>
</div>
{/* Settings Section */}
<Accordion type="single" collapsible className="w-full bg-muted/20 rounded-lg border px-4">
<AccordionItem value="settings" className="border-0">
<AccordionTrigger className="hover:no-underline py-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Settings2 className="h-4 w-4" />
<T>Post Settings & Visibility</T>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4 pt-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<div className="space-y-2">
<label className="text-sm font-medium"><T>Visibility</T></label>
<Select
value={settings?.visibility || 'public'}
onValueChange={(value) => setSettings({ ...settings, visibility: value })}
>
<SelectTrigger className="bg-background">
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
<SelectContent>
<SelectItem value="public"><T>Public (Everyone)</T></SelectItem>
<SelectItem value="listed"><T>Listed (Unlisted link)</T></SelectItem>
<SelectItem value="private"><T>Private (Only Me)</T></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{settings?.visibility === 'public' && <T>Visible to everyone on the homepage and profile.</T>}
{settings?.visibility === 'listed' && <T>Accessible only via direct link. Not shown on homepage.</T>}
{settings?.visibility === 'private' && <T>Only you can see this post.</T>}
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"><T>Display Mode</T></label>
<Select
value={settings?.display || 'compact'}
onValueChange={(value) => setSettings({ ...settings, display: value })}
>
<SelectTrigger className="bg-background">
<SelectValue placeholder="Select display mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="compact"><T>Compact (Standard)</T></SelectItem>
<SelectItem value="article"><T>Article (Blog Style)</T></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{(settings?.display === 'compact' || !settings?.display) && <T>Standard view with side panel.</T>}
{settings?.display === 'article' && <T>Wide layout with large images and inline text.</T>}
</p>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* Main Content Area */}
<div
ref={dropZoneRef}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`flex-1 overflow-y-auto space-y-4 pr-2 min-h-[200px] transition-colors rounded-lg ${isDragging ? 'bg-primary/5 ring-2 ring-primary ring-inset' : ''}`}
>
{images.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center p-8 border-2 border-dashed rounded-lg text-muted-foreground">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
<Plus className="h-8 w-8 opacity-50" />
</div>
<h3 className="text-lg font-medium mb-1"><T>No images added yet</T></h3>
<p className="text-sm mb-4"><T>Drag and drop images here or click Add Images</T></p>
<Button onClick={() => fileInputRef.current?.click()} variant="outline">
<T>Select Files</T>
</Button>
</div>
) : (
<div className="space-y-3">
{images.map((image, index) => (
<ImageItem
key={image.id}
image={image}
index={index}
total={images.length}
onUpdate={handleUpdateImage}
onRemove={onRemoveImage}
onMove={handleMoveImage}
/>
))}
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,video/*,.jpeg,.jpg,.png,.gif,.webp,.mp4,.webm,.mov,.qt,.m4v"
className="hidden"
onChange={onFileUpload}
/>
{isEditing && postId && (
<AddToCollectionModal
isOpen={showCollectionModal}
onClose={() => setShowCollectionModal(false)}
postId={postId}
/>
)}
</div>
);
};

View File

@ -0,0 +1,313 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Loader2,
ArrowUp,
Mic,
MicOff,
Sparkles,
FileText,
Plus,
Trash2
} from "lucide-react";
import { T, translate } from "@/i18n";
import { MAX_SPLIT_PROMPTS } from "@/constants";
import { ImageFile } from "../types";
import { toast } from "sonner";
import { PromptPreset } from "@/components/PresetManager";
interface PromptProps {
// Prompt value
prompt: string;
onPromptChange: (prompt: string) => void;
// Split mode
isSplitMode: boolean;
onSplitModeChange: (enabled: boolean) => void;
// Preset context
selectedPreset: PromptPreset | null;
// Templates
templates: Array<{name: string; template: string}>;
onApplyTemplate: (template: string) => void;
onSaveTemplate: () => void;
onDeleteTemplate: (index: number) => void;
// Optimization
onOptimizePrompt: () => void;
isOptimizing: boolean;
// Voice recording
onMicrophoneToggle: () => void;
isRecording: boolean;
isTranscribing: boolean;
// History
promptHistory: string[];
historyIndex: number;
onNavigateHistory: (direction: 'up' | 'down') => void;
onManualEdit: () => void;
// Generation
isGenerating: boolean;
onGenerate: () => void;
// Images (for paste functionality)
onImagePaste: (image: ImageFile) => void;
}
export const Prompt: React.FC<PromptProps> = ({
prompt,
onPromptChange,
isSplitMode,
onSplitModeChange,
selectedPreset,
templates,
onApplyTemplate,
onSaveTemplate,
onDeleteTemplate,
onOptimizePrompt,
isOptimizing,
onMicrophoneToggle,
isRecording,
isTranscribing,
promptHistory,
historyIndex,
onNavigateHistory,
onManualEdit,
isGenerating,
onGenerate,
onImagePaste,
}) => {
return (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm md:text-base font-medium">
<span className="text-xs text-muted-foreground mr-2">3.</span>
<T>Prompt</T>
</label>
{/* Split Mode Toggle */}
<div className="flex items-center gap-2">
<label htmlFor="split-mode" className="text-xs text-muted-foreground cursor-pointer">
<T>Split</T>
</label>
<Switch
id="split-mode"
checked={isSplitMode}
onCheckedChange={onSplitModeChange}
className="h-5 w-9"
/>
</div>
</div>
{/* Split Mode Info */}
{isSplitMode && (
<div className="mb-2 p-2 bg-blue-50 dark:bg-blue-900/20 rounded-md border border-blue-200 dark:border-blue-700">
<p className="text-xs text-blue-700 dark:text-blue-300">
<T>Split mode: Each line will be a separate prompt. Max {MAX_SPLIT_PROMPTS} lines. Each image feeds into the next.</T>
</p>
</div>
)}
<div className="flex items-center justify-between mb-2">
<div className="hidden md:flex gap-2">
{/* Templates Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8"
title={translate("Prompt Templates")}
>
<FileText className="h-4 w-4 mr-2" />
<T>Templates</T>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{templates.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
<T>No templates saved yet</T>
</div>
) : (
<>
{templates.map((template, index) => (
<DropdownMenuItem
key={index}
onSelect={() => onApplyTemplate(template.template)}
className="flex items-center justify-between group"
>
<span className="flex-1 truncate">{template.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteTemplate(index);
}}
className="opacity-0 group-hover:opacity-100 ml-2 p-1 hover:bg-destructive/20 rounded"
title={translate("Delete template")}
>
<Trash2 className="h-3 w-3 text-destructive" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onSelect={onSaveTemplate}>
<Plus className="h-3 w-3 mr-2" />
<T>Save current as template</T>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
onClick={onOptimizePrompt}
disabled={isOptimizing || !prompt.trim()}
className="h-8"
title={translate("Optimize prompt with AI")}
>
{isOptimizing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<T>Optimizing...</T>
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
<T>Optimize</T>
</>
)}
</Button>
</div>
</div>
<div className="space-y-2">
{/* History Navigation Buttons */}
{promptHistory.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
History: {historyIndex >= 0 ? `${historyIndex + 1}/${promptHistory.length}` : 'Current'}
</span>
<div className="flex gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onNavigateHistory('up')}
disabled={promptHistory.length === 0 || historyIndex === promptHistory.length - 1}
className="h-8 px-2 md:h-6"
title="Previous prompt (Ctrl+↑)"
>
<ArrowUp className="h-4 w-4 md:h-3 md:w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onNavigateHistory('down')}
disabled={historyIndex === -1}
className="h-8 px-2 md:h-6"
title="Next prompt (Ctrl+↓)"
>
<ArrowUp className="h-4 w-4 md:h-3 md:w-3 rotate-180" />
</Button>
</div>
</div>
)}
<div className="relative">
<Textarea
value={prompt}
onChange={(e) => {
onPromptChange(e.target.value);
onManualEdit();
}}
placeholder={
isSplitMode
? translate("Enter each prompt on a new line (max 10 lines)...")
: selectedPreset
? translate("Add specific details to the context above...")
: translate("Describe the image you want to create or edit... (Ctrl+V to paste images)")
}
rows={isSplitMode ? 6 : 3}
className="resize-none pr-10 text-base md:text-sm"
onKeyDown={(e) => {
if ((e.key === 'Enter' && e.ctrlKey) && prompt.trim() && !isGenerating) {
e.preventDefault();
onGenerate();
} else if (e.key === 'ArrowUp' && e.ctrlKey) {
e.preventDefault();
onNavigateHistory('up');
} else if (e.key === 'ArrowDown' && e.ctrlKey) {
e.preventDefault();
onNavigateHistory('down');
}
}}
onPaste={async (e) => {
const items = Array.from(e.clipboardData?.items || []);
const imageFiles = items.filter(item => item.type.startsWith('image/'));
if (imageFiles.length > 0) {
e.preventDefault();
for (const item of imageFiles) {
const file = item.getAsFile();
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const newImage: ImageFile = {
id: `paste-${Date.now()}-${Math.random()}`,
file: file,
src: event.target?.result as string,
title: file.name || `Pasted image ${Date.now()}`,
selected: true
};
onImagePaste(newImage);
toast.success(translate('Image pasted successfully!'));
};
reader.readAsDataURL(file);
}
}
}
}}
/>
<button
onClick={onMicrophoneToggle}
disabled={isTranscribing}
className={`absolute right-2 bottom-2 p-1.5 rounded-md transition-colors ${
isRecording
? 'bg-red-100 text-red-600 hover:bg-red-200'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={isRecording ? 'Stop recording' : 'Record audio'}
>
{isTranscribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isRecording ? (
<MicOff className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
);
};
export default Prompt;

View File

@ -0,0 +1,210 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Edit, Download, Trash2, Plus, Star, Wand2 } from 'lucide-react';
import { T, translate } from '@/i18n';
import { QuickAction } from '@/constants';
import { ImageFile } from '../types';
import { StylePresetSelector } from '@/components/StylePresetSelector';
interface QuickActionsToolbarProps {
quickActions: QuickAction[];
images: ImageFile[];
isGenerating: boolean;
onExecuteAction: (action: QuickAction) => void;
onEditActions: () => void;
// File actions
onDownload: () => void;
onDelete: () => void;
onSetAsSelected: () => void;
onEdit: () => void;
onSettings: () => void;
onAddToPost?: () => void;
onSaveAsVersion: () => void;
editingPostId?: string | null;
}
/**
* Quick Actions Toolbar
*
* Displays a unified toolbar with:
* 1. File management actions (always visible, disabled if not applicable)
* 2. Style presets in a separate row
*/
export const QuickActionsToolbar: React.FC<QuickActionsToolbarProps> = ({
quickActions,
images,
isGenerating,
onExecuteAction,
onEditActions,
onDownload,
onDelete,
onSetAsSelected,
onEdit,
onSettings,
onAddToPost,
onSaveAsVersion,
editingPostId,
}) => {
const selectedImages = images.filter(img => img.selected);
const hasSelectedImages = selectedImages.length > 0;
const singleImageSelected = selectedImages.length === 1;
const selectedImage = singleImageSelected ? selectedImages[0] : null;
// Determine capability states
const isSavedImage = selectedImage?.id && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(selectedImage.id);
const isGeneratedUnsaved = selectedImage?.isGenerated && !isSavedImage;
// Allow setting as preferred for any single image (saved, generated, or upload)
const canSetAsSelected = singleImageSelected;
const canEdit = singleImageSelected;
const canConfig = singleImageSelected && isSavedImage;
const canSaveAsVersion = singleImageSelected && isGeneratedUnsaved;
// Add to Post is valid if we are editing a post, single image selected, not already a real DB image (which implies it's already "in" the system, though logic might vary), and not currently adding.
const canAddToPost = singleImageSelected && editingPostId && !selectedImage?.realDatabaseId && !selectedImage?.isAddingToPost;
if (images.length === 0) return null;
return (
<div className="mb-4 space-y-2">
{/* Primary Toolbar: File Actions */}
<div className="p-2 md:p-3 bg-muted/50 rounded-lg border border-border">
<div className="flex items-center justify-between mb-2 px-1">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Wand2 className="h-3 w-3" />
<T>Image Actions</T>
{hasSelectedImages && (
<span className="text-primary font-normal normal-case ml-2 bg-primary/10 px-2 py-0.5 rounded-full">
{selectedImages.length} selected
</span>
)}
</label>
</div>
<div className="flex items-center gap-2 flex-wrap pb-1">
{/* Download */}
<Button
variant="outline"
size="sm"
disabled={!hasSelectedImages}
onClick={onDownload}
className="h-9 px-3"
title={translate("Download selected")}
>
<Download className="h-4 w-4 mr-1.5" />
<span className="text-xs whitespace-nowrap"><T>Download</T></span>
</Button>
{/* Preferred */}
<Button
variant="outline"
size="sm"
disabled={!canSetAsSelected}
onClick={onSetAsSelected}
className={`h-9 px-3 ${selectedImage?.isPreferred ? 'bg-yellow-100/50 border-yellow-400 text-yellow-700 dark:text-yellow-400' : ''}`}
title={translate("Set as Preferred Version")}
>
<Star className={`h-3.5 w-3.5 mr-1.5 ${selectedImage?.isPreferred ? 'fill-yellow-500 text-yellow-500' : ''}`} />
<span className="text-xs whitespace-nowrap"><T>Preferred</T></span>
</Button>
{/* Edit */}
<Button
variant="outline"
size="sm"
disabled={!canEdit}
onClick={onEdit}
className="h-9 px-3"
title={translate("Edit in Lightbox")}
>
<Edit className="h-3.5 w-3.5 mr-1.5" />
<span className="text-xs whitespace-nowrap"><T>Edit</T></span>
</Button>
{/* Settings */}
<Button
variant="outline"
size="sm"
disabled={!canConfig}
onClick={onSettings}
className="h-9 px-3"
title={translate("Image Settings & Metadata")}
>
<Wand2 className="h-3.5 w-3.5 mr-1.5" />
<span className="text-xs whitespace-nowrap"><T>Settings</T></span>
</Button>
{/* Add to Post - Only visible if context allows */}
{editingPostId && (
<Button
variant="outline"
size="sm"
disabled={!canAddToPost}
onClick={onAddToPost}
className="h-9 px-3 text-blue-600 hover:text-blue-700 hover:bg-blue-50 border-blue-200"
title={translate("Add to Post")}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
<span className="text-xs whitespace-nowrap"><T>Add to Post</T></span>
</Button>
)}
{/* Save Version */}
<Button
variant="outline"
size="sm"
disabled={!canSaveAsVersion}
onClick={onSaveAsVersion}
className="h-9 px-3"
title={translate("Save as Version")}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
<span className="text-xs whitespace-nowrap"><T>Save Version</T></span>
</Button>
{/* Delete */}
<Button
variant="outline"
size="sm"
disabled={!hasSelectedImages}
onClick={onDelete}
className="h-9 px-3 text-destructive hover:text-destructive hover:bg-destructive/10 hover:border-destructive/30"
title={translate("Delete selected")}
>
<Trash2 className="h-4 w-4 mr-1.5" />
<span className="text-xs whitespace-nowrap"><T>Delete</T></span>
</Button>
</div>
</div>
{/* Secondary Toolbar: Style Presets */}
<div className="p-2 md:p-3 bg-muted/50 rounded-lg border border-border">
<div className="flex items-center justify-between mb-2 px-1">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<T>Style Presets</T>
</label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onEditActions}
className="h-6 px-2 text-xs"
title="Edit quick actions"
>
<Edit className="h-3 w-3" />
</Button>
</div>
<div className="pb-1">
<StylePresetSelector
presets={quickActions}
onSelect={onExecuteAction}
disabled={!hasSelectedImages || isGenerating}
/>
</div>
</div>
</div>
);
};
export default QuickActionsToolbar;

View File

@ -0,0 +1,456 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Loader2, Upload, Save, Wand2, X, Brain, Mic, Plus, ChevronDown } from "lucide-react";
import { T } from "@/i18n";
import { PromptPreset } from "@/components/PresetManager";
import PresetManager from "@/components/PresetManager";
import { Workflow } from "@/components/WorkflowManager";
import WorkflowManager from "@/components/WorkflowManager";
import CollapsibleSection from "@/components/ui/collapsible-section";
import ModelSelector from "./ModelSelector";
import { ImageFile } from "../types";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface WizardSidebarProps {
// Model
selectedModel: string;
onModelChange: (model: string) => void;
aspectRatio: string;
onAspectRatioChange: (ratio: string) => void;
resolution: string;
onResolutionChange: (res: string) => void;
searchGrounding: boolean;
onSearchGroundingChange: (enabled: boolean) => void;
// Presets
selectedPreset: PromptPreset | null;
presets: PromptPreset[];
loadingPresets: boolean;
onPresetSelect: (preset: PromptPreset) => void;
onPresetClear: () => void;
onSavePreset: (preset: Omit<PromptPreset, 'id' | 'createdAt'>) => Promise<void>;
onUpdatePreset: (id: string, preset: Omit<PromptPreset, 'id' | 'createdAt'>) => Promise<void>;
onDeletePreset: (id: string) => Promise<void>;
// Workflows
workflows: Workflow[];
loadingWorkflows: boolean;
onSaveWorkflow: (workflow: Omit<Workflow, 'id' | 'createdAt'>) => Promise<void>;
onUpdateWorkflow: (id: string, workflow: Omit<Workflow, 'id' | 'createdAt'>) => Promise<void>;
onDeleteWorkflow: (id: string) => Promise<void>;
onExecuteWorkflow: (workflow: Workflow) => Promise<void>;
// Prompt (children will be rendered in the prompt section)
children: React.ReactNode;
// Generation
isGenerating: boolean;
isAgentMode: boolean;
isSplitMode: boolean;
prompt: string;
onGenerate: () => void;
onGenerateSplit: () => void;
onAgentGenerate: () => void;
onVoiceGenerate: () => void;
onAbort: () => void;
// Publishing
images: ImageFile[];
generatedImage: string | null;
postTitle: string;
onPostTitleChange: (title: string) => void;
isPublishing: boolean;
onQuickPublish: () => void;
onPublish: () => void;
onPublishToGallery?: () => void;
onAppendToPost?: () => void;
onAddToPost?: () => void;
editingPostId?: string | null;
}
export const WizardSidebar: React.FC<WizardSidebarProps> = ({
selectedModel,
onModelChange,
aspectRatio,
onAspectRatioChange,
resolution,
onResolutionChange,
searchGrounding,
onSearchGroundingChange,
selectedPreset,
presets,
loadingPresets,
onPresetSelect,
onPresetClear,
onSavePreset,
onUpdatePreset,
onDeletePreset,
workflows,
loadingWorkflows,
onSaveWorkflow,
onUpdateWorkflow,
onDeleteWorkflow,
onExecuteWorkflow,
children,
isGenerating,
isAgentMode,
isSplitMode,
prompt,
onGenerate,
onGenerateSplit,
onAgentGenerate,
onVoiceGenerate,
onAbort,
images,
generatedImage,
postTitle,
onPostTitleChange,
isPublishing,
onQuickPublish,
onPublish,
onPublishToGallery,
onAppendToPost,
onAddToPost,
editingPostId,
}) => {
return (
<div className="lg:col-span-1 space-y-3 md:space-y-6">
<Card>
<CardContent className="p-3 md:p-6 space-y-3 md:space-y-4">
{/* Step 1: Model Selector */}
<ModelSelector
selectedModel={selectedModel}
onChange={onModelChange}
/>
{/* Gemini 3 Pro Advanced Options */}
{selectedModel === 'google/gemini-3-pro-image-preview' && (
<div className="grid grid-cols-2 gap-3 pt-2">
<div>
<label className="text-xs font-medium text-muted-foreground"><T>Aspect Ratio</T></label>
<Select value={aspectRatio} onValueChange={onAspectRatioChange}>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="Aspect Ratio" />
</SelectTrigger>
<SelectContent>
{["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"].map(ratio => (
<SelectItem key={ratio} value={ratio} className="text-xs">{ratio}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground"><T>Resolution</T></label>
<Select value={resolution} onValueChange={onResolutionChange}>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="Resolution" />
</SelectTrigger>
<SelectContent>
{["1K", "2K", "4K"].map(res => (
<SelectItem key={res} value={res} className="text-xs">{res}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* Google Search Grounding */}
{selectedModel.startsWith('google/') && (
<div className="flex items-center justify-between pt-3">
<Label htmlFor="search-grounding" className="flex flex-col space-y-1">
<span className="font-medium text-sm"><T>Grounding with Google Search</T></span>
<span className="text-xs text-muted-foreground">
<T>Use Google Search to improve image relevance and accuracy.</T>
</span>
</Label>
<Switch
id="search-grounding"
checked={searchGrounding}
onCheckedChange={onSearchGroundingChange}
/>
</div>
)}
{/* Step 2: Prompt Presets Manager */}
<div className="hidden md:block">
<CollapsibleSection
title={
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">2.</span>
<T>Context Preset (Optional)</T>
</div>
}
headerContent={
selectedPreset && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
onPresetClear();
}}
className="h-6 px-2 text-xs text-red-600 hover:text-red-700"
>
<X className="w-3 h-3 mr-1" />
<T>Clear Context</T>
</Button>
)
}
initiallyOpen={false}
storageKey="wizard-presets-section"
minimal={true}
titleClassName="text-sm font-medium"
buttonClassName="h-6 w-6 p-0"
contentClassName="pt-2"
className="mb-3"
>
{selectedPreset && (
<div className="mb-2 p-2 bg-blue-50 dark:bg-blue-900/20 rounded-md border border-blue-200 dark:border-blue-700">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold text-blue-700 dark:text-blue-300">
Active Context: {selectedPreset.name}
</span>
</div>
<p className="text-xs text-blue-600 dark:text-blue-400 line-clamp-2">
{selectedPreset.prompt}
</p>
</div>
)}
<PresetManager
presets={presets}
currentPrompt={prompt}
onSelectPreset={onPresetSelect}
onSavePreset={onSavePreset}
onUpdatePreset={onUpdatePreset}
onDeletePreset={onDeletePreset}
loading={loadingPresets}
selectedPresetId={selectedPreset?.id}
/>
</CollapsibleSection>
</div>
{/* Step 2.5: Workflow Manager */}
<div className="hidden md:block">
<CollapsibleSection
title={
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">2.5.</span>
<T>Workflows</T>
</div>
}
initiallyOpen={false}
storageKey="wizard-workflows-section"
minimal={true}
titleClassName="text-sm font-medium"
buttonClassName="h-6 w-6 p-0"
contentClassName="pt-2"
className="mb-3"
>
<WorkflowManager
workflows={workflows}
onSaveWorkflow={onSaveWorkflow}
onUpdateWorkflow={onUpdateWorkflow}
onDeleteWorkflow={onDeleteWorkflow}
onExecuteWorkflow={onExecuteWorkflow}
loading={loadingWorkflows}
/>
</CollapsibleSection>
</div>
{/* Step 3: Prompt Section - Render children */}
{children}
{/* Step 4: Generate Buttons */}
<div>
<label className="text-sm md:text-base font-medium mb-2 block">
<span className="text-xs text-muted-foreground mr-2">4.</span>
<T>Generate Image</T>
</label>
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
{/* Direct Generation */}
{isGenerating && !isAgentMode ? (
<Button
onClick={onAbort}
variant="destructive"
className="w-full h-10 text-sm touch-manipulation px-2"
title="Cancel generation"
>
<X className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline"><T>Cancel</T></span>
</Button>
) : (
<Button
onClick={isSplitMode ? onGenerateSplit : onGenerate}
disabled={isGenerating || !prompt.trim()}
variant="default"
className="w-full h-10 text-sm touch-manipulation px-2"
title={isSplitMode ? "Split generation (sequential)" : "Direct image generation (fast)"}
>
<Wand2 className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">{isSplitMode ? <T>Generate Split</T> : <T>Generate</T>}</span>
</Button>
)}
{/* Agent Mode */}
{isGenerating && isAgentMode ? (
<Button
onClick={onAbort}
variant="destructive"
className="w-full h-10 text-sm touch-manipulation px-2"
title="Cancel agent"
>
<X className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline"><T>Cancel</T></span>
</Button>
) : (
<Button
onClick={onAgentGenerate}
disabled={isGenerating || !prompt.trim()}
variant="secondary"
className="w-full h-10 text-sm bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white touch-manipulation px-2"
title="AI Agent with tools (smart workflows)"
>
<Brain className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline"><T>Agent</T></span>
</Button>
)}
{/* Voice Agent */}
<Button
onClick={onVoiceGenerate}
disabled={isGenerating}
variant="secondary"
className="w-full h-10 text-sm bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white touch-manipulation px-2"
title="Voice to image with AI agent"
>
<Mic className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline"><T>Voice</T></span>
</Button>
</div>
{/* Button Descriptions */}
<div className="hidden sm:grid text-xs text-muted-foreground mt-2 grid-cols-3 gap-2 text-center">
<div><T>Fast & Direct</T></div>
<div><T>Smart & Optimized</T></div>
<div><T>Voice + AI</T></div>
</div>
</div>
{/* Step 5: Title Field */}
{(generatedImage || images.length > 0) && (
<div>
<label className="text-sm md:text-base font-medium mb-2 block">
<span className="text-xs text-muted-foreground mr-2">5.</span>
<T>Title for Publishing (Optional)</T>
</label>
<input
type="text"
placeholder="Enter a title..."
className="w-full px-3 py-2.5 md:py-2 border border-border rounded-md text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-background mb-3"
value={postTitle}
onChange={(e) => onPostTitleChange(e.target.value)}
/>
</div>
)}
{/* Step 6: Publish Buttons */}
{(generatedImage || images.length > 0) && (
<div>
<label className="text-sm md:text-base font-medium mb-2 block">
<span className="text-xs text-muted-foreground mr-2">6.</span>
<T>Publish</T>
</label>
<div className="space-y-1.5">
{/* Add to Post Button (if editing post) */}
{editingPostId && (
<Button
onClick={onAddToPost}
variant="default"
className="w-full text-sm h-10 bg-blue-600 hover:bg-blue-700 text-white"
disabled={isPublishing}
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<span className="hidden sm:inline">Adding...</span>
</>
) : (
<>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Add to Current Post</span>
<span className="sm:hidden">Add to Post</span>
</>
)}
</Button>
)}
{/* Quick Publish / Dropdown */}
<div className="flex rounded-md shadow-sm w-full">
<Button
onClick={onQuickPublish}
variant="default"
className="w-full text-sm h-10 rounded-r-none"
disabled={isPublishing || !prompt.trim()}
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<span className="hidden sm:inline">Publishing...</span>
</>
) : (
<>
<Upload className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline"><T>Quick Publish as Post (Default)</T></span>
<span className="sm:hidden"><T>Quick Publish</T></span>
</>
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" className="rounded-l-none border-l border-primary-foreground/20 px-2 h-10" disabled={isPublishing}>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onQuickPublish}>
<T>Quick Publish as Post (Default)</T>
</DropdownMenuItem>
{onPublishToGallery && (
<DropdownMenuItem onClick={onPublishToGallery}>
<T>Publish as Picture</T>
</DropdownMenuItem>
)}
{onAppendToPost && (
<DropdownMenuItem onClick={onAppendToPost}>
<T>Append to Existing Post</T>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div >
);
};
export default WizardSidebar;

View File

@ -0,0 +1,14 @@
// Central exports for ImageWizard UI components
export { QuickActionsToolbar } from './QuickActionsToolbar';
export { ImageActionButtons } from './ImageActionButtons';
export { ModelSelector } from './ModelSelector';
export { default as WizardSidebar } from './WizardSidebar';
export { default as Prompt } from './Prompt';
export { default as ImageGalleryPanel } from './ImageGalleryPanel';
export { PostComposer } from './PostComposer';

View File

@ -0,0 +1,129 @@
/**
* Wizard Context
* Provides shared state and actions for ImageWizard components
* Eliminates prop drilling through component hierarchy
*/
import React, { createContext, useContext } from 'react';
import { ImageFile } from '../types';
import { PromptPreset } from '@/components/PresetManager';
import { Workflow } from '@/components/WorkflowManager';
import { QuickAction } from '@/constants';
import { Logger } from '../utils/logger';
interface WizardContextValue {
// User
userId: string | undefined;
// Images
images: ImageFile[];
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>;
availableImages: ImageFile[];
generatedImage: string | null;
// Generation state
isGenerating: boolean;
isAgentMode: boolean;
isSplitMode: boolean;
setIsSplitMode: React.Dispatch<React.SetStateAction<boolean>>;
isOptimizingPrompt: boolean;
// Form state
prompt: string;
setPrompt: React.Dispatch<React.SetStateAction<string>>;
postTitle: string;
setPostTitle: React.Dispatch<React.SetStateAction<string>>;
selectedModel: string;
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
// Settings
selectedPreset: PromptPreset | null;
promptPresets: PromptPreset[];
loadingPresets: boolean;
workflows: Workflow[];
loadingWorkflows: boolean;
promptTemplates: Array<{name: string; template: string}>;
quickActions: QuickAction[];
// Prompt history
promptHistory: string[];
historyIndex: number;
setHistoryIndex: React.Dispatch<React.SetStateAction<number>>;
// Voice
isRecording: boolean;
isTranscribing: boolean;
// UI state
isPublishing: boolean;
dragIn: boolean;
setDragIn: React.Dispatch<React.SetStateAction<boolean>>;
loadingImages: boolean;
dragLeaveTimeoutRef: React.RefObject<NodeJS.Timeout | null>;
// Actions - Image operations
toggleImageSelection: (imageId: string, isMultiSelect?: boolean, fromGallery?: boolean) => void;
openLightbox: (index: number) => void;
setAsSelected: (imageId: string) => void;
removeImage: (imageId: string) => void;
deleteSelectedImages: () => void;
// Actions - Generation
generateImage: () => Promise<void>;
generateImageSplit: () => Promise<void>;
handleAgentGeneration: () => void;
handleOptimizePrompt: () => void;
handleMicrophone: () => void;
// Actions - Settings
handlePresetSelect: (preset: PromptPreset) => void;
handlePresetClear: () => void;
savePreset: (preset: Omit<PromptPreset, 'id' | 'createdAt'>) => Promise<void>;
updatePreset: (id: string, preset: Omit<PromptPreset, 'id' | 'createdAt'>) => Promise<void>;
deletePreset: (id: string) => Promise<void>;
saveWorkflow: (workflow: Omit<Workflow, 'id' | 'createdAt'>) => Promise<void>;
updateWorkflow: (id: string, workflow: Omit<Workflow, 'id' | 'createdAt'>) => Promise<void>;
deleteWorkflow: (id: string) => Promise<void>;
executeWorkflow: (workflow: Workflow) => Promise<void>;
applyTemplate: (template: string) => void;
deleteTemplate: (index: number) => Promise<void>;
handleSaveCurrentPromptAsTemplate: () => void;
executeQuickAction: (action: QuickAction) => void;
openEditActionsDialog: () => void;
navigateHistory: (direction: 'up' | 'down') => void;
// Actions - Publishing
quickPublishAsNew: () => Promise<void>;
publishImage: () => Promise<void>;
// Actions - Misc
setShowVoicePopup: React.Dispatch<React.SetStateAction<boolean>>;
// Logger
logger: Logger;
}
const WizardContext = createContext<WizardContextValue | null>(null);
export const WizardProvider: React.FC<{
value: WizardContextValue;
children: React.ReactNode;
}> = ({ value, children }) => {
return (
<WizardContext.Provider value={value}>
{children}
</WizardContext.Provider>
);
};
export const useWizard = () => {
const context = useContext(WizardContext);
if (!context) {
throw new Error('useWizard must be used within WizardProvider');
}
return context;
};

View File

@ -0,0 +1,408 @@
/**
* Database operations for ImageWizard
* All Supabase queries and mutations isolated here
*/
import { supabase } from "@/integrations/supabase/client";
import { toast } from "sonner";
import { translate } from "@/i18n";
/**
* Get organization ID by slug
*/
export const getOrganizationId = async (orgSlug: string): Promise<string | null> => {
try {
const { data: org } = await supabase
.from('organizations')
.select('id')
.eq('slug', orgSlug)
.single();
return org?.id || null;
} catch (error) {
console.error('Error fetching organization:', error);
return null;
}
};
/**
* Upload image blob to storage
*/
export const uploadImageToStorage = async (
userId: string,
blob: Blob,
suffix: string = 'generated'
): Promise<{ fileName: string; publicUrl: string } | null> => {
try {
const fileName = `${userId}/${Date.now()}-${suffix}.png`;
const { error: uploadError } = await supabase.storage
.from('pictures')
.upload(fileName, blob);
if (uploadError) throw uploadError;
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('pictures')
.getPublicUrl(fileName);
return { fileName, publicUrl };
} catch (error) {
console.error('Error uploading image to storage:', error);
throw error;
}
};
/**
* Create new picture in database
*/
export const createPictureRecord = async (params: {
userId: string;
title: string | null;
description: string | null;
imageUrl: string;
organizationId?: string | null;
parentId?: string | null;
isSelected?: boolean;
}): Promise<{ id: string } | null> => {
try {
const { data: pictureData, error: dbError } = await supabase
.from('pictures')
.insert([{
title: params.title?.trim() || null,
description: params.description || null,
image_url: params.imageUrl,
user_id: params.userId,
parent_id: params.parentId || null,
is_selected: params.isSelected ?? false,
organization_id: params.organizationId || null,
}])
.select()
.single();
if (dbError) throw dbError;
return pictureData;
} catch (error) {
console.error('Error creating picture record:', error);
throw error;
}
};
/**
* Add picture to collections
*/
export const addPictureToCollections = async (
pictureId: string,
collectionIds: string[]
): Promise<boolean> => {
try {
const collectionInserts = collectionIds.map(collectionId => ({
collection_id: collectionId,
picture_id: pictureId
}));
const { error: collectionError } = await supabase
.from('collection_pictures')
.insert(collectionInserts);
if (collectionError) {
console.error('Error adding to collections:', collectionError);
return false;
}
return true;
} catch (error) {
console.error('Error adding to collections:', error);
return false;
}
};
/**
* Unselect all images in a family (root and all versions)
*/
export const unselectImageFamily = async (
rootParentId: string,
userId: string
): Promise<void> => {
try {
await supabase
.from('pictures')
.update({ is_selected: false })
.or(`id.eq.${rootParentId},parent_id.eq.${rootParentId}`)
.eq('user_id', userId);
} catch (error) {
console.error('Error unselecting image family:', error);
throw error;
}
};
/**
* Check selection status of an image
*/
export const getImageSelectionStatus = async (imageId: string): Promise<boolean> => {
try {
const { data } = await supabase
.from('pictures')
.select('is_selected')
.eq('id', imageId)
.single();
return data?.is_selected || false;
} catch (error) {
console.error('Error getting image selection status:', error);
return false;
}
};
/**
* Publish image as new post
*/
export const publishImageAsNew = async (params: {
userId: string;
blob: Blob;
title: string;
description?: string;
isOrgContext: boolean;
orgSlug?: string;
collectionIds?: string[];
}): Promise<void> => {
const { userId, blob, title, description, isOrgContext, orgSlug, collectionIds } = params;
// Upload to storage
const uploadResult = await uploadImageToStorage(userId, blob, 'generated');
if (!uploadResult) throw new Error('Failed to upload image');
// Get organization ID if needed
let organizationId = null;
if (isOrgContext && orgSlug) {
organizationId = await getOrganizationId(orgSlug);
}
// Create picture record
const pictureData = await createPictureRecord({
userId,
title,
description: description || null,
imageUrl: uploadResult.publicUrl,
organizationId,
});
if (!pictureData) throw new Error('Failed to create picture record');
// Add to collections if specified
if (collectionIds && collectionIds.length > 0) {
const success = await addPictureToCollections(pictureData.id, collectionIds);
if (success) {
toast.success(translate(`Image published and added to ${collectionIds.length} collection(s)!`));
} else {
toast.error(translate('Image published but failed to add to collections'));
}
} else {
toast.success(translate('Image published to gallery!'));
}
};
/**
* Publish image as version of existing image
*/
export const publishImageAsVersion = async (params: {
userId: string;
blob: Blob;
title: string;
description?: string;
parentId: string;
isOrgContext: boolean;
orgSlug?: string;
collectionIds?: string[];
}): Promise<void> => {
const { userId, blob, title, description, parentId, isOrgContext, orgSlug, collectionIds } = params;
// Upload to storage
const uploadResult = await uploadImageToStorage(userId, blob, 'version');
if (!uploadResult) throw new Error('Failed to upload image');
// Unselect all images in the family first
const rootParentId = parentId && parentId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) ? parentId : null;
if (rootParentId) {
await unselectImageFamily(rootParentId, userId);
}
// Get organization ID if needed
let organizationId = null;
if (isOrgContext && orgSlug) {
organizationId = await getOrganizationId(orgSlug);
}
// Create version record (selected by default)
const pictureData = await createPictureRecord({
userId,
title,
description: description || null,
imageUrl: uploadResult.publicUrl,
organizationId,
parentId: rootParentId,
isSelected: true,
});
if (!pictureData) throw new Error('Failed to create version record');
// Add to collections if specified
if (collectionIds && collectionIds.length > 0) {
const success = await addPictureToCollections(pictureData.id, collectionIds);
if (success) {
toast.success(translate(`Version saved and added to ${collectionIds.length} collection(s)!`));
} else {
toast.error(translate('Version saved but failed to add to collections'));
}
} else {
toast.success(translate('Version saved successfully!'));
}
};
/**
* Publish image directly to an existing post
*/
export const publishImageToPost = async (params: {
userId: string;
blob: Blob;
title: string;
description?: string;
postId: string;
isOrgContext: boolean;
orgSlug?: string;
collectionIds?: string[];
}): Promise<void> => {
const { userId, blob, title, description, postId, isOrgContext, orgSlug, collectionIds } = params;
// Upload to storage
const uploadResult = await uploadImageToStorage(userId, blob, 'post-add');
if (!uploadResult) throw new Error('Failed to upload image');
// Get organization ID if needed
let organizationId = null;
if (isOrgContext && orgSlug) {
organizationId = await getOrganizationId(orgSlug);
}
// Get current max position for this post to append at the end
const { data: maxPosData } = await supabase
.from('pictures')
.select('position')
.eq('post_id', postId)
.order('position', { ascending: false })
.limit(1)
.single();
const nextPosition = (maxPosData?.position || 0) + 1;
// Create picture record attached to post
const { data: pictureData, error: dbError } = await supabase
.from('pictures')
.insert([{
title: title,
description: description || null,
image_url: uploadResult.publicUrl,
user_id: userId,
post_id: postId,
position: nextPosition,
is_selected: true,
organization_id: organizationId || null,
}])
.select()
.single();
if (dbError) throw dbError;
if (!pictureData) throw new Error('Failed to create picture record');
// Add to collections if specified
if (collectionIds && collectionIds.length > 0) {
const success = await addPictureToCollections(pictureData.id, collectionIds);
if (success) {
toast.success(translate(`Image added to post and ${collectionIds.length} collection(s)!`));
} else {
toast.error(translate('Image added to post but failed to add to collections'));
}
} else {
toast.success(translate('Image added to post successfully!'));
}
};
/**
* Get user's OpenAI API key from user_secrets.settings
*/
export const getUserOpenAIKey = async (userId: string): Promise<string | null> => {
try {
const secrets = await getUserSecrets(userId);
return secrets?.openai_api_key || null;
} catch (error) {
console.error('Error fetching OpenAI key:', error);
return null;
}
};
/**
* Get user secrets from user_secrets table (settings column)
*/
export const getUserSecrets = async (userId: string): Promise<Record<string, string> | null> => {
try {
const { data: secretData } = await supabase
.from('user_secrets')
.select('settings')
.eq('user_id', userId)
.single();
if (!secretData?.settings) return null;
const settings = secretData.settings as Record<string, any>;
return (settings.api_keys as Record<string, string>) || null;
} catch (error) {
console.error('Error fetching user secrets:', error);
return null;
}
};
/**
* Update user secrets in user_secrets table (settings column)
*/
export const updateUserSecrets = async (userId: string, secrets: Record<string, string>): Promise<void> => {
try {
// Check if record exists
const { data: existing } = await supabase
.from('user_secrets')
.select('settings')
.eq('user_id', userId)
.maybeSingle();
if (existing) {
// Update existing
const currentSettings = (existing.settings as Record<string, any>) || {};
const currentApiKeys = (currentSettings.api_keys as Record<string, any>) || {};
const newSettings = {
...currentSettings,
api_keys: { ...currentApiKeys, ...secrets }
};
const { error } = await supabase
.from('user_secrets')
.update({ settings: newSettings })
.eq('user_id', userId);
if (error) throw error;
} else {
// Insert new
const { error } = await supabase
.from('user_secrets')
.insert({
user_id: userId,
settings: { api_keys: secrets }
});
if (error) throw error;
}
} catch (error) {
console.error('Error updating user secrets:', error);
throw error;
}
};

View File

@ -0,0 +1,351 @@
# ImageWizard Handlers - Quick Reference
This directory contains modular handler functions for the ImageWizard component, organized by responsibility.
## 📁 File Structure
```
handlers/
├── index.ts # Central export file
├── imageHandlers.ts # Image operations
├── generationHandlers.ts # Image generation
├── publishHandlers.ts # Publishing workflows
├── dataHandlers.ts # Data loading
├── settingsHandlers.ts # User settings
├── voiceHandlers.ts # Voice/audio
├── agentHandlers.ts # AI agent mode
└── README.md # This file
```
## 🎯 Quick Import Guide
### Import Everything
```typescript
import {
handleFileUpload,
loadPromptTemplates,
handleVoiceToImage,
// ... etc
} from './ImageWizard/handlers';
```
### Import Specific Module
```typescript
import { handleFileUpload } from './ImageWizard/handlers/imageHandlers';
```
## 📚 Handler Reference
### imageHandlers.ts
**Purpose**: Image file operations
| Function | Description |
|----------|-------------|
| `handleFileUpload` | Upload images from file system |
| `toggleImageSelection` | Toggle image selection state |
| `removeImageRequest` | Request image deletion |
| `confirmDeleteImage` | Confirm and delete image |
| `setAsSelected` | Mark image version as selected |
| `handleDownloadImage` | Download image to device |
**Usage Example**:
```typescript
// File upload
handleFileUpload(event, setImages);
// Toggle selection
toggleImageSelection(imageId, isMultiSelect, fromGallery, availableImages, images, setImages);
```
---
### generationHandlers.ts
**Purpose**: Image generation operations
| Function | Description |
|----------|-------------|
| `handleOptimizePrompt` | Optimize prompt with AI |
| `buildFullPrompt` | Build prompt with preset context |
| `abortGeneration` | Cancel generation |
**Usage Example**:
```typescript
// Optimize prompt
await handleOptimizePrompt(
prompt,
lightboxOpen,
setLightboxPrompt,
setPrompt,
setIsOptimizingPrompt
);
// Build full prompt
const fullPrompt = buildFullPrompt(selectedPreset, prompt);
```
---
### publishHandlers.ts
**Purpose**: Publishing workflows
| Function | Description |
|----------|-------------|
| `publishImage` | Standard publish with metadata |
| `quickPublishAsNew` | Quick publish (prompt as description) |
**Usage Example**:
```typescript
// Standard publish
await publishImage(
{
user,
generatedImage,
images,
lightboxOpen,
currentImageIndex,
postTitle,
prompt,
isOrgContext,
orgSlug,
onPublish,
},
setIsPublishing
);
```
---
### dataHandlers.ts
**Purpose**: Data loading from database
| Function | Description |
|----------|-------------|
| `loadFamilyVersions` | Load image version families |
| `loadAvailableImages` | Load gallery images |
**Usage Example**:
```typescript
// Load family versions
await loadFamilyVersions(parentImages, setImages, addLog);
// Load available images
await loadAvailableImages(setAvailableImages, setLoadingImages);
```
---
### settingsHandlers.ts
**Purpose**: User settings management
| Function | Description |
|----------|-------------|
| `loadPromptTemplates` | Load prompt templates |
| `savePromptTemplates` | Save prompt templates |
| `loadPromptPresets` | Load prompt presets |
| `loadWorkflows` | Load workflows |
| `loadQuickActions` | Load quick actions |
| `saveQuickActions` | Save quick actions |
| `loadPromptHistory` | Load prompt history |
| `addToPromptHistory` | Add to history |
| `navigatePromptHistory` | Navigate history |
**Usage Example**:
```typescript
// Load templates
await loadPromptTemplates(userId, setPromptTemplates, setLoadingTemplates);
// Save templates
await savePromptTemplates(userId, templates);
// Navigate history
navigatePromptHistory('up', promptHistory, historyIndex, setHistoryIndex, setPrompt);
```
---
### voiceHandlers.ts
**Purpose**: Voice recording and transcription
| Function | Description |
|----------|-------------|
| `handleMicrophone` | Start/stop recording |
| `handleVoiceToImage` | Voice-to-image workflow |
**Usage Example**:
```typescript
// Microphone control
await handleMicrophone(
isRecording,
mediaRecorderRef,
setIsRecording,
setIsTranscribing,
audioChunksRef,
lightboxOpen,
setLightboxPrompt,
setPrompt
);
// Voice to image
await handleVoiceToImage(
transcribedText,
userId,
selectedPreset,
selectedModel,
setPrompt,
setImages,
setPostTitle,
setPostDescription,
voicePopupRef,
addLog
);
```
---
### agentHandlers.ts
**Purpose**: AI agent mode with tool calling
| Function | Description |
|----------|-------------|
| `handleAgentGeneration` | Agent-based generation |
**Usage Example**:
```typescript
await handleAgentGeneration(
prompt,
userId,
fullPrompt,
selectedModel,
setIsAgentMode,
setIsGenerating,
setImages,
setPostTitle,
setPostDescription,
addLog
);
```
---
## 🔧 Common Patterns
### State Setter Pattern
Most handlers accept React state setters as parameters:
```typescript
const handleSomething = (
data: string,
setSomeState: React.Dispatch<React.SetStateAction<string>>
) => {
setSomeState(data);
};
```
### Async Operations
Handlers that interact with APIs/database are async:
```typescript
const result = await someHandler(...);
```
### Error Handling
All handlers include try-catch with toast notifications:
```typescript
try {
// operation
toast.success('Success!');
} catch (error) {
toast.error('Failed');
throw error;
}
```
## 🎨 Best Practices
1. **Always pass state setters** - Don't access component state directly
2. **Use TypeScript types** - Import from `../types`
3. **Handle errors gracefully** - Use toast for user feedback
4. **Log important actions** - Use `addLog` for debugging
5. **Keep handlers pure** - No side effects beyond parameters
## 🚀 Migration Guide
### Before (Inline Function)
```typescript
const loadSettings = async () => {
if (!user) return;
setLoading(true);
try {
const { data } = await supabase.from('profiles')...
// ... more code
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
```
### After (Using Handler)
```typescript
import { loadPromptTemplates } from './ImageWizard/handlers';
useEffect(() => {
if (user) {
loadPromptTemplates(user.id, setPromptTemplates, setLoadingTemplates);
}
}, [user]);
```
## 📝 Adding New Handlers
1. **Choose the right file** - Group by responsibility
2. **Export function** - Use named exports
3. **Add to index.ts** - Re-export from index
4. **Document** - Add JSDoc comment
5. **Update this README** - Add to reference table
Example:
```typescript
/**
* Description of what this does
*/
export const myNewHandler = async (
param1: string,
setState: React.Dispatch<React.SetStateAction<SomeType>>
) => {
try {
// implementation
} catch (error) {
console.error('Error:', error);
throw error;
}
};
```
## 🐛 Debugging
Enable debug logs in browser console:
```typescript
// In handlers, logs are already included
console.log('🔧 [Handler] Debug message', data);
```
Look for these emoji prefixes:
- 🔧 Debug/info logs
- ✅ Success
- ❌ Error
- 🎤 Voice operations
- 🤖 Agent operations
---
**Last Updated**: 2025-10-03
**Version**: 1.0
**Status**: Production Ready

View File

@ -0,0 +1,192 @@
import { ImageFile } from '../types';
import { getUserOpenAIKey } from '../db';
import { toast } from 'sonner';
import { translate } from '@/i18n';
import { runTools } from '@/lib/openai';
import { Logger } from '../utils/logger';
/**
* Agent Mode Handlers
* - Agent-based image generation with tools
*/
export const handleAgentGeneration = async (
prompt: string,
userId: string | undefined,
fullPrompt: string,
selectedModel: string,
setIsAgentMode: React.Dispatch<React.SetStateAction<boolean>>,
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>,
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
setPostTitle: React.Dispatch<React.SetStateAction<string>>,
setPostDescription: React.Dispatch<React.SetStateAction<string>>,
logger: Logger
) => {
if (!prompt.trim()) {
toast.error(translate('Please enter a prompt'));
return;
}
setIsAgentMode(true);
setIsGenerating(true);
logger.info('Starting Agent Mode generation');
// Create placeholder image for agent mode
const placeholderId = `agent-placeholder-${Date.now()}`;
const placeholderImage: ImageFile = {
id: placeholderId,
src: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iI2YwZjBmMCIvPjwvc3ZnPg==',
title: '🤖 Agent working...',
selected: false,
isGenerated: true,
};
setImages(prev => [...prev.map(img => ({ ...img, selected: false })), placeholderImage]);
try {
toast.info('🤖 Agent mode: Analyzing your request...');
// Get user's OpenAI API key
const openaiApiKey = await getUserOpenAIKey(userId || '');
logger.debug(`Agent prompt: "${fullPrompt.substring(0, 100)}..."`);
const result = await runTools({
prompt: `${fullPrompt}\n\nIMPORTANT: When calling generate_image, you MUST use model="${selectedModel}"`,
preset: 'smart-generation',
apiKey: openaiApiKey || undefined,
onToolCall: (toolCall) => {
if ('function' in toolCall) {
const toolName = toolCall.function?.name || 'unknown';
logger.debug(`Agent calling tool: ${toolName}`);
const toolMessages: Record<string, string> = {
'optimize_prompt': '✨ Optimizing your prompt...',
'generate_image': '🎨 Generating image(s)...',
'generate_image_metadata': '✍️ Creating title and description...',
'publish_image': '📋 Preparing for publishing...',
};
if (toolMessages[toolName]) {
toast.info(toolMessages[toolName]);
}
}
},
onContent: (content) => {
console.log('🤖 Agent:', content);
}
});
if (!result.success) {
toast.error('Agent failed: ' + (result.error || 'Unknown error'));
return;
}
// Extract generated images from tool calls
const imageToolCall = result.toolCalls?.find(
tc => 'function' in tc && tc.function?.name === 'generate_image'
);
if (imageToolCall && 'function' in imageToolCall) {
try {
let imageResult: any;
// Look for the function result in messages
for (const msg of result.messages || []) {
if (msg.role === 'tool' && 'content' in msg) {
try {
const toolContent = JSON.parse(msg.content as string);
if (toolContent.images || toolContent.imageUrl) {
imageResult = toolContent;
break;
}
} catch (e) {
// Not JSON, skip
}
}
}
if (!imageResult) {
console.warn('🤖 No image result found in tool responses');
toast.warning('Agent completed but no images were returned');
return;
}
// Handle multiple images
if (imageResult.images && Array.isArray(imageResult.images)) {
const newImages: ImageFile[] = [];
for (let i = 0; i < imageResult.images.length; i++) {
const img = imageResult.images[i];
const newImage: ImageFile = {
id: `agent-generated-${Date.now()}-${i}`,
src: img.imageUrl,
title: `Agent: ${prompt.substring(0, 40)}...`,
selected: i === imageResult.images.length - 1,
isGenerated: true,
aiText: img.text
};
newImages.push(newImage);
}
// Replace placeholder with actual images
setImages(prev => {
const withoutPlaceholder = prev.filter(img => img.id !== placeholderId);
return [...withoutPlaceholder, ...newImages];
});
toast.success(`✅ Agent generated ${imageResult.images.length} image${imageResult.images.length > 1 ? 's' : ''}!`);
} else if (imageResult.imageUrl) {
// Single image format
const newImage: ImageFile = {
id: `agent-generated-${Date.now()}`,
src: imageResult.imageUrl,
title: `Agent: ${prompt.substring(0, 40)}...`,
selected: true,
isGenerated: true,
aiText: imageResult.text
};
// Replace placeholder with actual image
setImages(prev => {
const withoutPlaceholder = prev.filter(img => img.id !== placeholderId);
return [...withoutPlaceholder, newImage];
});
toast.success('✅ Agent generated image!');
}
} catch (parseError) {
console.error('🤖 Error parsing image result:', parseError);
toast.error('Failed to parse Agent results');
}
}
// Extract metadata if available
const metadataToolCall = result.toolCalls?.find(
tc => 'function' in tc && tc.function?.name === 'generate_image_metadata'
);
if (metadataToolCall && 'function' in metadataToolCall) {
const metadataResult = JSON.parse(metadataToolCall.function.arguments || '{}');
if (metadataResult.title) {
setPostTitle(metadataResult.title);
toast.info(`📝 Suggested title: "${metadataResult.title}"`);
}
if (metadataResult.description) {
setPostDescription(metadataResult.description);
}
}
toast.success('🤖 Agent completed workflow!');
logger.success('Agent workflow completed successfully');
} catch (error: any) {
console.error('Error in agent mode:', error);
logger.error(`Agent error: ${error.message}`);
toast.error('Agent error: ' + error.message);
// Remove placeholder on error
setImages(prev => prev.filter(img => img.id !== placeholderId));
} finally {
setIsGenerating(false);
setIsAgentMode(false);
}
};

View File

@ -0,0 +1,193 @@
import { ImageFile } from '../types';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { translate } from '@/i18n';
import { Logger } from '../utils/logger';
/**
* Data Loading & Saving Handlers
* - Load/save presets
* - Load/save workflows
* - Load/save templates
* - Load/save quick actions
* - Load/save history
* - Load family versions
* - Load available images
*/
export const loadFamilyVersions = async (
parentImages: ImageFile[],
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
logger: Logger
) => {
try {
// Get all parent IDs and image IDs to find complete families
const imageIds = parentImages.map(img => img.realDatabaseId || img.id).filter(id => id && id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i));
if (imageIds.length === 0) {
return;
}
// For each image, find its complete family tree
const allFamilyImages = new Set<string>();
for (const imageId of imageIds) {
// First, get the current image details to check if it has a parent
const { data: currentImage, error: currentError } = await supabase
.from('pictures')
.select('id, parent_id')
.eq('id', imageId)
.single();
if (currentError) {
console.error('🔧 [Wizard] Error loading current image:', currentError);
continue;
}
// Determine the root of the family tree
let rootId = currentImage.parent_id || imageId;
// Load all versions in this family (root + all children)
const { data: familyVersions, error: familyError } = await supabase
.from('pictures')
.select(`
id,
title,
image_url,
user_id,
parent_id,
description,
is_selected,
created_at
`)
.or(`id.eq.${rootId},parent_id.eq.${rootId}`)
.order('created_at', { ascending: true });
if (familyError) {
console.error('🔧 [Wizard] Error loading family versions:', familyError);
continue;
}
// Add all family members to our set (excluding the initial image)
familyVersions?.forEach(version => {
if (version.id !== imageId) {
allFamilyImages.add(version.id);
}
});
}
// Now fetch all the unique family images
if (allFamilyImages.size > 0) {
const { data: versions, error } = await supabase
.from('pictures')
.select(`
id,
title,
image_url,
user_id,
parent_id,
description,
is_selected,
created_at
`)
.in('id', Array.from(allFamilyImages))
.order('created_at', { ascending: true });
if (error) throw error;
const versionImages: ImageFile[] = versions?.map(version => ({
id: version.id,
src: version.image_url,
title: version.title,
userId: version.user_id,
selected: false, // Default to not selected in UI
isPreferred: version.is_selected || false,
isGenerated: true,
aiText: version.description || undefined
})) || [];
// Add versions to images, but also update existing images with correct selection status
setImages(prev => {
// Create a map of database selection status
const dbSelectionStatus = new Map();
// Check if this is a singleton family (only one version)
const isSingleton = versions?.length === 1;
versions?.forEach(version => {
// If explicitly selected in DB, OR if it's the only version in the family
const isPreferred = version.is_selected || (isSingleton && !version.parent_id);
dbSelectionStatus.set(version.id, isPreferred);
});
// Update existing images and add new ones
const existingIds = new Set(prev.map(img => img.id));
const updatedExisting = prev.map(img => {
if (dbSelectionStatus.has(img.id)) {
const dbSelected = dbSelectionStatus.get(img.id);
return { ...img, isPreferred: dbSelected };
}
return img;
});
// Add new images
const newImages = versionImages.filter(img => !existingIds.has(img.id)).map(img => {
if (dbSelectionStatus.has(img.id)) {
return { ...img, isPreferred: dbSelectionStatus.get(img.id) };
}
return img;
});
const finalImages = [...updatedExisting, ...newImages];
return finalImages;
});
}
} catch (error) {
console.error('🔧 [Wizard] Error loading family versions:', error);
toast.error(translate('Failed to load image versions'));
}
};
export const loadAvailableImages = async (
setAvailableImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
setLoadingImages: React.Dispatch<React.SetStateAction<boolean>>
) => {
setLoadingImages(true);
try {
// Load images from all users (public images)
const { data: pictures, error } = await supabase
.from('pictures')
.select(`
id,
title,
image_url,
user_id
`)
.order('created_at', { ascending: false })
.limit(50);
if (error) throw error;
const imageFiles: ImageFile[] = pictures?.map(picture => ({
id: picture.id,
src: picture.image_url,
title: picture.title,
userId: picture.user_id,
selected: false
})) || [];
setAvailableImages(imageFiles);
} catch (error) {
console.error('Error loading images:', error);
toast.error(translate('Failed to load images'));
} finally {
setLoadingImages(false);
}
};

View File

@ -0,0 +1,171 @@
/**
* Drag and Drop Handlers for ImageWizard
* Handles file drag-and-drop functionality with stable state management
*/
import { toast } from "sonner";
import { translate } from "@/i18n";
import { ImageFile } from "../types";
import { uploadInternalVideo } from '@/utils/uploadUtils';
/**
* Handle drag enter event
* Clears any pending leave timeout and shows drag overlay for files
*/
export const handleDragEnter = (
e: React.DragEvent<HTMLDivElement>,
dragLeaveTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>,
setDragIn: (value: boolean) => void
) => {
e.preventDefault();
e.stopPropagation();
// Clear any pending leave timeout
if (dragLeaveTimeoutRef.current) {
clearTimeout(dragLeaveTimeoutRef.current);
dragLeaveTimeoutRef.current = null;
}
// Only show overlay if dragging files (not internal elements)
if (e.dataTransfer.types && e.dataTransfer.types.includes('Files')) {
setDragIn(true);
}
};
/**
* Handle drag over event
* Keeps overlay active during drag
*/
export const handleDragOver = (
e: React.DragEvent<HTMLDivElement>,
dragIn: boolean,
setDragIn: (value: boolean) => void
) => {
e.preventDefault();
e.stopPropagation();
// Keep overlay active
if (!dragIn && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) {
setDragIn(true);
}
};
/**
* Handle drag leave event
* Debounces the hide action to prevent flickering
*/
export const handleDragLeave = (
e: React.DragEvent<HTMLDivElement>,
dragLeaveTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>,
setDragIn: (value: boolean) => void
) => {
e.preventDefault();
e.stopPropagation();
// Debounce the leave - only hide after 100ms of no drag activity
if (dragLeaveTimeoutRef.current) {
clearTimeout(dragLeaveTimeoutRef.current);
}
dragLeaveTimeoutRef.current = setTimeout(() => {
setDragIn(false);
}, 100);
};
/**
* Handle drop event
* Processes dropped image/video files and adds them to the wizard
*/
export const handleDrop = async (
e: React.DragEvent<HTMLDivElement>,
dragLeaveTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>,
setDragIn: (value: boolean) => void,
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
user: any
) => {
e.preventDefault();
e.stopPropagation();
// Clear timeout and hide overlay immediately
if (dragLeaveTimeoutRef.current) {
clearTimeout(dragLeaveTimeoutRef.current);
dragLeaveTimeoutRef.current = null;
}
setDragIn(false);
const files = Array.from(e.dataTransfer.files).filter(file =>
file.type.startsWith('image/') || file.type.startsWith('video/')
);
if (files.length === 0) {
toast.error(translate('No valid image or video files dropped'));
return;
}
for (const file of files) {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (event) => {
const newImage: ImageFile = {
id: `dropped-${Date.now()}-${Math.random()}`,
file: file,
src: event.target?.result as string,
title: file.name,
selected: true, // Auto-select dropped images
type: 'image'
};
setImages(prev => [...prev.map(img => ({ ...img, selected: false })), newImage]);
};
reader.readAsDataURL(file);
} else if (file.type.startsWith('video/')) {
// Handle Video Drop
if (!user) {
toast.error(translate('Please sign in to upload dropped videos'));
continue;
}
const tempId = `dropped-video-${Date.now()}-${Math.random()}`;
const newVideo: ImageFile = {
id: tempId,
file: file,
src: '', // Placeholder
title: file.name,
selected: true,
type: 'video',
uploadStatus: 'uploading',
uploadProgress: 0
};
setImages(prev => [...prev.map(img => ({ ...img, selected: false })), newVideo]);
// Start Internal Upload Process
try {
await uploadInternalVideo(file, user.id, (progress) => {
setImages(prev => prev.map(img =>
img.id === tempId ? { ...img, uploadProgress: progress } : img
));
}).then(data => {
setImages(prev => prev.map(img =>
img.id === tempId ? {
...img,
id: data.dbId || tempId,
realDatabaseId: data.dbId,
uploadStatus: 'ready',
src: data.thumbnailUrl || '',
// aiText: JSON.stringify(data.meta || {})
} : img
));
toast.success(translate('Video uploaded successfully'));
});
} catch (err) {
console.error("Video drop upload failed", err);
setImages(prev => prev.map(img =>
img.id === tempId ? { ...img, uploadStatus: 'error' } : img
));
toast.error(translate('Failed to upload dropped video'));
}
}
}
toast.success(translate(`${files.length} file(s) added successfully!`));
};

View File

@ -0,0 +1,88 @@
import { ImageFile } from '../types';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { translate } from '@/i18n';
import { createImage, editImage } from '@/lib/image-router';
import { runTools, optimizePrompt } from '@/lib/openai';
import { PromptPreset } from '@/components/PresetManager';
import { Logger } from '../utils/logger';
/**
* Generation Handlers
* - Direct image generation
* - Agent mode generation
* - Voice agent generation
* - Prompt optimization
* - Lightbox image generation
*/
export const handleOptimizePrompt = async (
currentPrompt: string,
lightboxOpen: boolean,
setLightboxPrompt: React.Dispatch<React.SetStateAction<string>>,
setPrompt: React.Dispatch<React.SetStateAction<string>>,
setIsOptimizingPrompt: React.Dispatch<React.SetStateAction<boolean>>
) => {
if (!currentPrompt.trim()) {
toast.error(translate('Please enter a prompt first'));
return;
}
setIsOptimizingPrompt(true);
try {
toast.info(translate('Optimizing your prompt...'));
const optimized = await optimizePrompt(currentPrompt);
if (optimized) {
// Update the appropriate prompt field
if (lightboxOpen) {
setLightboxPrompt(optimized);
} else {
setPrompt(optimized);
}
toast.success(translate('Prompt optimized successfully!'));
} else {
toast.error(translate('Failed to optimize prompt. Please check your OpenAI API key.'));
}
} catch (error: any) {
console.error('Error optimizing prompt:', error);
toast.error(error.message || translate('Failed to optimize prompt'));
} finally {
setIsOptimizingPrompt(false);
}
};
export const buildFullPrompt = (selectedPreset: PromptPreset | null, prompt: string): string => {
if (selectedPreset) {
if (prompt.trim()) {
return `Context: ${selectedPreset.prompt}\n\nPrompt: ${prompt}`;
} else {
return selectedPreset.prompt;
}
}
return prompt;
};
export const abortGeneration = (
abortControllerRef: React.MutableRefObject<AbortController | null>,
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>,
setIsAgentMode: React.Dispatch<React.SetStateAction<boolean>>,
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
logger: Logger
) => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsGenerating(false);
setIsAgentMode(false);
// Remove any placeholder images when aborting
setImages(prev => prev.filter(img =>
!img.title.includes('Generating') &&
!img.title.includes('Agent working') &&
!img.title.includes('Voice Agent working')
));
toast.info('Generation aborted');
logger.info('Generation aborted by user');
}
};

View File

@ -0,0 +1,448 @@
import { ImageFile } from '../types';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { translate } from '@/i18n';
import { downloadImage, generateFilename } from '@/utils/downloadUtils';
/**
* Image Handlers
* - File upload
* - Image selection/deselection
* - Image removal/deletion
* - Download
* - Set as selected
*/
import { uploadInternalVideo } from '@/utils/uploadUtils';
// Helper to upload internal video
// Handled in uploadUtils
export const handleFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
user: any
) => {
const files = Array.from(event.target.files || []);
for (const file of files) {
const isVideo = file.type.startsWith('video/') || /\.(mp4|webm|mov|qt|m4v|avi|mkv)$/i.test(file.name);
if (isVideo) {
// Handle Video
if (!user) {
toast.error(translate('Please sign in to upload videos'));
continue;
}
const tempId = `upload-${Date.now()}-${Math.random()}`;
const newVideo: ImageFile = {
id: tempId,
file: file,
src: '', // Placeholder or thumbnail if generated
title: file.name,
selected: false,
type: 'video',
uploadStatus: 'uploading',
uploadProgress: 0
};
setImages(prev => [...prev, newVideo]);
// Start Internal Upload Process
try {
await uploadInternalVideo(file, user.id, (progress) => {
setImages(prev => prev.map(img =>
img.id === tempId ? { ...img, uploadProgress: progress } : img
));
}).then(data => {
setImages(prev => prev.map(img =>
img.id === tempId ? {
...img,
id: data.dbId || tempId, // Update ID to DB ID if possible, but keeping tempId is safer for React keys if index based
realDatabaseId: data.dbId,
uploadStatus: 'ready',
src: data.thumbnailUrl || '', // Use generated thumbnail
// aiText: JSON.stringify(data.meta || {}) // Don't store meta as description
} : img
));
toast.success(translate('Video uploaded successfully'));
});
} catch (err) {
console.error("Video upload failed", err);
setImages(prev => prev.map(img =>
img.id === tempId ? { ...img, uploadStatus: 'error' } : img
));
toast.error(translate('Failed to upload video'));
}
} else {
// Handle Image
const reader = new FileReader();
reader.onload = (e) => {
const newImage: ImageFile = {
id: `upload-${Date.now()}-${Math.random()}`,
file: file,
src: e.target?.result as string,
title: file.name,
selected: false,
type: 'image'
};
setImages(prev => [...prev, newImage]);
};
reader.readAsDataURL(file);
}
}
};
export const toggleImageSelection = (
imageId: string,
isMultiSelect: boolean,
fromGallery: boolean,
availableImages: ImageFile[],
images: ImageFile[],
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>
) => {
if (fromGallery) {
// Add from gallery to selected images
const galleryImage = availableImages.find(img => img.id === imageId);
if (galleryImage && !images.find(img => img.id === imageId)) {
setImages(prev => [...prev, { ...galleryImage, selected: true }]);
}
} else {
// Toggle selection in current images
setImages(prev => prev.map(img => {
if (img.id === imageId) {
return { ...img, selected: isMultiSelect ? !img.selected : true };
}
// For single select, deselect other images
if (!isMultiSelect) {
return { ...img, selected: false };
}
return img;
}));
}
};
export const removeImageRequest = (
imageId: string,
setImageToDelete: React.Dispatch<React.SetStateAction<string | null>>,
setShowDeleteConfirmDialog: React.Dispatch<React.SetStateAction<boolean>>
) => {
setImageToDelete(imageId);
setShowDeleteConfirmDialog(true);
};
export const confirmDeleteImage = async (
imageToDelete: string | null,
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
setImageToDelete: React.Dispatch<React.SetStateAction<string | null>>,
setShowDeleteConfirmDialog: React.Dispatch<React.SetStateAction<boolean>>,
initialImages: ImageFile[],
loadFamilyVersions: (images: ImageFile[]) => Promise<void>
) => {
if (!imageToDelete) return;
// Remove from local state immediately for better UX
setImages(prev => prev.filter(img => img.id !== imageToDelete));
// Check if this image exists in the database and delete it
try {
const { data: picture, error: fetchError } = await supabase
.from('pictures')
.select('id, image_url, user_id, parent_id')
.eq('id', imageToDelete)
.single();
if (!fetchError && picture) {
// Check if user owns this image
const { data: { user } } = await supabase.auth.getUser();
if (user && picture.user_id === user.id) {
let isRootWithChildren = false;
// If this is a root image (parent_id is null), check for children
if (!picture.parent_id) {
// Check if this root image has any children
const { data: children, error: childrenError } = await supabase
.from('pictures')
.select('id')
.eq('parent_id', imageToDelete);
if (!childrenError && children && children.length > 0) {
// This is a root image with children - delete entire family
console.log('Deleting root image with children - removing entire family');
isRootWithChildren = true;
// Delete all children first
const { error: deleteChildrenError } = await supabase
.from('pictures')
.delete()
.eq('parent_id', imageToDelete);
if (deleteChildrenError) {
console.error('Error deleting children:', deleteChildrenError);
toast.error(translate('Failed to delete image family'));
return;
}
}
}
// Delete the main image from database
const { error: deleteError } = await supabase
.from('pictures')
.delete()
.eq('id', imageToDelete);
if (deleteError) {
console.error('Error deleting picture from database:', deleteError);
toast.error(translate('Failed to delete image from database'));
} else {
// Try to delete from storage as well
if (picture.image_url) {
const urlParts = picture.image_url.split('/');
const fileName = urlParts[urlParts.length - 1];
const userIdFromUrl = urlParts[urlParts.length - 2];
const { error: storageError } = await supabase.storage
.from('pictures')
.remove([`${userIdFromUrl}/${fileName}`]);
if (storageError) {
console.error('Error deleting from storage:', storageError);
// Don't show error to user as the main deletion succeeded
}
}
toast.success(translate(isRootWithChildren ? 'Image family deleted successfully' : 'Image deleted successfully'));
// If we deleted a root image, remove all related images from local state
if (!picture.parent_id) {
setImages(prev => prev.filter(img =>
img.id !== imageToDelete &&
img.realDatabaseId !== imageToDelete
));
// Reload family versions to refresh the UI
if (initialImages.length > 0) {
setTimeout(() => loadFamilyVersions(initialImages), 500);
}
}
}
}
}
} catch (error) {
console.error('Error during image deletion:', error);
// Don't show error if it's just not found in database (local image)
} finally {
setImageToDelete(null);
setShowDeleteConfirmDialog(false);
}
};
export const setAsSelected = async (
imageId: string,
images: ImageFile[],
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
initialImages: ImageFile[],
loadFamilyVersions: (images: ImageFile[]) => Promise<void>
) => {
try {
// Check if this is a valid UUID (database image)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isSaved = uuidRegex.test(imageId);
// Get the current user
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
toast.error(translate('You must be logged in to set version as selected'));
return;
}
// Handle Unsaved Generated Images
if (!isSaved) {
const img = images.find(i => i.id === imageId);
if (!img) return;
toast.loading(translate('Saving and selecting version...'));
try {
// 1. Upload to Storage
const blob = await (await fetch(img.src)).blob();
const fileName = `${user.id}/${Date.now()}_generated.png`;
const { error: uploadError } = await supabase.storage.from('pictures').upload(fileName, blob);
if (uploadError) throw uploadError;
const { data: { publicUrl } } = supabase.storage.from('pictures').getPublicUrl(fileName);
// 2. Unselect previous selected in family (if parent exists)
const parentId = img.parentForNewVersions || null;
if (parentId) {
await supabase
.from('pictures')
.update({ is_selected: false })
.or(`id.eq.${parentId},parent_id.eq.${parentId}`)
.eq('user_id', user.id);
}
// 3. Insert new picture as selected
const { data: newPic, error: insertError } = await supabase
.from('pictures')
.insert({
user_id: user.id,
image_url: publicUrl,
parent_id: parentId,
is_selected: true,
title: img.title,
description: img.aiText
})
.select()
.single();
if (insertError) throw insertError;
toast.dismiss();
toast.success(translate('Version saved and selected'));
// 4. Update local state
setImages(prev => {
return prev.map(item => {
// Replace the temp image with the saved one
if (item.id === imageId) {
return {
...item,
id: newPic.id,
realDatabaseId: newPic.id,
selected: true, // Keep it selected in UI as we just acted on it
isPreferred: true // Mark as preferred in DB context
};
}
return {
...item,
selected: false,
isPreferred: item.isPreferred // Don't change preference for unrelated images?
// Wait, if it's a new family (new generated image), usually the older one wasn't preferred?
// Logic below handles unselecting previous family members.
};
}).map(item => {
// For newly generated images, if we find a parent, we need to un-prefer it?
// The logic at line 280 handled DB update. We need local update.
if (img.parentForNewVersions && (item.id === img.parentForNewVersions || item.realDatabaseId === img.parentForNewVersions)) {
return { ...item, isPreferred: false };
}
return item;
});
});
// Refresh family
if (initialImages.length > 0) {
setTimeout(() => loadFamilyVersions(initialImages), 500);
}
return;
} catch (err) {
toast.dismiss();
console.error('Error saving generated image:', err);
toast.error(translate('Failed to save generated image'));
return;
}
}
// Get the image and its parent/child relationships
const { data: targetImage, error: fetchError } = await supabase
.from('pictures')
.select('id, parent_id, user_id')
.eq('id', imageId)
.single();
if (fetchError) {
console.error('Error fetching image:', fetchError);
toast.error(translate('Failed to find image'));
return;
}
// Check if user owns this image
if (targetImage.user_id !== user.id) {
toast.error(translate('Can only select your own images'));
return;
}
// Find all related images (same parent tree)
const parentId = targetImage.parent_id || imageId;
// First, unselect all images in the same family
const { error: unselectError } = await supabase
.from('pictures')
.update({ is_selected: false })
.or(`id.eq.${parentId},parent_id.eq.${parentId}`)
.eq('user_id', user.id);
if (unselectError) {
console.error('Error unselecting images:', unselectError);
toast.error(translate('Failed to update selection'));
return;
}
// Then select the target image
const { error: selectError } = await supabase
.from('pictures')
.update({ is_selected: true })
.eq('id', imageId);
if (selectError) {
console.error('Error selecting image:', selectError);
toast.error(translate('Failed to set as selected'));
return;
}
toast.success(translate('Version set as selected'));
setImages(prev => {
const updated = prev.map(img => {
// If this is the image we just selected, mark it as preferred
if (img.id === imageId) {
console.log('🔧 [setAsSelected] Setting as preferred:', img.title);
return { ...img, isPreferred: true }; // Don't enforce UI selection here if not needed, but usually we engage with it.
}
// Find the selected image to determine family relationships
const selectedImg = prev.find(i => i.id === imageId);
const selectedRootId = selectedImg?.realDatabaseId || selectedImg?.id;
const currentRootId = img.realDatabaseId || img.id;
// If this image is in the same family, un-prefer it
if (selectedRootId && currentRootId && selectedRootId === currentRootId && img.id !== imageId) {
console.log('🔧 [setAsSelected] Unsetting preference for family member:', img.title);
return { ...img, isPreferred: false };
}
return img;
});
console.log('🔧 [setAsSelected] Images after update:', updated.map(img => ({ id: img.id, title: img.title, selected: img.selected })));
return updated;
});
// Refresh the images to reflect the change from database
if (initialImages.length > 0) {
setTimeout(() => loadFamilyVersions(initialImages), 500);
}
} catch (error) {
console.error('Error setting as selected:', error);
toast.error(translate('Failed to set as selected'));
}
};
export const handleDownloadImage = async (image: ImageFile) => {
try {
const filename = generateFilename(image.title);
await downloadImage(image.src, filename);
toast.success(translate('Image downloaded successfully'));
} catch (error) {
console.error('Error downloading image:', error);
toast.error(translate('Failed to download image'));
}
};

View File

@ -0,0 +1,10 @@
// Centralized exports for all wizard handlers
export * from './imageHandlers';
export * from './generationHandlers';
export * from './publishHandlers';
export * from './dataHandlers';
export * from './settingsHandlers';
export * from './voiceHandlers';
export * from './agentHandlers';
export * from './promptHandlers';
export * from './dropHandlers';

View File

@ -0,0 +1,227 @@
/**
* Prompt Handlers for ImageWizard
* Handles prompt splitting, processing, and related operations
*/
import { toast } from "sonner";
import { translate } from "@/i18n";
import { splitPromptByLines, MAX_SPLIT_PROMPTS } from "@/constants";
import { ImageFile } from "../types";
import { createImage, editImage } from "@/lib/image-router";
import { Logger } from "../utils/logger";
/**
* Generate images from split prompts (sequential generation)
* Each line in the prompt becomes a separate generation step
* Each generated image feeds into the next step as input
*/
export const generateImageSplit = async (
prompt: string,
images: ImageFile[],
selectedModel: string,
abortControllerRef: React.MutableRefObject<AbortController | null>,
setIsGenerating: (value: boolean) => void,
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
addToPromptHistory: (prompt: string) => Promise<void>,
logger: Logger
) => {
// Use the raw prompt directly, not the preset-combined version
const rawPrompt = prompt.trim();
if (!rawPrompt) {
toast.error(translate('Please enter a prompt'));
return;
}
// Split the raw prompt by lines
const promptLines = splitPromptByLines(rawPrompt, MAX_SPLIT_PROMPTS);
logger.debug(`Raw prompt: "${rawPrompt.substring(0, 100)}..."`);
logger.debug(`Split into ${promptLines.length} lines`);
if (promptLines.length === 0) {
toast.error(translate('No valid prompts found'));
return;
}
if (promptLines.length === 1) {
toast.warning(translate('Only one line detected. Add more lines or turn off Split mode.'));
}
if (promptLines.length > MAX_SPLIT_PROMPTS) {
toast.warning(translate(`Only the first ${MAX_SPLIT_PROMPTS} prompts will be processed`));
}
// Add to history before generating
if (rawPrompt) {
await addToPromptHistory(rawPrompt);
}
// Create new abort controller for split generation
abortControllerRef.current = new AbortController();
setIsGenerating(true);
logger.info(`Starting split generation with ${promptLines.length} prompts`);
toast.info(`Processing ${promptLines.length} prompts sequentially...`);
try {
// Check if there are selected images to use as starting point
const selectedImages = images.filter(img => img.selected);
// Track the previous image data URL directly (simpler than state lookups)
let previousImageDataUrl: string | null = null;
// If we have selected images, use the first one as the starting point
if (selectedImages.length > 0) {
previousImageDataUrl = selectedImages[0].src;
logger.info(`Using ${selectedImages.length} selected image(s) as starting point`);
toast.info(`Starting from selected image...`);
}
for (let i = 0; i < promptLines.length; i++) {
// Check if aborted before each step
if (abortControllerRef.current?.signal.aborted) {
logger.info(`Split generation aborted at step ${i + 1}`);
toast.info(`Split generation cancelled at step ${i + 1}/${promptLines.length}`);
break;
}
const currentPrompt = promptLines[i];
logger.info(`[${i + 1}/${promptLines.length}] Generating: "${currentPrompt.substring(0, 50)}..."`);
// Create placeholder with current prompt instruction
const placeholderId = `placeholder-split-${Date.now()}-${i}`;
const truncatedPrompt = currentPrompt.length > 60
? currentPrompt.substring(0, 60) + '...'
: currentPrompt;
const placeholderImage: ImageFile = {
id: placeholderId,
src: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iI2YwZjBmMCIvPjwvc3ZnPg==',
title: `Generating ${i + 1}/${promptLines.length}: ${truncatedPrompt}`,
selected: false,
isGenerated: true,
};
setImages(prev => [...prev.map(img => ({ ...img, selected: false })), placeholderImage]);
let result: { imageData: ArrayBuffer; text?: string } | null = null;
// If we have a previous image (either from selection or previous generation), use it as input
if (previousImageDataUrl) {
logger.debug(`Using previous image as input for step ${i + 1}`);
// Convert previous image data URL to File
try {
const response = await fetch(previousImageDataUrl);
const blob = await response.blob();
const file = new File([blob], `step-${i}.png`, {
type: 'image/png'
});
// If we have multiple selected images and this is the first iteration, use all of them
if (i === 0 && selectedImages.length > 1) {
logger.debug(`Using all ${selectedImages.length} selected images for first step`);
const files: File[] = [];
for (const selectedImg of selectedImages) {
try {
const resp = await fetch(selectedImg.src);
const imgBlob = await resp.blob();
const imgFile = new File([imgBlob], selectedImg.title || 'image.png', {
type: imgBlob.type || 'image/png'
});
files.push(imgFile);
} catch (error) {
console.error('Error converting selected image:', error);
}
}
result = await editImage(currentPrompt, files, selectedModel);
} else {
// Single image or subsequent iterations
result = await editImage(currentPrompt, [file], selectedModel);
}
} catch (error) {
console.error('Error converting previous image:', error);
logger.error(`Failed to convert previous image for prompt ${i + 1}`);
toast.error(translate(`Failed at step ${i + 1}: Could not process previous image`));
// Remove placeholder on failure
setImages(prev => prev.filter(img => img.id !== placeholderId));
break;
}
} else {
// First image with no selection - generate from text
logger.debug(`Generating first image from text`);
result = await createImage(currentPrompt, selectedModel);
}
if (result) {
logger.debug(`Successfully generated image for step ${i + 1}, converting to data URL`);
// Convert ArrayBuffer to base64 data URL
const uint8Array = new Uint8Array(result.imageData);
const blob = new Blob([uint8Array], { type: 'image/png' });
const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
const newImage: ImageFile = {
id: `split-generated-${Date.now()}-${i}`,
src: dataUrl,
title: `[${i + 1}/${promptLines.length}] ${currentPrompt.substring(0, 40)}${currentPrompt.length > 40 ? '...' : ''}`,
selected: i === promptLines.length - 1, // Only select the last one
isGenerated: true,
aiText: result.text,
};
// Replace placeholder with actual image
setImages(prev => {
const withoutPlaceholder = prev.filter(img => img.id !== placeholderId);
return [...withoutPlaceholder, newImage];
});
// Store this image data URL for next iteration
previousImageDataUrl = dataUrl;
logger.success(`[${i + 1}/${promptLines.length}] Generated successfully, ready for next step`);
} else {
// Remove placeholder on failure
setImages(prev => prev.filter(img => img.id !== placeholderId));
logger.error(`Failed to generate image for prompt ${i + 1} - result was null`);
toast.error(translate(`Failed at step ${i + 1}`));
break;
}
// Small delay between generations to allow UI to update
if (i < promptLines.length - 1) {
logger.debug(`Waiting before next step...`);
await new Promise(resolve => setTimeout(resolve, 500));
// Check again after delay
if (abortControllerRef.current?.signal.aborted) {
logger.info(`Split generation aborted after step ${i + 1}`);
toast.info(`Split generation cancelled after step ${i + 1}/${promptLines.length}`);
break;
}
}
}
// Only show success if not aborted
if (!abortControllerRef.current?.signal.aborted) {
toast.success(translate(`Split generation completed! Generated ${promptLines.length} images.`));
logger.success(`Split generation completed: ${promptLines.length} images`);
}
} catch (error: any) {
// Don't show error if it was just aborted
if (abortControllerRef.current?.signal.aborted) {
logger.info('Split generation aborted by user');
} else {
console.error('Error in split generation:', error);
logger.error(`Split generation failed: ${error.message}`);
toast.error(translate(`Split generation failed: ${error.message}`));
}
} finally {
setIsGenerating(false);
// Clean up abort controller
abortControllerRef.current = null;
}
};

View File

@ -0,0 +1,532 @@
import { ImageFile } from '../types';
import { uploadImage } from '@/lib/uploadUtils';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { translate } from '@/i18n';
/**
* Publishing Handlers
* - Publish image (Create Post with multiple images)
* - Quick publish (Create Post with single image)
* - Lightbox publish
*/
interface PublishImageOptions {
user: any;
generatedImage: string | null;
images: ImageFile[];
lightboxOpen: boolean;
currentImageIndex: number;
postTitle: string;
postDescription?: string;
prompt: string;
isOrgContext: boolean;
orgSlug: string | null;
onPublish?: (imageUrl: string, prompt: string) => void;
publishAll?: boolean;
editingPostId?: string;
settings?: any;
meta?: any;
}
export const publishImage = async (
options: PublishImageOptions,
setIsPublishing: React.Dispatch<React.SetStateAction<boolean>>
) => {
const { user, generatedImage, images, postTitle, postDescription, prompt, onPublish, publishAll, editingPostId, settings, meta } = options;
if (!user) {
toast.error(translate('User not authenticated'));
return;
}
// Determine which images to publish
let imagesToPublish = images;
if (!publishAll) {
const selectedImages = images.filter(img => img.selected);
if (selectedImages.length > 0) {
imagesToPublish = selectedImages;
}
}
if (imagesToPublish.length === 0) {
toast.error(translate('No images available to publish'));
return;
}
setIsPublishing(true);
try {
let postId = editingPostId;
// 1. Create or Update Post container
const title = postTitle.trim() || (imagesToPublish[0].title || 'Untitled Post');
const description = postDescription || prompt.trim() || null;
if (editingPostId) {
// Update existing post
const { error: updateError } = await supabase
.from('posts')
.update({
title: title,
description: description,
settings: settings || undefined,
meta: meta || undefined,
updated_at: new Date().toISOString()
})
.eq('id', editingPostId);
if (updateError) throw updateError;
} else {
// Create new post
const { data: postData, error: postError } = await supabase
.from('posts')
.insert({
user_id: user.id,
title: title,
description: description,
settings: settings || { visibility: 'public' },
meta: meta || {},
})
.select()
.single();
if (postError) throw postError;
if (!postData) throw new Error('Failed to create post');
postId = postData.id;
}
if (!postId) throw new Error('No post ID available');
// 2. Process images (Upload new, Link existing)
let successfulOps = 0;
for (let i = 0; i < imagesToPublish.length; i++) {
const img = imagesToPublish[i];
// If image already exists in DB (has realDatabaseId), just update its metadata/position
if (img.realDatabaseId) {
const { error: updateImgError } = await supabase
.from('pictures')
.update({
title: img.title || title,
description: img.description || img.aiText || null,
position: i,
post_id: postId // Ensure it's linked to this post (re-linking if moved)
})
.eq('id', img.realDatabaseId);
if (updateImgError) {
console.error(`Failed to update image ${i}:`, updateImgError);
} else {
successfulOps++;
}
continue;
}
// New Media Upload Logic
let file: File;
let isVideo = img.type === 'video';
if (isVideo) {
if (img.uploadStatus !== 'ready' || !img.muxAssetId) {
console.error(`Video ${i} is not ready or missing Mux ID`);
toast.error(translate(`Video "${img.title}" is not ready yet`));
continue;
}
// Insert Mux Video
const metadata = {
mux_upload_id: img.muxUploadId,
mux_asset_id: img.muxAssetId,
mux_playback_id: img.muxPlaybackId,
// duration, aspect_ratio etc would need to be fetched or assumed available later via webhook/polling
// For now we store minimal required meta
created_at: new Date().toISOString(),
status: 'ready'
};
const { error: insertError } = await supabase
.from('pictures')
.insert({
user_id: user.id,
post_id: postId,
title: img.title || title,
description: img.description || img.aiText || null,
image_url: `https://stream.mux.com/${img.muxPlaybackId}.m3u8`, // HLS URL
thumbnail_url: `https://image.mux.com/${img.muxPlaybackId}/thumbnail.jpg`,
position: i,
type: 'mux-video',
meta: metadata
});
if (insertError) {
console.error(`Failed to link video ${i} to post:`, insertError);
} else {
successfulOps++;
}
continue; // Done with video
}
// External Page Logic (Link Post)
if (img.type === 'page-external') {
const siteInfo = img.meta || {};
// Ensure we have the link URL if missing in meta but present in path
if (!siteInfo.url && img.path) {
siteInfo.url = img.path;
}
const { error: insertError } = await supabase
.from('pictures')
.insert({
user_id: user.id,
post_id: postId,
title: img.title || title,
description: img.description || null,
image_url: img.src, // Use external cover image URL
position: i,
type: 'page-external',
meta: siteInfo // Store full site info in picture.meta
});
if (insertError) {
console.error("Failed to insert page-external picture:", insertError);
toast.error(translate("Failed to save link"));
} else {
successfulOps++;
}
continue;
}
// Image Logic
if (img.file) {
file = img.file;
} else {
try {
const response = await fetch(img.src);
const blob = await response.blob();
file = new File([blob], `image-${Date.now()}-${i}.png`, { type: blob.type || 'image/png' });
} catch (err) {
console.error(`Failed to process image ${i}:`, err);
continue;
}
}
// Upload to Supabase Storage (direct or via proxy)
const { publicUrl, meta: uploadedMeta } = await uploadImage(file, user.id);
const { error: insertError } = await supabase
.from('pictures')
.insert({
user_id: user.id,
post_id: postId,
title: img.title || title,
description: img.description || img.aiText || null,
image_url: publicUrl,
position: i,
type: 'supabase-image',
meta: uploadedMeta || {}
});
if (insertError) {
console.error(`Failed to link image ${i} to post:`, insertError);
} else {
successfulOps++;
}
}
if (successfulOps === 0) {
throw new Error('Failed to process any images');
}
toast.success(translate(editingPostId ? 'Post updated successfully!' : 'Post published successfully!'));
if (imagesToPublish.length > 0) {
// Pass the postId as the second argument (repurposing prompt likely, or we should update signature)
// Actually, let's keep the signature but pass the ID.
// Wait, onPublish signature is (imageUrl: string, prompt: string) => void
// We should probably change the signature or standard usage.
// Given the request, let's pass postId as the second arg if mode is post.
onPublish?.(imagesToPublish[0].src, postId);
}
} catch (error) {
console.error('Error publishing/updating post:', error);
toast.error(translate('Failed to save post'));
} finally {
setIsPublishing(false);
}
};
export const quickPublishAsNew = async (
options: PublishImageOptions,
setIsPublishing: React.Dispatch<React.SetStateAction<boolean>>
) => {
const { user, generatedImage, images, lightboxOpen, currentImageIndex, postTitle, prompt, isOrgContext, orgSlug, onPublish } = options;
if (!user) {
toast.error(translate('User not authenticated'));
return;
}
if (!prompt.trim()) {
toast.error(translate('Please enter a prompt to use as description'));
return;
}
// Determine which SINGLE image to publish for Quick Publish
let imageToPublish = null;
let imageFile = null;
let imageTitle = postTitle.trim() || '';
// Priority 1: Generated image
if (generatedImage) {
imageToPublish = generatedImage;
imageTitle = imageTitle || prompt.substring(0, 50);
}
// Priority 2: Currently displayed image in lightbox
else if (lightboxOpen && images.length > 0 && currentImageIndex < images.length) {
const currentImage = images[currentImageIndex];
imageToPublish = currentImage.src;
imageFile = currentImage.file;
imageTitle = imageTitle || currentImage.title || '';
}
// Priority 3: First uploaded image
else if (images.length > 0) {
const firstImage = images[0];
imageToPublish = firstImage.src;
imageFile = firstImage.file;
imageTitle = imageTitle || firstImage.title || '';
}
if (!imageToPublish) {
toast.error(translate('No image available to publish'));
return;
}
setIsPublishing(true);
try {
// 1. Create Post
const { data: postData, error: postError } = await supabase
.from('posts')
.insert({
user_id: user.id,
title: imageTitle || 'Quick Publish',
description: prompt.trim(), // Use prompt as description
})
.select()
.single();
if (postError) throw postError;
const postId = postData.id;
let file;
// If you have the file, use it
if (imageFile) {
file = imageFile;
} else {
// Convert data URL to blob
const response = await fetch(imageToPublish);
const blob = await response.blob();
file = new File([blob], `quick-publish-${Date.now()}.png`, { type: 'image/png' });
}
// Upload to Supabase Storage (direct or via proxy)
const { publicUrl, meta: uploadedMeta } = await uploadImage(file, user.id);
// Get organization ID if in org context
let organizationId = null;
if (isOrgContext && orgSlug) {
const { data: org } = await supabase
.from('organizations')
.select('id')
.eq('slug', orgSlug)
.single();
organizationId = org?.id || null;
}
// Insert into pictures linked to Post
const { error: insertError } = await supabase
.from('pictures')
.insert({
user_id: user.id,
post_id: postId, // Link to Post
title: imageTitle || 'Quick Publish',
description: prompt.trim(),
image_url: publicUrl,
organization_id: organizationId,
position: 0,
type: 'supabase-image',
meta: uploadedMeta || {}
});
if (insertError) throw insertError;
toast.success(translate('Image quick published as new post!'));
onPublish?.(publicUrl, prompt);
} catch (error) {
console.error('Error quick publishing image:', error);
toast.error(translate('Failed to quick publish image'));
} finally {
setIsPublishing(false);
}
};
export const publishToGallery = async (
options: PublishImageOptions,
setIsPublishing: React.Dispatch<React.SetStateAction<boolean>>
) => {
const { user, images, onPublish, isOrgContext, orgSlug } = options;
if (!user) {
toast.error(translate('User not authenticated'));
return;
}
const selectedImages = images.filter(img => img.selected);
const imagesToPublish = selectedImages.length > 0 ? selectedImages : images;
if (imagesToPublish.length === 0) {
toast.error(translate('No images available to publish'));
return;
}
setIsPublishing(true);
try {
let successfulOps = 0;
for (let i = 0; i < imagesToPublish.length; i++) {
const img = imagesToPublish[i];
// Skip existing database images that are already saved
if (img.realDatabaseId) {
successfulOps++;
continue;
}
// New Media Upload Logic
let file: File;
let isVideo = img.type === 'video';
if (isVideo) {
if (img.uploadStatus !== 'ready' || !img.muxAssetId) {
console.error(`Video ${i} is not ready or missing Mux ID`);
toast.error(translate(`Video "${img.title}" is not ready yet`));
continue;
}
// Insert Mux Video
const metadata = {
mux_upload_id: img.muxUploadId,
mux_asset_id: img.muxAssetId,
mux_playback_id: img.muxPlaybackId,
created_at: new Date().toISOString(),
status: 'ready'
};
const { error: insertError } = await supabase
.from('pictures')
.insert({
user_id: user.id,
post_id: null, // No post ID
title: img.title || 'Untitled Video',
description: img.description || img.aiText || null,
image_url: `https://stream.mux.com/${img.muxPlaybackId}.m3u8`,
thumbnail_url: `https://image.mux.com/${img.muxPlaybackId}/thumbnail.jpg`,
position: 0, // No specific position in gallery
type: 'mux-video',
meta: metadata
});
if (insertError) {
console.error(`Failed to save video ${i}:`, insertError);
} else {
successfulOps++;
}
continue;
}
// External Page Logic
if (img.type === 'page-external') {
const siteInfo = img.meta || {};
if (!siteInfo.url && img.path) {
siteInfo.url = img.path;
}
const { error: insertError } = await supabase
.from('pictures')
.insert({
user_id: user.id,
post_id: null,
title: img.title || 'Untitled Link',
description: img.description || null,
image_url: img.src,
position: 0,
type: 'page-external',
meta: siteInfo
});
if (insertError) {
console.error("Failed to save page-external picture:", insertError);
} else {
successfulOps++;
}
continue;
}
// Image Logic
if (img.file) {
file = img.file;
} else {
try {
const response = await fetch(img.src);
const blob = await response.blob();
file = new File([blob], `image-${Date.now()}-${i}.png`, { type: blob.type || 'image/png' });
} catch (err) {
console.error(`Failed to process image ${i}:`, err);
continue;
}
}
// Upload to Supabase Storage
const { publicUrl, meta: uploadedMeta } = await uploadImage(file, user.id);
const { error: insertError } = await supabase
.from('pictures')
.insert({
user_id: user.id,
post_id: null,
title: img.title || 'Untitled Image',
description: img.description || img.aiText || null,
image_url: publicUrl,
position: 0,
type: 'supabase-image',
meta: uploadedMeta || {}
});
if (insertError) {
console.error(`Failed to save image ${i}:`, insertError);
} else {
successfulOps++;
}
}
if (successfulOps === 0) {
throw new Error('Failed to process any images');
}
toast.success(translate('Saved to gallery successfully!'));
// Just call onPublish with first image to signal completion
if (imagesToPublish.length > 0) {
onPublish?.(imagesToPublish[0].src, '');
}
} catch (error) {
console.error('Error publishing to gallery:', error);
toast.error(translate('Failed to save to gallery'));
} finally {
setIsPublishing(false);
}
};

View File

@ -0,0 +1,550 @@
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { translate } from '@/i18n';
import { QuickAction } from '@/constants';
import { PromptPreset } from '@/components/PresetManager';
import { Workflow } from '@/components/WorkflowManager';
/**
* Settings Handlers
* - Load/save prompt templates
* - Load/save prompt presets
* - Load/save workflows
* - Load/save quick actions
* - Load/save prompt history
* - Prompt history navigation
*/
export const loadPromptTemplates = async (
userId: string,
setPromptTemplates: React.Dispatch<React.SetStateAction<Array<{name: string; template: string}>>>,
setLoadingTemplates: React.Dispatch<React.SetStateAction<boolean>>
) => {
setLoadingTemplates(true);
try {
const { data: profile, error } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (error && error.code !== 'PGRST116') throw error;
const settings = profile?.settings as any;
const templates = settings?.promptTemplates || [];
setPromptTemplates(templates);
} catch (error) {
console.error('Error loading prompt templates:', error);
} finally {
setLoadingTemplates(false);
}
};
export const savePromptTemplates = async (
userId: string,
templates: Array<{name: string; template: string}>
) => {
try {
const { data: profile, error: fetchError } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (fetchError && fetchError.code !== 'PGRST116') throw fetchError;
const currentSettings = (profile?.settings as any) || {};
const newSettings = {
...currentSettings,
promptTemplates: templates
};
const { error: updateError } = await supabase
.from('profiles')
.update({ settings: newSettings as any })
.eq('user_id', userId);
if (updateError) throw updateError;
toast.success(translate('Templates saved successfully!'));
} catch (error) {
console.error('Error saving prompt templates:', error);
toast.error(translate('Failed to save templates'));
throw error;
}
};
export const loadPromptPresets = async (
userId: string,
setPromptPresets: React.Dispatch<React.SetStateAction<PromptPreset[]>>,
setLoadingPresets: React.Dispatch<React.SetStateAction<boolean>>
) => {
setLoadingPresets(true);
try {
const { data: profile, error } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (error && error.code !== 'PGRST116') throw error;
const settings = profile?.settings as any;
const presets = settings?.promptPresets || [];
setPromptPresets(presets);
} catch (error) {
console.error('Error loading prompt presets:', error);
} finally {
setLoadingPresets(false);
}
};
export const loadWorkflows = async (
userId: string,
setWorkflows: React.Dispatch<React.SetStateAction<Workflow[]>>,
setLoadingWorkflows: React.Dispatch<React.SetStateAction<boolean>>
) => {
setLoadingWorkflows(true);
try {
const { data: profile, error } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (error && error.code !== 'PGRST116') throw error;
const settings = profile?.settings as any;
const workflows = settings?.workflows || [];
setWorkflows(workflows);
} catch (error) {
console.error('Error loading workflows:', error);
} finally {
setLoadingWorkflows(false);
}
};
export const loadQuickActions = async (
userId: string,
setQuickActions: React.Dispatch<React.SetStateAction<QuickAction[]>>,
setLoadingActions: React.Dispatch<React.SetStateAction<boolean>>,
DEFAULT_QUICK_ACTIONS: QuickAction[]
) => {
setLoadingActions(true);
try {
const { data: profile, error } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (error && error.code !== 'PGRST116') throw error;
const settings = profile?.settings as any;
const actions = settings?.quickActions || DEFAULT_QUICK_ACTIONS;
setQuickActions(actions);
} catch (error) {
console.error('Error loading quick actions:', error);
setQuickActions(DEFAULT_QUICK_ACTIONS);
} finally {
setLoadingActions(false);
}
};
export const saveQuickActions = async (
userId: string,
actions: QuickAction[]
) => {
try {
const { data: profile, error: fetchError } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (fetchError && fetchError.code !== 'PGRST116') throw fetchError;
const currentSettings = (profile?.settings as any) || {};
const { error: updateError } = await supabase
.from('profiles')
.update({
settings: {
...currentSettings,
quickActions: actions
} as any
})
.eq('user_id', userId);
if (updateError) throw updateError;
toast.success('Quick Actions saved');
} catch (error) {
console.error('Error saving quick actions:', error);
toast.error('Failed to save quick actions');
throw error;
}
};
export const loadPromptHistory = async (
userId: string,
setPromptHistory: React.Dispatch<React.SetStateAction<string[]>>
) => {
try {
const { data: profile, error } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (error && error.code !== 'PGRST116') throw error;
const settings = profile?.settings as any;
const history = settings?.promptHistory || [];
setPromptHistory(history);
} catch (error) {
console.error('Error loading prompt history:', error);
}
};
export const addToPromptHistory = async (
userId: string,
promptText: string,
setPromptHistory: React.Dispatch<React.SetStateAction<string[]>>,
setHistoryIndex: React.Dispatch<React.SetStateAction<number>>
) => {
if (!promptText.trim()) return;
try {
const { data: profile, error: fetchError } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (fetchError && fetchError.code !== 'PGRST116') throw fetchError;
const currentSettings = (profile?.settings as any) || {};
const currentHistory = currentSettings.promptHistory || [];
// Add to history, avoid duplicates, limit to 50
const newHistory = [promptText, ...currentHistory.filter((h: string) => h !== promptText)].slice(0, 50);
const { error: updateError } = await supabase
.from('profiles')
.update({
settings: {
...currentSettings,
promptHistory: newHistory
} as any
})
.eq('user_id', userId);
if (updateError) throw updateError;
setPromptHistory(newHistory);
setHistoryIndex(-1); // Reset to latest
} catch (error) {
console.error('Error saving prompt history:', error);
}
};
export const navigatePromptHistory = (
direction: 'up' | 'down',
promptHistory: string[],
historyIndex: number,
setHistoryIndex: React.Dispatch<React.SetStateAction<number>>,
setPrompt: React.Dispatch<React.SetStateAction<string>>
) => {
if (promptHistory.length === 0) return;
let newIndex = historyIndex;
if (direction === 'up') {
// Go back in history
newIndex = Math.min(historyIndex + 1, promptHistory.length - 1);
} else {
// Go forward in history
newIndex = Math.max(historyIndex - 1, -1);
}
setHistoryIndex(newIndex);
if (newIndex === -1) {
setPrompt('');
} else {
setPrompt(promptHistory[newIndex]);
}
};
// ============================================================================
// PRESET CRUD OPERATIONS
// ============================================================================
export const savePromptPreset = async (
userId: string,
preset: Omit<PromptPreset, 'id' | 'createdAt'>,
setPromptPresets: React.Dispatch<React.SetStateAction<PromptPreset[]>>
) => {
try {
const { data: profile, error: fetchError } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (fetchError && fetchError.code !== 'PGRST116') throw fetchError;
const settings = (profile?.settings as any) || {};
const existingPresets = settings.promptPresets || [];
const newPreset: PromptPreset = {
id: `preset_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: preset.name,
prompt: preset.prompt,
createdAt: new Date().toISOString(),
};
const updatedPresets = [...existingPresets, newPreset];
const { error: updateError } = await supabase
.from('profiles')
.update({
settings: {
...settings,
promptPresets: updatedPresets,
},
})
.eq('user_id', userId);
if (updateError) throw updateError;
setPromptPresets(updatedPresets);
toast.success(translate('Preset saved successfully!'));
} catch (error) {
console.error('Error saving prompt preset:', error);
toast.error(translate('Failed to save preset'));
throw error;
}
};
export const updatePromptPreset = async (
userId: string,
id: string,
preset: Omit<PromptPreset, 'id' | 'createdAt'>,
setPromptPresets: React.Dispatch<React.SetStateAction<PromptPreset[]>>
) => {
try {
const { data: profile, error: fetchError } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (fetchError) throw fetchError;
const settings = (profile?.settings as any) || {};
const existingPresets = settings.promptPresets || [];
const updatedPresets = existingPresets.map((p: PromptPreset) =>
p.id === id
? { ...p, name: preset.name, prompt: preset.prompt }
: p
);
const { error: updateError } = await supabase
.from('profiles')
.update({
settings: {
...settings,
promptPresets: updatedPresets,
},
})
.eq('user_id', userId);
if (updateError) throw updateError;
setPromptPresets(updatedPresets);
toast.success(translate('Preset updated successfully!'));
} catch (error) {
console.error('Error updating prompt preset:', error);
toast.error(translate('Failed to update preset'));
throw error;
}
};
export const deletePromptPreset = async (
userId: string,
id: string,
setPromptPresets: React.Dispatch<React.SetStateAction<PromptPreset[]>>
) => {
try {
const { data: profile, error: fetchError } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (fetchError) throw fetchError;
const settings = (profile?.settings as any) || {};
const existingPresets = settings.promptPresets || [];
const updatedPresets = existingPresets.filter((p: PromptPreset) => p.id !== id);
const { error: updateError } = await supabase
.from('profiles')
.update({
settings: {
...settings,
promptPresets: updatedPresets,
},
})
.eq('user_id', userId);
if (updateError) throw updateError;
setPromptPresets(updatedPresets);
toast.success(translate('Preset deleted successfully!'));
} catch (error) {
console.error('Error deleting prompt preset:', error);
toast.error(translate('Failed to delete preset'));
throw error;
}
};
// ============================================================================
// WORKFLOW CRUD OPERATIONS
// ============================================================================
export const saveWorkflow = async (
userId: string,
workflow: Omit<Workflow, 'id' | 'createdAt'>,
setWorkflows: React.Dispatch<React.SetStateAction<Workflow[]>>
) => {
try {
const { data: profile, error: fetchError } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (fetchError && fetchError.code !== 'PGRST116') throw fetchError;
const settings = (profile?.settings as any) || {};
const existingWorkflows = settings.workflows || [];
const newWorkflow: Workflow = {
id: `workflow_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: workflow.name,
actions: workflow.actions,
createdAt: new Date().toISOString(),
};
const updatedWorkflows = [...existingWorkflows, newWorkflow];
const { error: updateError } = await supabase
.from('profiles')
.update({
settings: {
...settings,
workflows: updatedWorkflows,
},
})
.eq('user_id', userId);
if (updateError) throw updateError;
setWorkflows(updatedWorkflows);
toast.success(translate('Workflow saved successfully!'));
} catch (error) {
console.error('Error saving workflow:', error);
toast.error(translate('Failed to save workflow'));
throw error;
}
};
export const updateWorkflow = async (
userId: string,
id: string,
workflow: Omit<Workflow, 'id' | 'createdAt'>,
setWorkflows: React.Dispatch<React.SetStateAction<Workflow[]>>
) => {
try {
const { data: profile, error: fetchError } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (fetchError) throw fetchError;
const settings = (profile?.settings as any) || {};
const existingWorkflows = settings.workflows || [];
const updatedWorkflows = existingWorkflows.map((w: Workflow) =>
w.id === id
? { ...w, name: workflow.name, actions: workflow.actions }
: w
);
const { error: updateError } = await supabase
.from('profiles')
.update({
settings: {
...settings,
workflows: updatedWorkflows,
},
})
.eq('user_id', userId);
if (updateError) throw updateError;
setWorkflows(updatedWorkflows);
toast.success(translate('Workflow updated successfully!'));
} catch (error) {
console.error('Error updating workflow:', error);
toast.error(translate('Failed to update workflow'));
throw error;
}
};
export const deleteWorkflow = async (
userId: string,
id: string,
setWorkflows: React.Dispatch<React.SetStateAction<Workflow[]>>
) => {
try {
const { data: profile, error: fetchError } = await supabase
.from('profiles')
.select('settings')
.eq('user_id', userId)
.single();
if (fetchError) throw fetchError;
const settings = (profile?.settings as any) || {};
const existingWorkflows = settings.workflows || [];
const updatedWorkflows = existingWorkflows.filter((w: Workflow) => w.id !== id);
const { error: updateError } = await supabase
.from('profiles')
.update({
settings: {
...settings,
workflows: updatedWorkflows,
},
})
.eq('user_id', userId);
if (updateError) throw updateError;
setWorkflows(updatedWorkflows);
toast.success(translate('Workflow deleted successfully!'));
} catch (error) {
console.error('Error deleting workflow:', error);
toast.error(translate('Failed to delete workflow'));
throw error;
}
};

View File

@ -0,0 +1,295 @@
import { ImageFile } from '../types';
import { getUserOpenAIKey } from '../db';
import { toast } from 'sonner';
import { translate } from '@/i18n';
import { transcribeAudio, runTools } from '@/lib/openai';
import { PromptPreset } from '@/components/PresetManager';
import { Logger } from '../utils/logger';
/**
* Voice Handlers
* - Microphone recording
* - Audio transcription
* - Voice-to-image workflow
*/
export const handleMicrophone = async (
isRecording: boolean,
mediaRecorderRef: React.MutableRefObject<MediaRecorder | null>,
setIsRecording: React.Dispatch<React.SetStateAction<boolean>>,
setIsTranscribing: React.Dispatch<React.SetStateAction<boolean>>,
audioChunksRef: React.MutableRefObject<Blob[]>,
lightboxOpen: boolean,
setLightboxPrompt: React.Dispatch<React.SetStateAction<string>>,
setPrompt: React.Dispatch<React.SetStateAction<string>>
) => {
if (isRecording) {
// Stop recording
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
setIsRecording(false);
}
} else {
// Start recording
try {
// Check if MediaRecorder is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
toast.error(translate('Audio recording is not supported in your browser'));
return;
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Create MediaRecorder with common audio format
const options = { mimeType: 'audio/webm' };
let mediaRecorder: MediaRecorder;
try {
mediaRecorder = new MediaRecorder(stream, options);
} catch (e) {
// Fallback without options if the format is not supported
mediaRecorder = new MediaRecorder(stream);
}
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
setIsTranscribing(true);
try {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' });
toast.info(translate('Transcribing audio...'));
const transcribedText = await transcribeAudio(audioFile);
if (transcribedText) {
// Update the appropriate prompt field based on whether lightbox is open
if (lightboxOpen) {
setLightboxPrompt(prev => {
const trimmed = prev.trim();
if (trimmed) {
return `${trimmed} ${transcribedText}`;
}
return transcribedText;
});
} else {
setPrompt(prev => {
const trimmed = prev.trim();
if (trimmed) {
return `${trimmed} ${transcribedText}`;
}
return transcribedText;
});
}
toast.success(translate('Audio transcribed successfully!'));
} else {
toast.error(translate('Failed to transcribe audio. Please check your OpenAI API key.'));
}
} catch (error: any) {
console.error('Error transcribing audio:', error);
toast.error(error.message || translate('Failed to transcribe audio'));
} finally {
setIsTranscribing(false);
audioChunksRef.current = [];
// Stop all tracks
stream.getTracks().forEach(track => track.stop());
}
};
mediaRecorder.start();
setIsRecording(true);
toast.info(translate('Recording started... Click again to stop'));
} catch (error: any) {
console.error('Error accessing microphone:', error);
if (error.name === 'NotAllowedError') {
toast.error(translate('Microphone access denied. Please allow microphone access in your browser settings.'));
} else {
toast.error(translate('Failed to access microphone') + ': ' + error.message);
}
}
}
};
export const handleVoiceToImage = async (
transcribedText: string,
userId: string | undefined,
selectedPreset: PromptPreset | null,
selectedModel: string,
setPrompt: React.Dispatch<React.SetStateAction<string>>,
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
setPostTitle: React.Dispatch<React.SetStateAction<string>>,
setPostDescription: React.Dispatch<React.SetStateAction<string>>,
voicePopupRef: any,
logger: Logger
) => {
setPrompt(transcribedText);
// Use Agent mode for voice input to get optimization and metadata
voicePopupRef.current?.setGenerating(true);
// Create placeholder for voice agent
const placeholderId = `voice-placeholder-${Date.now()}`;
const placeholderImage: ImageFile = {
id: placeholderId,
src: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iI2YwZjBmMCIvPjwvc3ZnPg==',
title: '🎤 Voice Agent working...',
selected: false,
isGenerated: true,
};
setImages(prev => [...prev.map(img => ({ ...img, selected: false })), placeholderImage]);
logger.info(`Voice Agent started with prompt: "${transcribedText.substring(0, 50)}..."`);
try {
// Get user's OpenAI API key
const openaiApiKey = await getUserOpenAIKey(userId || '');
const fullPrompt = selectedPreset
? `Context: ${selectedPreset.prompt}\n\nPrompt: ${transcribedText}`
: transcribedText;
const result = await runTools({
prompt: `${fullPrompt}\n\nIMPORTANT: When calling generate_image, you MUST use model="${selectedModel}"`,
preset: 'smart-generation',
apiKey: openaiApiKey || undefined,
onToolCall: (toolCall) => {
if ('function' in toolCall) {
const toolName = toolCall.function?.name || 'unknown';
logger.debug(`Voice Agent calling tool: ${toolName}`);
const toolDisplayNames: Record<string, string> = {
'optimize_prompt': 'Optimizing prompt',
'generate_image': 'Generating image',
'generate_image_metadata': 'Creating title & description',
'publish_image': 'Preparing for publish',
};
const displayName = toolDisplayNames[toolName] || toolName;
voicePopupRef.current?.addToolCall(displayName);
}
}
});
if (result.success) {
// Extract and add generated images
const imageToolCall = result.toolCalls?.find(
tc => 'function' in tc && tc.function?.name === 'generate_image'
);
if (imageToolCall && 'function' in imageToolCall) {
let imageResult: any;
for (const msg of result.messages || []) {
if (msg.role === 'tool' && 'content' in msg) {
try {
const toolContent = JSON.parse(msg.content as string);
if (toolContent.images || toolContent.imageUrl) {
imageResult = toolContent;
break;
}
} catch (e) {
// Not JSON, skip
}
}
}
if (!imageResult) {
console.warn('🎤 No image result found in voice agent tool responses');
logger.warning('Voice Agent completed but no images were returned');
setImages(prev => prev.filter(img => img.id !== placeholderId));
voicePopupRef.current?.setGenerating(false);
return;
}
// Handle multiple images
if (imageResult.images && Array.isArray(imageResult.images)) {
const newImages: ImageFile[] = [];
for (let i = 0; i < imageResult.images.length; i++) {
const img = imageResult.images[i];
const newImage: ImageFile = {
id: `voice-agent-${Date.now()}-${i}`,
src: img.imageUrl,
title: `Voice: ${transcribedText.substring(0, 40)}...`,
selected: i === imageResult.images.length - 1,
isGenerated: true,
aiText: img.text
};
newImages.push(newImage);
}
setImages(prev => {
const withoutPlaceholder = prev.filter(img => img.id !== placeholderId);
return [...withoutPlaceholder, ...newImages];
});
logger.success(`Voice Agent generated ${newImages.length} image(s)`);
} else if (imageResult.imageUrl) {
// Single image format
const newImage: ImageFile = {
id: `voice-agent-${Date.now()}`,
src: imageResult.imageUrl,
title: `Voice: ${transcribedText.substring(0, 40)}...`,
selected: true,
isGenerated: true,
aiText: imageResult.text
};
setImages(prev => {
const withoutPlaceholder = prev.filter(img => img.id !== placeholderId);
return [...withoutPlaceholder, newImage];
});
logger.success('Voice Agent generated image');
}
}
// Extract metadata if available
const metadataToolCall = result.toolCalls?.find(
tc => 'function' in tc && tc.function?.name === 'generate_image_metadata'
);
if (metadataToolCall && 'function' in metadataToolCall) {
const metadataResult = JSON.parse(metadataToolCall.function.arguments || '{}');
if (metadataResult.title) {
setPostTitle(metadataResult.title);
}
if (metadataResult.description) {
setPostDescription(metadataResult.description);
}
}
toast.success('Voice Agent completed!');
logger.success('Voice Agent workflow completed');
// Close popup after showing results
setTimeout(() => {
voicePopupRef.current?.setGenerating(false);
}, 2000);
} else {
toast.error('Voice Agent failed: ' + (result.error || 'Unknown error'));
logger.error(`Voice Agent failed: ${result.error || 'Unknown error'}`);
voicePopupRef.current?.setGenerating(false);
setImages(prev => prev.filter(img => img.id !== placeholderId));
}
} catch (error: any) {
console.error('Error in voice agent:', error);
logger.error(`Voice Agent error: ${error.message}`);
toast.error('Voice Agent error: ' + error.message);
voicePopupRef.current?.setGenerating(false);
setImages(prev => prev.filter(img => img.id !== placeholderId));
}
};

View File

@ -0,0 +1,320 @@
/**
* ImageWizard State Management Hook
* Centralizes all state declarations with a flat structure for better maintainability
*/
import { useState, useRef } from "react";
import { ImageFile } from "../types";
import { DEFAULT_QUICK_ACTIONS, QuickAction } from "@/constants";
import { PromptPreset } from "@/components/PresetManager";
import { Workflow } from "@/components/WorkflowManager";
import { AVAILABLE_MODELS, getModelString } from "@/lib/image-router";
import { VoiceRecordingPopupHandle } from "@/components/VoiceRecordingPopup";
/**
* Flat state interface for ImageWizard
* All state variables are at the top level for easy access
*/
export interface ImageWizardState {
// Image State
images: ImageFile[];
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>;
availableImages: ImageFile[];
setAvailableImages: React.Dispatch<React.SetStateAction<ImageFile[]>>;
generatedImage: string | null;
setGeneratedImage: React.Dispatch<React.SetStateAction<string | null>>;
// Generation State
isGenerating: boolean;
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>;
isAgentMode: boolean;
setIsAgentMode: React.Dispatch<React.SetStateAction<boolean>>;
isSplitMode: boolean;
setIsSplitMode: React.Dispatch<React.SetStateAction<boolean>>;
isOptimizingPrompt: boolean;
setIsOptimizingPrompt: React.Dispatch<React.SetStateAction<boolean>>;
abortControllerRef: React.MutableRefObject<AbortController | null>;
// UI State
dragIn: boolean;
setDragIn: React.Dispatch<React.SetStateAction<boolean>>;
loadingImages: boolean;
setLoadingImages: React.Dispatch<React.SetStateAction<boolean>>;
isPublishing: boolean;
setIsPublishing: React.Dispatch<React.SetStateAction<boolean>>;
dropZoneRef: React.RefObject<HTMLDivElement>;
dragLeaveTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
// Form State
prompt: string;
setPrompt: React.Dispatch<React.SetStateAction<string>>;
postTitle: string;
setPostTitle: React.Dispatch<React.SetStateAction<string>>;
postDescription: string;
setPostDescription: React.Dispatch<React.SetStateAction<string>>;
editingPostId: string | undefined;
setEditingPostId: React.Dispatch<React.SetStateAction<string | undefined>>;
selectedModel: string;
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
aspectRatio: string;
setAspectRatio: React.Dispatch<React.SetStateAction<string>>;
resolution: string;
setResolution: React.Dispatch<React.SetStateAction<string>>;
searchGrounding: boolean;
setSearchGrounding: React.Dispatch<React.SetStateAction<boolean>>;
// Dialog State
showDeleteConfirmDialog: boolean;
setShowDeleteConfirmDialog: React.Dispatch<React.SetStateAction<boolean>>;
imageToDelete: string | null;
setImageToDelete: React.Dispatch<React.SetStateAction<string | null>>;
showSaveTemplateDialog: boolean;
setShowSaveTemplateDialog: React.Dispatch<React.SetStateAction<boolean>>;
newTemplateName: string;
setNewTemplateName: React.Dispatch<React.SetStateAction<string>>;
showTemplateManager: boolean;
setShowTemplateManager: React.Dispatch<React.SetStateAction<boolean>>;
showEditActionsDialog: boolean;
setShowEditActionsDialog: React.Dispatch<React.SetStateAction<boolean>>;
showLightboxPublishDialog: boolean;
setShowLightboxPublishDialog: React.Dispatch<React.SetStateAction<boolean>>;
showVoicePopup: boolean;
setShowVoicePopup: React.Dispatch<React.SetStateAction<boolean>>;
// Lightbox State
lightboxOpen: boolean;
setLightboxOpen: React.Dispatch<React.SetStateAction<boolean>>;
currentImageIndex: number;
setCurrentImageIndex: React.Dispatch<React.SetStateAction<number>>;
lightboxPrompt: string;
setLightboxPrompt: React.Dispatch<React.SetStateAction<string>>;
selectedOriginalImageId: string | null;
setSelectedOriginalImageId: React.Dispatch<React.SetStateAction<string | null>>;
// Settings State
promptTemplates: Array<{ name: string; template: string }>;
setPromptTemplates: React.Dispatch<React.SetStateAction<Array<{ name: string; template: string }>>>;
loadingTemplates: boolean;
setLoadingTemplates: React.Dispatch<React.SetStateAction<boolean>>;
promptPresets: PromptPreset[];
setPromptPresets: React.Dispatch<React.SetStateAction<PromptPreset[]>>;
selectedPreset: PromptPreset | null;
setSelectedPreset: React.Dispatch<React.SetStateAction<PromptPreset | null>>;
loadingPresets: boolean;
setLoadingPresets: React.Dispatch<React.SetStateAction<boolean>>;
workflows: Workflow[];
setWorkflows: React.Dispatch<React.SetStateAction<Workflow[]>>;
loadingWorkflows: boolean;
setLoadingWorkflows: React.Dispatch<React.SetStateAction<boolean>>;
quickActions: QuickAction[];
setQuickActions: React.Dispatch<React.SetStateAction<QuickAction[]>>;
editingActions: QuickAction[];
setEditingActions: React.Dispatch<React.SetStateAction<QuickAction[]>>;
loadingActions: boolean;
setLoadingActions: React.Dispatch<React.SetStateAction<boolean>>;
promptHistory: string[];
setPromptHistory: React.Dispatch<React.SetStateAction<string[]>>;
historyIndex: number;
setHistoryIndex: React.Dispatch<React.SetStateAction<number>>;
// Voice State
isRecording: boolean;
setIsRecording: React.Dispatch<React.SetStateAction<boolean>>;
isTranscribing: boolean;
setIsTranscribing: React.Dispatch<React.SetStateAction<boolean>>;
mediaRecorderRef: React.MutableRefObject<MediaRecorder | null>;
audioChunksRef: React.MutableRefObject<Blob[]>;
voicePopupRef: React.RefObject<VoiceRecordingPopupHandle>;
}
/**
* Central state management hook for ImageWizard
* Returns all state directly in a flat structure for easy destructuring
*/
export const useImageWizardState = (
initialImages: ImageFile[] = [],
initialPostTitle: string = "",
initialPostDescription: string = "",
initialEditingPostId: string | undefined = undefined
): ImageWizardState => {
// Image State
const [images, setImages] = useState<ImageFile[]>(initialImages);
const [availableImages, setAvailableImages] = useState<ImageFile[]>([]);
const [generatedImage, setGeneratedImage] = useState<string | null>(null);
// Generation State
const [isGenerating, setIsGenerating] = useState(false);
const [isAgentMode, setIsAgentMode] = useState(false);
const [isSplitMode, setIsSplitMode] = useState(false);
const [isOptimizingPrompt, setIsOptimizingPrompt] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
// UI State
const [dragIn, setDragIn] = useState(false);
const [loadingImages, setLoadingImages] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const dropZoneRef = useRef<HTMLDivElement>(null);
const dragLeaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Form State
const [prompt, setPrompt] = useState("");
const [postTitle, setPostTitle] = useState(initialPostTitle);
const [postDescription, setPostDescription] = useState(initialPostDescription);
const [editingPostId, setEditingPostId] = useState(initialEditingPostId);
const [selectedModel, setSelectedModel] = useState<string>(
getModelString(AVAILABLE_MODELS[0].provider, AVAILABLE_MODELS[0].modelName)
);
const [aspectRatio, setAspectRatio] = useState<string>("1:1");
const [resolution, setResolution] = useState<string>("1K");
const [searchGrounding, setSearchGrounding] = useState<boolean>(false);
// Dialog State
const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false);
const [imageToDelete, setImageToDelete] = useState<string | null>(null);
const [showSaveTemplateDialog, setShowSaveTemplateDialog] = useState(false);
const [newTemplateName, setNewTemplateName] = useState("");
const [showTemplateManager, setShowTemplateManager] = useState(false);
const [showEditActionsDialog, setShowEditActionsDialog] = useState(false);
const [showLightboxPublishDialog, setShowLightboxPublishDialog] = useState(false);
const [showVoicePopup, setShowVoicePopup] = useState(false);
// Lightbox State
const [lightboxOpen, setLightboxOpen] = useState(false);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [lightboxPrompt, setLightboxPrompt] = useState("");
const [selectedOriginalImageId, setSelectedOriginalImageId] = useState<string | null>(null);
// Settings State
const [promptTemplates, setPromptTemplates] = useState<Array<{ name: string; template: string }>>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [promptPresets, setPromptPresets] = useState<PromptPreset[]>([]);
const [selectedPreset, setSelectedPreset] = useState<PromptPreset | null>(null);
const [loadingPresets, setLoadingPresets] = useState(false);
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
const [editingActions, setEditingActions] = useState<QuickAction[]>([]);
const [loadingActions, setLoadingActions] = useState(false);
const [promptHistory, setPromptHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// Voice State
const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const voicePopupRef = useRef<VoiceRecordingPopupHandle>(null);
// Return flat structure - all state at top level
return {
// Image State
images,
setImages,
availableImages,
setAvailableImages,
generatedImage,
setGeneratedImage,
// Generation State
isGenerating,
setIsGenerating,
isAgentMode,
setIsAgentMode,
isSplitMode,
setIsSplitMode,
isOptimizingPrompt,
setIsOptimizingPrompt,
abortControllerRef,
// UI State
dragIn,
setDragIn,
loadingImages,
setLoadingImages,
isPublishing,
setIsPublishing,
dropZoneRef,
dragLeaveTimeoutRef,
// Form State
prompt,
setPrompt,
postTitle,
setPostTitle,
postDescription,
setPostDescription,
editingPostId,
setEditingPostId,
selectedModel,
setSelectedModel,
aspectRatio,
setAspectRatio,
resolution,
setResolution,
searchGrounding,
setSearchGrounding,
// Dialog State
showDeleteConfirmDialog,
setShowDeleteConfirmDialog,
imageToDelete,
setImageToDelete,
showSaveTemplateDialog,
setShowSaveTemplateDialog,
newTemplateName,
setNewTemplateName,
showTemplateManager,
setShowTemplateManager,
showEditActionsDialog,
setShowEditActionsDialog,
showLightboxPublishDialog,
setShowLightboxPublishDialog,
showVoicePopup,
setShowVoicePopup,
// Lightbox State
lightboxOpen,
setLightboxOpen,
currentImageIndex,
setCurrentImageIndex,
lightboxPrompt,
setLightboxPrompt,
selectedOriginalImageId,
setSelectedOriginalImageId,
// Settings State
promptTemplates,
setPromptTemplates,
loadingTemplates,
setLoadingTemplates,
promptPresets,
setPromptPresets,
selectedPreset,
setSelectedPreset,
loadingPresets,
setLoadingPresets,
workflows,
setWorkflows,
loadingWorkflows,
setLoadingWorkflows,
quickActions,
setQuickActions,
editingActions,
setEditingActions,
loadingActions,
setLoadingActions,
promptHistory,
setPromptHistory,
historyIndex,
setHistoryIndex,
// Voice State
isRecording,
setIsRecording,
isTranscribing,
setIsTranscribing,
mediaRecorderRef,
audioChunksRef,
voicePopupRef,
};
};

View File

@ -0,0 +1,39 @@
export interface ImageFile {
id: string;
file?: File;
src: string;
title: string;
selected?: boolean;
isPreferred?: boolean; // Database selection status (star/preferred version)
isGenerated?: boolean;
userId?: string;
aiText?: string; // Add AI description text
description?: string; // User editable description/caption
realDatabaseId?: string; // Store the real database ID when coming from MagicWizardButton
parentForNewVersions?: string; // Store the parent ID for creating new versions (allows tree hierarchy)
path?: string; // External link URL or generic path
// Video specific fields
type?: 'image' | 'video' | 'page-external';
muxUploadId?: string;
muxAssetId?: string;
muxPlaybackId?: string;
uploadStatus?: 'uploading' | 'processing' | 'ready' | 'error';
uploadProgress?: number;
isAddingToPost?: boolean; // UI state for adding to post
meta?: any; // Start storing flexible metadata (like site-info for links)
}
// QuickAction is now defined in @constants.ts
export interface ImageWizardProps {
isOpen: boolean;
onClose: () => void;
initialImages?: ImageFile[];
onPublish?: (imageUrl: string, prompt: string) => void;
originalImageId?: string;
mode?: 'default' | 'post'; // Add mode prop
initialPostTitle?: string;
initialPostDescription?: string;
editingPostId?: string;
}

View File

@ -0,0 +1,93 @@
/**
* Logger utility for ImageWizard
* Provides a cleaner API for logging with automatic category prefixing
*/
import { LogEntry } from "@/contexts/LogContext";
export interface Logger {
debug: (message: string, ...args: any[]) => void;
info: (message: string, ...args: any[]) => void;
warning: (message: string, ...args: any[]) => void;
error: (message: string, ...args: any[]) => void;
success: (message: string, ...args: any[]) => void;
verbose: (message: string, ...args: any[]) => void; // Alias for debug
}
/**
* Format log message with additional arguments
*/
const formatMessage = (message: string, args: any[]): string => {
if (args.length === 0) return message;
// Stringify additional arguments and append them
const argsStr = args.map(arg => {
if (typeof arg === 'string') return arg;
if (arg instanceof Error) return arg.message;
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}).join(' ');
return `${message} ${argsStr}`;
};
/**
* Create a logger instance with a specific category
* @param addLog The addLog function from LogContext
* @param category Optional category prefix for all logs
*/
export const createLogger = (
addLog: (level: LogEntry['level'], message: string, category?: string) => void,
category?: string
): Logger => {
return {
debug: (message: string, ...args: any[]) => {
const formattedMessage = formatMessage(message, args);
addLog('debug', formattedMessage, category);
console.debug(`[DEBUG] ${formattedMessage}`);
},
info: (message: string, ...args: any[]) => {
const formattedMessage = formatMessage(message, args);
addLog('info', formattedMessage, category);
console.info(`[INFO] ${formattedMessage}`);
},
warning: (message: string, ...args: any[]) => {
const formattedMessage = formatMessage(message, args);
addLog('warning', formattedMessage, category);
console.warn(`[WARN] ${formattedMessage}`);
},
error: (message: string, ...args: any[]) => {
const formattedMessage = formatMessage(message, args);
addLog('error', formattedMessage, category);
console.error(`[ERROR] ${formattedMessage}`);
},
success: (message: string, ...args: any[]) => {
const formattedMessage = formatMessage(message, args);
addLog('success', formattedMessage, category);
console.log(`[SUCCESS] ${formattedMessage}`);
},
verbose: (message: string, ...args: any[]) => {
const formattedMessage = formatMessage(message, args);
addLog('debug', formattedMessage, category);
console.debug(`[DEBUG] ${formattedMessage}`);
},
};
};
/**
* Create a scoped logger with a sub-category
* @param logger Parent logger
* @param addLog The addLog function from LogContext
* @param subCategory Sub-category to append to parent category
*/
export const createScopedLogger = (
addLog: (level: LogEntry['level'], message: string, category?: string) => void,
category: string,
subCategory: string
): Logger => {
const fullCategory = `${category} > ${subCategory}`;
return createLogger(addLog, fullCategory);
};

View File

@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { Plus } from 'lucide-react';
import { T } from '@/i18n';
interface InlineDropZoneProps {
onDrop: (files: File[]) => void;
index: number;
}
export const InlineDropZone: React.FC<InlineDropZoneProps> = ({ onDrop, index }) => {
const [isDragOver, setIsDragOver] = useState(false);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes('Files')) {
setIsDragOver(true);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes('Files') && !isDragOver) {
setIsDragOver(true);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
if (files.length > 0) {
onDrop(files);
}
};
return (
<div className={`flex justify-center pt-4 transition-all duration-200 ${isDragOver ? 'opacity-100 scale-105' : 'opacity-70 hover:opacity-100'}`}>
<label
className={`flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer transition-colors
${isDragOver ? 'border-primary bg-primary/10' : 'hover:bg-muted/50 border-muted-foreground/25'}
`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="flex flex-col items-center justify-center pt-5 pb-6 pointer-events-none">
<Plus className={`h-8 w-8 mb-2 transition-colors ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`} />
<p className={`text-sm ${isDragOver ? 'text-primary font-bold' : 'text-muted-foreground'}`}>
<span className="font-semibold"><T>Click to upload</T></span> <T>or drag and drop</T>
</p>
</div>
<input
type="file"
className="hidden"
accept="image/*"
multiple
onChange={(e) => {
if (e.target.files?.length) {
onDrop(Array.from(e.target.files));
}
}}
/>
</label>
</div>
);
};

View File

@ -0,0 +1,258 @@
import React, { useState, useEffect } from "react";
import { useFeedData, FeedSortOption } from "@/hooks/useFeedData";
import { useOrganization } from "@/contexts/OrganizationContext";
import { useIsMobile } from "@/hooks/use-mobile";
import { useNavigate } from "react-router-dom";
import { formatDistanceToNow } from "date-fns";
import { MessageCircle, Heart, ExternalLink } from "lucide-react";
import UserAvatarBlock from "@/components/UserAvatarBlock";
import { Button } from "@/components/ui/button";
import Post from "@/pages/Post";
import UserPage from "@/pages/UserPage";
interface ListLayoutProps {
sortBy?: FeedSortOption;
navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'widget';
navigationSourceId?: string;
isOwner?: boolean; // Not strictly used for rendering list but good for consistency
}
const ListItem = ({ item, isSelected, onClick }: { item: any, isSelected: boolean, onClick: () => void }) => {
const isExternal = item.type === 'page-external';
const domain = isExternal && item.meta?.url ? new URL(item.meta.url).hostname : null;
return (
<div
className={`p-3 border-b flex items-start gap-3 cursor-pointer hover:bg-muted/50 transition-colors ${isSelected ? 'bg-muted border-l-4 border-l-primary' : ''}`}
onClick={onClick}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-semibold truncate text-foreground">
{item.title || "Untitled"}
</h3>
{domain && (
<span className="text-xs text-muted-foreground flex items-center gap-0.5">
<ExternalLink className="h-3 w-3" />
{domain}
</span>
)}
</div>
<div className="text-xs text-muted-foreground line-clamp-2 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(); }}>
<UserAvatarBlock
userId={item.user_id || item.author?.id}
avatarUrl={item.author?.avatar_url}
displayName={item.author?.username}
className="w-5 h-5"
showDate={false}
hoverStyle={false}
textSize="xs"
/>
</div>
<span>{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}</span>
<div className="flex items-center gap-3 ml-auto">
<span className="flex items-center gap-1">
<Heart className="h-3 w-3" />
{item.likes_count || 0}
</span>
<span className="flex items-center gap-1">
<MessageCircle className="h-3 w-3" />
{/* Assuming comments count is available, otherwise 0 */}
{(item.comments && item.comments[0]?.count) || 0}
</span>
</div>
</div>
</div>
{/* Thumbnail for quick context */}
{/* Thumbnail for quick context */}
<div className="w-16 h-16 shrink-0 rounded-md overflow-hidden bg-muted">
{item.thumbnail_url || item.cover?.image_url || item.pictures?.[0]?.image_url ? (
<img
src={item.thumbnail_url || item.cover?.image_url || item.pictures?.[0]?.image_url}
alt=""
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted text-muted-foreground">
<div className="w-4 h-4 rounded-full border-2 border-current opacity-20" />
</div>
)}
</div>
</div>
);
};
export const ListLayout = ({
sortBy = 'latest',
navigationSource = 'home',
navigationSourceId
}: ListLayoutProps) => {
const navigate = useNavigate();
const isMobile = useIsMobile();
const { orgSlug, isOrgContext } = useOrganization();
// State for desktop selection
const [selectedId, setSelectedId] = useState<string | null>(null);
const {
posts: feedPosts,
loading,
hasMore,
loadMore
} = useFeedData({
source: navigationSource,
sourceId: navigationSourceId,
isOrgContext,
orgSlug,
sortBy
});
console.log('posts', feedPosts);
const handleItemClick = (item: any) => {
if (isMobile) {
navigate(`/post/${item.id}`);
} else {
setSelectedId(item.id);
}
};
// Keyboard navigation
useEffect(() => {
if (isMobile || !selectedId) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const currentIndex = feedPosts.findIndex((p: any) => p.id === selectedId);
if (currentIndex === -1) return;
let newIndex = currentIndex;
if (e.key === 'ArrowDown' && currentIndex < feedPosts.length - 1) {
newIndex = currentIndex + 1;
} else if (e.key === 'ArrowUp' && currentIndex > 0) {
newIndex = currentIndex - 1;
}
if (newIndex !== currentIndex) {
setSelectedId((feedPosts[newIndex] as any).id);
// Ensure the list item is visible
const element = document.getElementById(`list-item-${(feedPosts[newIndex] as any).id}`);
element?.scrollIntoView({ block: 'nearest' });
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedId, feedPosts, isMobile]);
// Select first item by default on desktop if nothing selected
useEffect(() => {
if (!isMobile && !selectedId && feedPosts.length > 0) {
setSelectedId((feedPosts[0] as any).id);
}
}, [feedPosts, isMobile, selectedId]);
if (loading && feedPosts.length === 0) {
return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
}
if (feedPosts.length === 0) {
return <div className="p-8 text-center text-muted-foreground">No posts found.</div>;
}
console.log('ListLayout FeedPosts', feedPosts);
if (!isMobile) {
// Desktop Split Layout
return (
<div className="flex h-[calc(100vh-8rem)] overflow-hidden border rounded-lg bg-background shadow-sm">
{/* Left: List */}
<div className="w-[350px] lg:w-[400px] border-r flex flex-col bg-card shrink-0">
<div className="flex-1 overflow-y-auto scrollbar-custom">
{feedPosts.map((post: any) => (
<div key={post.id} id={`list-item-${post.id}`}>
<ListItem
item={post}
isSelected={selectedId === post.id}
onClick={() => handleItemClick(post)}
/>
</div>
))}
{hasMore && (
<div className="p-4 text-center">
<Button variant="ghost" size="sm" onClick={() => loadMore()}>Load More</Button>
</div>
)}
</div>
</div>
{/* Right: Detail */}
<div className="flex-1 bg-background overflow-hidden relative">
{selectedId ? (
(() => {
const selectedPost = feedPosts.find((p: any) => p.id === selectedId);
const postAny = selectedPost as any;
// Check for slug in various locations depending on data structure
const slug = postAny.meta?.slug || postAny.cover?.meta?.slug || postAny.pictures?.[0]?.meta?.slug;
if (postAny?.type === 'page-intern' && slug) {
return (
<UserPage
userId={postAny.user_id}
slug={slug}
embedded
/>
);
}
return (
<Post
key={selectedId} // Force remount on ID change
postId={selectedId}
embedded
className="h-full overflow-y-auto scrollbar-custom"
/>
);
})()
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
Select an item to view details
</div>
)}
</div>
</div>
);
}
// Mobile Layout
return (
<div className="flex flex-col">
{feedPosts.map((post: any) => (
<ListItem
key={post.id}
item={post}
isSelected={false}
onClick={() => handleItemClick(post)}
/>
))}
{hasMore && (
<div className="p-4 text-center">
<Button variant="ghost" size="sm" onClick={() => loadMore()}>Load More</Button>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,261 @@
import { useMemo, useState, useEffect, useRef } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Button } from './ui/button';
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { ListFilter, ArrowDownCircle, Trash2, Download, X } from 'lucide-react';
import { Input } from './ui/input';
import { toast } from 'sonner';
import { useLog, LogEntry } from '@/contexts/LogContext';
interface LogViewerProps {
onClose?: () => void;
logs?: LogEntry[];
clearLogs?: () => void;
title?: string;
sourceInfo?: React.ReactNode;
}
const LogViewer: React.FC<LogViewerProps> = ({ onClose, logs: propLogs, clearLogs: propClearLogs, title = "Activity Logs", sourceInfo }) => {
const context = useLog();
const logs = propLogs || context.logs;
const clearLogs = propClearLogs || context.clearLogs;
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [activeTab, setActiveTab] = useState<string>('all');
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const safeToString = (value: any): string => {
if (typeof value === 'string') return value;
if (value === null || typeof value === 'undefined') return '';
try {
return JSON.stringify(value);
} catch (e) {
return '[Unserializable object]';
}
};
const filteredLogs = useMemo(() => {
let filtered = logs;
if (searchTerm) {
const searchTerms = searchTerm.toLowerCase().split(' ').filter(term => term.trim() !== '');
if (searchTerms.length > 0) {
filtered = filtered.filter(log =>
searchTerms.every(term =>
(log.message && safeToString(log.message).toLowerCase().includes(term)) ||
(log.level && log.level.toLowerCase().includes(term)) ||
(log.category && safeToString(log.category).toLowerCase().includes(term))
)
);
}
}
return {
all: filtered,
info: filtered.filter(log => log.level === 'info'),
debug: filtered.filter(log => log.level === 'debug'),
warning: filtered.filter(log => log.level === 'warning'),
error: filtered.filter(log => log.level === 'error'),
success: filtered.filter(log => log.level === 'success'),
};
}, [logs, searchTerm]);
useEffect(() => {
if (autoScrollEnabled && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'auto', block: 'end' });
}
}, [filteredLogs[activeTab as keyof typeof filteredLogs], activeTab, autoScrollEnabled]);
const handleDownloadJson = () => {
if (filteredLogs.all.length === 0) {
toast.info("No logs to download based on the current filter.");
return;
}
const jsonString = JSON.stringify(filteredLogs.all, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
a.download = `wizard-logs-${timestamp}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Filtered logs have been downloaded.");
};
const renderLogList = (level: keyof typeof filteredLogs) => (
<ScrollArea
ref={activeTab === level ? scrollAreaRef : undefined}
className="h-full w-full rounded-md border border-border bg-muted/30 p-2 md:p-3 font-mono text-[10px] sm:text-xs overflow-hidden"
>
{filteredLogs[level].length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
No {level !== 'all' ? `${level} ` : ''}logs available.
</div>
) : (
<>
{filteredLogs[level].map((log) => (
<div key={log.id} className="whitespace-pre-wrap mb-1 break-words">
<span className="text-muted-foreground mr-1 sm:mr-2">
{log.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className={`mr-1 sm:mr-2 font-semibold ${getLogLevelColor(log.level)}`}>
[{log.level.toUpperCase()}]
</span>
{log.category && (
<span className="text-orange-400 mr-1 sm:mr-2">
[{safeToString(log.category)}]
</span>
)}
<span className="break-all">{safeToString(log.message)}</span>
</div>
))}
<div ref={messagesEndRef} />
</>
)}
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
</ScrollArea>
);
const getLogLevelColor = (level: string) => {
switch (level) {
case 'error':
return 'text-red-500';
case 'warning':
return 'text-yellow-500';
case 'info':
return 'text-blue-500';
case 'debug':
return 'text-purple-400';
case 'success':
return 'text-green-500';
default:
return 'text-muted-foreground';
}
};
return (
<div className="border border-border rounded-lg p-2 md:p-4 bg-card flex flex-col h-full">
{/* Header - Mobile Optimized */}
<div className="flex flex-col gap-2 mb-3 md:mb-4">
{/* Title Row */}
<div className="flex items-center justify-between">
<h3 className="text-base md:text-lg font-bold flex items-center gap-2">
<ListFilter className="h-4 w-4 md:h-5 md:w-5" />
<span className="hidden sm:inline">{title}</span>
<span className="sm:hidden">Logs</span>
{sourceInfo}
</h3>
{onClose && (
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 md:px-2 md:w-auto"
title="Close"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{/* Search Row */}
<Input
placeholder="Search logs..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full h-9 text-sm"
/>
{/* Controls Row */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center space-x-2">
<Switch
id="auto-scroll-switch"
checked={autoScrollEnabled}
onCheckedChange={setAutoScrollEnabled}
/>
<Label htmlFor="auto-scroll-switch" className="text-xs font-mono flex items-center gap-1 whitespace-nowrap">
<ArrowDownCircle className="h-3 w-3" />
<span className="hidden sm:inline">Auto Scroll</span>
<span className="sm:hidden">Auto</span>
</Label>
</div>
<div className="flex items-center gap-1.5">
<Button
variant="destructive"
size="sm"
onClick={() => {
clearLogs();
toast.success("Logs cleared");
}}
disabled={logs.length === 0}
className="h-8 w-8 p-0 md:w-auto md:px-3"
title="Clear logs"
>
<Trash2 className="h-3.5 w-3.5" />
<span className="hidden md:inline md:ml-1.5">Clear</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownloadJson}
disabled={filteredLogs.all.length === 0}
className="h-8 w-8 p-0 md:w-auto md:px-3"
title="Download logs"
>
<Download className="h-3.5 w-3.5" />
<span className="hidden md:inline md:ml-1.5">Download</span>
</Button>
</div>
</div>
</div>
<Tabs
defaultValue="all"
className="w-full flex flex-col flex-grow min-h-0"
value={activeTab}
onValueChange={setActiveTab}
>
<TabsList className="grid w-full grid-cols-3 sm:grid-cols-6 mb-2 h-auto bg-muted/50 p-0.5">
<TabsTrigger value="all" className="text-[11px] sm:text-xs px-1 sm:px-2 py-1.5">
All<span className="hidden sm:inline ml-0.5">({filteredLogs.all.length})</span>
</TabsTrigger>
<TabsTrigger value="info" className="text-[11px] sm:text-xs px-1 sm:px-2 py-1.5">
Info<span className="hidden sm:inline ml-0.5">({filteredLogs.info.length})</span>
</TabsTrigger>
<TabsTrigger value="success" className="text-[11px] sm:text-xs px-1 sm:px-2 py-1.5">
OK<span className="hidden sm:inline ml-0.5">({filteredLogs.success.length})</span>
</TabsTrigger>
<TabsTrigger value="debug" className="text-[11px] sm:text-xs px-1 sm:px-2 py-1.5">
Debug<span className="hidden sm:inline ml-0.5">({filteredLogs.debug.length})</span>
</TabsTrigger>
<TabsTrigger value="warning" className="text-[11px] sm:text-xs px-1 sm:px-2 py-1.5">
Warn<span className="hidden sm:inline ml-0.5">({filteredLogs.warning.length})</span>
</TabsTrigger>
<TabsTrigger value="error" className="text-[11px] sm:text-xs px-1 sm:px-2 py-1.5">
Err<span className="hidden sm:inline ml-0.5">or ({filteredLogs.error.length})</span>
</TabsTrigger>
</TabsList>
<TabsContent value="all" className="flex-grow min-h-0"><div className="h-full">{renderLogList('all')}</div></TabsContent>
<TabsContent value="info" className="flex-grow min-h-0"><div className="h-full">{renderLogList('info')}</div></TabsContent>
<TabsContent value="success" className="flex-grow min-h-0"><div className="h-full">{renderLogList('success')}</div></TabsContent>
<TabsContent value="debug" className="flex-grow min-h-0"><div className="h-full">{renderLogList('debug')}</div></TabsContent>
<TabsContent value="warning" className="flex-grow min-h-0"><div className="h-full">{renderLogList('warning')}</div></TabsContent>
<TabsContent value="error" className="flex-grow min-h-0"><div className="h-full">{renderLogList('error')}</div></TabsContent>
</Tabs>
</div>
);
};
export default LogViewer;

View File

@ -0,0 +1,117 @@
import React from "react";
import { Wand2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "react-router-dom";
import { useWizardContext } from "@/hooks/useWizardContext";
import { toast } from "sonner";
import { translate } from "@/i18n";
interface MagicWizardButtonProps {
imageUrl: string;
imageTitle: string;
className?: string;
size?: "sm" | "default" | "lg" | "icon";
variant?: "default" | "ghost" | "outline";
onClick?: (e: React.MouseEvent) => void;
editingPostId?: string | null; // Explicitly pass the post ID if known, or null to prevent auto-detection
pictureId?: string; // Explicitly pass the picture ID if known
children?: React.ReactNode;
}
const MagicWizardButton: React.FC<MagicWizardButtonProps> = ({
imageUrl,
imageTitle,
className = "",
size = "sm",
variant = "ghost",
onClick,
editingPostId: explicitPostId,
pictureId: explicitPictureId,
children
}) => {
const { user } = useAuth();
const navigate = useNavigate();
const { setWizardImage } = useWizardContext();
const handleClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (onClick) {
onClick(e);
return;
}
if (!user) {
toast.error(translate('Please sign in to use the AI wizard'));
return;
}
// Get the real picture ID from the URL path if available
const urlPath = window.location.pathname;
// Check if we are in a post context
// If explicitPostId is provided (even as null), use it.
// If it is undefined, try to infer from URL.
let editingPostId: string | null = null;
if (explicitPostId !== undefined) {
editingPostId = explicitPostId;
} else {
const postMatch = urlPath.match(/\/post\/([a-f0-9-]{36})/);
if (postMatch) {
editingPostId = postMatch[1];
}
}
let realPictureId = explicitPictureId || null;
// Only try to guess from URL if explicitPictureId is not provided
if (!realPictureId) {
const pictureIdMatch = urlPath.match(/\/post\/([a-f0-9-]{36})/);
realPictureId = pictureIdMatch ? pictureIdMatch[1] : null;
}
const imageData = {
id: realPictureId || `external-${Date.now()}`,
src: imageUrl,
title: imageTitle,
selected: true,
realDatabaseId: realPictureId // Store the real database ID separately
};
// Store in Zustand (replaces sessionStorage) with return path
setWizardImage(imageData, window.location.pathname);
// Navigate to wizard
// Note: navigationData is now maintained by Zustand - no manual storage needed!
navigate('/wizard', {
state: {
mode: 'default', // Keep default mode but passing editingPostId allows "Add to Post"
editingPostId: editingPostId
}
});
};
// Don't render if user is not logged in
if (!user) {
return null;
}
return (
<Button
size={size}
variant={variant}
onClick={handleClick}
className={`${className?.includes('p-0')
? 'text-foreground hover:text-primary transition-colors'
: 'text-foreground hover:text-primary transition-colors'
} ${className}`}
title={translate("Edit with AI Wizard")}
>
<Wand2 className={`${size === 'sm' ? 'h-6 w-6' : 'h-5 w-5'} ${className?.includes('p-0') ? '' : 'mr-1'} drop-shadow-sm`} />
{children}
</Button>
);
};
export default MagicWizardButton;

View File

@ -0,0 +1,153 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { supabase } from '@/integrations/supabase/client';
// Lazy load the heavy editor component
const MilkdownEditorInternal = React.lazy(() => import('@/components/lazy-editors/MilkdownEditorInternal'));
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
}
const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
value,
onChange,
placeholder = "Enter description...",
className = "",
onKeyDown
}) => {
const [activeTab, setActiveTab] = useState<'editor' | 'raw'>('editor');
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const pendingImageResolveRef = useRef<((url: string) => void) | null>(null);
// Handler for image upload - opens the ImagePickerDialog
const handleImageUpload = useCallback((_file?: File): Promise<string> => {
console.log('[handleImageUpload] Called from image-block, opening ImagePickerDialog');
return new Promise((resolve) => {
pendingImageResolveRef.current = resolve;
console.log('[handleImageUpload] Resolve function stored in ref');
setImagePickerOpen(true);
});
}, []);
// Handler for image selection from picker
const handleImageSelect = useCallback(async (pictureId: string) => {
console.log('[handleImageSelect] Selected picture ID:', pictureId);
try {
// Fetch the image URL from Supabase
const { data, error } = await supabase
.from('pictures')
.select('image_url')
.eq('id', pictureId)
.single();
if (error) throw error;
const imageUrl = data.image_url;
const resolveFunc = pendingImageResolveRef.current;
if (resolveFunc) {
pendingImageResolveRef.current = null;
resolveFunc(imageUrl);
}
setImagePickerOpen(false);
} catch (error) {
console.error('[handleImageSelect] Error fetching image:', error);
if (pendingImageResolveRef.current) {
pendingImageResolveRef.current('');
pendingImageResolveRef.current = null;
}
setImagePickerOpen(false);
}
}, []);
const handleRawChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
}, [onChange]);
return (
<>
<div className={`border rounded-md bg-background ${className}`}>
<div className="flex border-b">
<button
type="button"
onClick={() => setActiveTab('editor')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'editor'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Editor
</button>
<button
type="button"
onClick={() => setActiveTab('raw')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'raw'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Markdown
</button>
</div>
{activeTab === 'editor' && (
<React.Suspense fallback={<div className="p-3 text-muted-foreground">Loading editor...</div>}>
<MilkdownEditorInternal
value={value}
onChange={onChange}
className={className}
/>
</React.Suspense>
)}
{activeTab === 'raw' && (
<textarea
value={value || ''}
onChange={handleRawChange}
onKeyDown={onKeyDown}
placeholder={placeholder}
className="w-full p-3 bg-transparent border-0 rounded-b-md focus:ring-0 focus:outline-none resize-none font-mono text-sm"
style={{ height: '120px', minHeight: '120px' }}
aria-label="Raw markdown input"
autoFocus
/>
)}
</div>
{/* Image Picker Dialog */}
<ImagePickerDialog
isOpen={imagePickerOpen}
onClose={() => {
setImagePickerOpen(false);
// Reject the promise if closed without selection
if (pendingImageResolveRef.current) {
pendingImageResolveRef.current('');
pendingImageResolveRef.current = null;
}
}}
onSelect={handleImageSelect}
currentValue={null}
/>
</>
);
};
MarkdownEditor.displayName = 'MarkdownEditor';
// Memoize with custom comparison
export default React.memo(MarkdownEditor, (prevProps, nextProps) => {
// Re-render if value, placeholder, className, or onKeyDown change
return (
prevProps.value === nextProps.value &&
prevProps.placeholder === nextProps.placeholder &&
prevProps.className === nextProps.className &&
prevProps.onKeyDown === nextProps.onKeyDown
// onChange is intentionally omitted to prevent unnecessary re-renders
);
});

View File

@ -0,0 +1,228 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { supabase } from '@/integrations/supabase/client';
// Lazy load the heavy editor component
const MDXEditorInternal = React.lazy(() => import('@/components/lazy-editors/MDXEditorInternal'));
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
onSelectionChange?: (selectedText: string) => void;
onSave?: () => void;
editorRef?: React.RefObject<any>;
}
interface MDXEditorWithImagePickerProps {
value: string;
onChange: (markdown: string) => void;
placeholder: string;
onRequestImage: () => Promise<string>;
onTextInsert: (text: string) => void;
onSelectionChange?: (selectedText: string) => void;
isFullscreen: boolean;
onToggleFullscreen: () => void;
onSave?: () => void;
editorRef: React.MutableRefObject<any>;
}
export function MDXEditorWithImagePicker(props: MDXEditorWithImagePickerProps) {
return (
<React.Suspense fallback={<div className="p-4 text-center text-muted-foreground">Loading editor...</div>}>
<MDXEditorInternal {...props} />
</React.Suspense>
);
}
const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
value,
onChange,
placeholder = "Enter description...",
className = "",
onKeyDown,
onSelectionChange,
onSave,
editorRef: externalEditorRef
}) => {
const [activeTab, setActiveTab] = useState<'editor' | 'raw'>('editor');
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const lastEmittedValue = useRef<string>(value);
const internalEditorRef = useRef<any>(null);
const editorRef = externalEditorRef || internalEditorRef;
const imageResolveRef = useRef<((url: string) => void) | null>(null);
const imageRejectRef = useRef<(() => void) | null>(null);
// Handle external value changes (e.g., from filter panel)
useEffect(() => {
if (value !== lastEmittedValue.current) {
lastEmittedValue.current = value;
// Force update the MDXEditor if it exists
if (editorRef.current && activeTab === 'editor') {
try {
editorRef.current.setMarkdown(value);
} catch (error) {
console.warn('Failed to update MDXEditor:', error);
}
}
}
}, [value, activeTab]);
const handleEditorChange = useCallback((markdown: string) => {
// Only call onChange if content actually changed
if (markdown !== lastEmittedValue.current) {
lastEmittedValue.current = markdown;
onChange(markdown);
}
}, [onChange]);
const handleTextInsert = useCallback((text: string) => {
// Append transcribed text to current markdown
const currentMarkdown = lastEmittedValue.current || '';
const newMarkdown = currentMarkdown
? `${currentMarkdown}\n\n${text}`
: text;
lastEmittedValue.current = newMarkdown;
onChange(newMarkdown);
// Force update the editor
if (editorRef.current) {
editorRef.current.setMarkdown(newMarkdown);
}
}, [onChange]);
const handleToggleFullscreen = useCallback(() => {
setIsFullscreen(prev => !prev);
}, []);
// This function returns a promise that resolves with the image URL
const handleRequestImage = useCallback((): Promise<string> => {
return new Promise((resolve, reject) => {
imageResolveRef.current = resolve;
imageRejectRef.current = reject;
setImagePickerOpen(true);
});
}, []);
const handleImageSelect = useCallback(async (pictureId: string) => {
try {
// Fetch the image URL from Supabase
const { data, error } = await supabase
.from('pictures')
.select('image_url')
.eq('id', pictureId)
.single();
if (error) throw error;
const imageUrl = data.image_url;
// Resolve the promise with the image URL
if (imageResolveRef.current) {
imageResolveRef.current(imageUrl);
imageResolveRef.current = null;
imageRejectRef.current = null;
}
setImagePickerOpen(false);
} catch (error) {
if (imageRejectRef.current) {
imageRejectRef.current();
imageRejectRef.current = null;
imageResolveRef.current = null;
}
setImagePickerOpen(false);
}
}, []);
const handleRawChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
lastEmittedValue.current = newValue;
onChange(newValue);
}, [onChange]);
return (
<>
<div className={`flex flex-col bg-background ${className} ${isFullscreen ? '' : 'border rounded-md'}`}>
<div className="flex border-b flex-shrink-0">
<button
type="button"
onClick={() => setActiveTab('editor')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'editor'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Editor
</button>
<button
type="button"
onClick={() => setActiveTab('raw')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'raw'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Markdown
</button>
</div>
{activeTab === 'editor' && (
<div className={`mdx-editor-wrapper flex-1 overflow-y-auto ${isFullscreen ? '' : 'min-h-[120px]'}`}>
<MDXEditorWithImagePicker
value={value}
onChange={handleEditorChange}
placeholder={placeholder}
onRequestImage={handleRequestImage}
onTextInsert={handleTextInsert}
onSelectionChange={onSelectionChange}
isFullscreen={isFullscreen}
onToggleFullscreen={handleToggleFullscreen}
onSave={onSave}
editorRef={editorRef}
/>
</div>
)}
{activeTab === 'raw' && (
<textarea
value={value || ''}
onChange={handleRawChange}
onKeyDown={onKeyDown}
placeholder={placeholder}
className={`flex-1 w-full bg-transparent border-0 focus:ring-0 focus:outline-none resize-none font-mono text-sm ${isFullscreen ? '' : 'p-3 rounded-b-md min-h-[120px]'
}`}
aria-label="Raw markdown input"
autoFocus
/>
)}
</div>
{/* Image Picker Dialog */}
<ImagePickerDialog
isOpen={imagePickerOpen}
onClose={() => {
setImagePickerOpen(false);
// Reject the promise if closed without selection
if (imageRejectRef.current) {
imageRejectRef.current();
imageRejectRef.current = null;
imageResolveRef.current = null;
}
}}
onSelect={handleImageSelect}
currentValue={null}
/>
</>
);
};
MarkdownEditor.displayName = 'MarkdownEditor';
// Memoize with default shallow comparison
export default React.memo(MarkdownEditor);

View File

@ -0,0 +1,129 @@
import React, { useMemo, useEffect, useRef } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import HashtagText from './HashtagText';
import Prism from 'prismjs';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-css';
import 'prismjs/components/prism-markup';
import '../styles/prism-custom-theme.css';
interface MarkdownRendererProps {
content: string;
className?: string;
}
const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRendererProps) => {
const containerRef = React.useRef<HTMLDivElement>(null);
// Memoize content analysis
const contentAnalysis = useMemo(() => {
const hasHashtags = /#[a-zA-Z0-9_]+/.test(content);
const hasMarkdownLinks = /\[.*?\]\(.*?\)/.test(content);
const hasMarkdownSyntax = /(\*\*|__|##?|###?|####?|#####?|######?|\*|\n\*|\n-|\n\d+\.)/.test(content);
return {
hasHashtags,
hasMarkdownLinks,
hasMarkdownSyntax
};
}, [content]);
// Only use HashtagText if content has hashtags but NO markdown syntax at all
// This preserves hashtag linking for simple text while allowing markdown to render properly
if (contentAnalysis.hasHashtags && !contentAnalysis.hasMarkdownLinks && !contentAnalysis.hasMarkdownSyntax) {
return (
<div className={`prose prose-sm max-w-none dark:prose-invert ${className}`}>
<HashtagText>{content}</HashtagText>
</div>
);
}
// Memoize the expensive HTML processing
const htmlContent = useMemo(() => {
// Decode HTML entities first if present
const decodedContent = content
.replace(/&#x20;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
// Configure marked options for regular markdown
marked.setOptions({
breaks: true, // Convert \n to <br>
gfm: true, // GitHub flavored markdown
});
// Convert markdown to HTML
const rawHtml = marked.parse(decodedContent) as string;
// Helper function to format URL display text
const formatUrlDisplay = (url: string): string => {
try {
// Remove protocol
let displayUrl = url.replace(/^https?:\/\//, '');
// Remove www. if present
displayUrl = displayUrl.replace(/^www\./, '');
// Truncate if too long (keep domain + some path)
if (displayUrl.length > 40) {
const parts = displayUrl.split('/');
const domain = parts[0];
const path = parts.slice(1).join('/');
if (path.length > 20) {
displayUrl = `${domain}/${path.substring(0, 15)}...`;
} else {
displayUrl = `${domain}/${path}`;
}
}
return displayUrl;
} catch {
return url;
}
};
// Post-process to add target="_blank", styling, and format link text
const processedHtml = rawHtml.replace(
/<a href="([^"]*)"([^>]*)>([^<]*)<\/a>/g,
(match, href, attrs, text) => {
// If the link text is the same as the URL (auto-generated), format it nicely
const isAutoLink = text === href || text.replace(/^https?:\/\//, '') === href.replace(/^https?:\/\//, '');
const displayText = isAutoLink ? formatUrlDisplay(href) : text;
return `<a href="${href}"${attrs} target="_blank" rel="noopener noreferrer" class="text-primary hover:text-primary/80 underline hover:no-underline transition-colors">${displayText}</a>`;
}
);
return DOMPurify.sanitize(processedHtml, {
ADD_ATTR: ['target', 'rel', 'class'] // Allow target, rel, and class attributes for links
});
}, [content]);
// Apply syntax highlighting after render
React.useEffect(() => {
if (containerRef.current) {
Prism.highlightAllUnder(containerRef.current);
}
}, [htmlContent]);
return (
<div
ref={containerRef}
className={`prose prose-sm max-w-none dark:prose-invert ${className}`}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
);
});
MarkdownRenderer.displayName = 'MarkdownRenderer';
export default MarkdownRenderer;

View File

@ -0,0 +1,158 @@
/**
* MediaCard - Unified component that renders the appropriate card type
* based on the media type from the 'pictures' table
*/
import React from 'react';
import PhotoCard from './PhotoCard';
import VideoCard from '@/components/VideoCard';
import PageCard from '@/components/PageCard';
import { normalizeMediaType, MEDIA_TYPES, type MediaType } from '@/lib/mediaRegistry';
interface MediaCardProps {
id: string;
pictureId?: string; // Add pictureId explicitly
url: string;
thumbnailUrl?: string | null;
title: string;
author: string;
authorId: string;
likes: number;
comments: number;
isLiked?: boolean;
description?: string | null;
type: MediaType;
meta?: any;
responsive?: any; // Keeping as any for now to avoid tight coupling or import ResponsiveData
onClick?: (id: string) => void;
onLike?: () => void;
onDelete?: () => void;
onEdit?: (id: string) => void;
created_at?: string;
authorAvatarUrl?: string | null;
showContent?: boolean;
job?: any;
variant?: 'grid' | 'feed';
apiUrl?: string;
}
const MediaCard: React.FC<MediaCardProps> = ({
id,
pictureId,
url,
thumbnailUrl,
title,
author,
authorAvatarUrl,
authorId,
likes,
comments,
isLiked,
description,
type,
meta,
onClick,
onLike,
onDelete,
onEdit,
created_at,
showContent = true,
responsive,
job,
variant = 'grid',
apiUrl
}) => {
const normalizedType = normalizeMediaType(type);
// Render based on type
if (normalizedType === 'tiktok') {
return (
<div className="w-full h-full bg-black flex justify-center aspect-[9/16] rounded-md overflow-hidden shadow-sm border relative">
<iframe
src={url}
className="w-full h-full border-0"
title={title}
></iframe>
</div>
);
}
if (normalizedType === MEDIA_TYPES.VIDEO_INTERN) {
return (
<VideoCard
videoId={pictureId || id}
videoUrl={url}
thumbnailUrl={thumbnailUrl || undefined}
title={title}
author={author}
authorId={authorId}
likes={likes}
comments={comments}
isLiked={isLiked}
description={description}
onClick={() => onClick?.(id)}
onLike={onLike}
onDelete={onDelete}
showContent={showContent}
created_at={created_at}
authorAvatarUrl={authorAvatarUrl}
job={job}
variant={variant}
apiUrl={apiUrl}
/>
);
}
if (normalizedType === MEDIA_TYPES.PAGE || normalizedType === MEDIA_TYPES.PAGE_EXTERNAL) {
return (
<PageCard
id={id}
url={url} // For external pages, this is the link
thumbnailUrl={thumbnailUrl} // Preview image
title={title}
author={author}
authorId={authorId}
authorAvatarUrl={authorAvatarUrl}
likes={likes}
comments={comments}
isLiked={isLiked}
description={description}
type={type}
meta={meta}
onClick={() => onClick?.(id)}
onLike={onLike}
onDelete={onDelete}
showContent={showContent}
created_at={created_at}
responsive={responsive}
variant={variant}
apiUrl={apiUrl}
/>
);
}
// Default to PhotoCard for images (type === null or 'supabase-image')
return (
<PhotoCard
pictureId={pictureId || id} // Use pictureId if available
image={url}
title={title}
author={author}
authorId={authorId}
likes={likes}
comments={comments}
isLiked={isLiked}
description={description}
onClick={onClick}
onLike={onLike}
onDelete={onDelete}
onEdit={onEdit}
createdAt={created_at}
authorAvatarUrl={authorAvatarUrl}
showContent={showContent}
responsive={responsive}
variant={variant}
apiUrl={apiUrl}
/>
);
};
export default MediaCard;

View File

@ -0,0 +1,73 @@
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Building2 } from "lucide-react";
import { Link } from "react-router-dom";
const OrganizationsList = () => {
const { data: organizations, isLoading } = useQuery({
queryKey: ["organizations"],
queryFn: async () => {
const { data, error } = await supabase
.from("organizations")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return data;
},
});
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardHeader>
<div className="h-6 bg-muted rounded w-3/4 mb-2" />
<div className="h-4 bg-muted rounded w-1/2" />
</CardHeader>
</Card>
))}
</div>
);
}
if (!organizations || organizations.length === 0) {
return null;
}
return (
<div className="py-16 px-4">
<div className="text-center mb-12">
<h2 className="text-4xl font-bold mb-4">
<span className="bg-gradient-primary bg-clip-text text-transparent">
Organizations
</span>
</h2>
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
Explore creative communities and their collections
</p>
</div>
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{organizations.map((org) => (
<Link key={org.id} to={`/org/${org.slug}`}>
<Card className="hover:shadow-lg transition-all duration-300 hover:scale-105 cursor-pointer h-full">
<CardHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-primary/10">
<Building2 className="w-6 h-6 text-primary" />
</div>
</div>
<CardTitle className="text-xl">{org.name}</CardTitle>
<CardDescription>@{org.slug}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
</div>
);
};
export default OrganizationsList;

View File

@ -0,0 +1,259 @@
import React from 'react';
import { FileText, Heart, MessageCircle, Maximize, Play } from "lucide-react";
import { Button } from "@/components/ui/button";
import ResponsiveImage from "@/components/ResponsiveImage";
import UserAvatarBlock from "@/components/UserAvatarBlock";
import MarkdownRenderer from "@/components/MarkdownRenderer";
import type { MediaRendererProps } from "@/lib/mediaRegistry";
import { getTikTokVideoId, getYouTubeVideoId } from "@/utils/mediaUtils";
interface PageCardProps extends Omit<MediaRendererProps, 'created_at'> {
variant?: 'grid' | 'feed';
responsive?: any;
showContent?: boolean;
authorAvatarUrl?: string | null;
created_at?: string;
apiUrl?: string;
}
const PageCard: React.FC<PageCardProps> = ({
id,
url,
thumbnailUrl,
title,
author,
authorId,
authorAvatarUrl,
likes,
comments,
isLiked,
description,
onClick,
onLike,
created_at,
variant = 'grid',
responsive,
showContent = true,
apiUrl
}) => {
// Determine image source
// If url is missing or empty, fallback to picsum
// For PAGE_EXTERNAL, currently 'url' is the link and 'thumbnailUrl' is the image.
const displayImage = thumbnailUrl || url || "https://picsum.photos/640";
const [isPlaying, setIsPlaying] = React.useState(false);
// Check for External Video Types
const tikTokId = getTikTokVideoId(url);
const ytId = getYouTubeVideoId(url);
const isExternalVideo = !!(tikTokId || ytId);
// Use thumbnail if available and preferred (logic from MediaCard usually handles this before passing url,
// but here we ensure we have *something*).
const handleLike = (e: React.MouseEvent) => {
e.stopPropagation();
onLike?.();
};
const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(id);
};
if (variant === 'feed') {
return (
<div
className="group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full border rounded-lg mb-4"
onClick={handleCardClick}
>
<div className="p-4 border-b flex items-center justify-between">
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author}
className="w-8 h-8"
showDate={true}
createdAt={created_at}
/>
</div>
<div className={`relative w-full ${tikTokId ? 'aspect-[9/16]' : 'aspect-[16/9]'} overflow-hidden bg-muted`}>
{isPlaying && isExternalVideo ? (
<div className="w-full h-full bg-black flex justify-center">
<iframe
src={tikTokId
? `https://www.tiktok.com/embed/v2/${tikTokId}`
: `https://www.youtube.com/embed/${ytId}?autoplay=1`
}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={title}
></iframe>
</div>
) : (
<>
<ResponsiveImage
src={displayImage}
alt={title}
className="w-full h-full"
imgClassName="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
sizes="100vw"
data={responsive}
apiUrl={apiUrl}
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{isExternalVideo ? (
<div
className="bg-black/50 p-4 rounded-full backdrop-blur-sm pointer-events-auto cursor-pointer hover:bg-black/70 transition-colors"
onClick={(e) => {
e.stopPropagation();
setIsPlaying(true);
}}
>
<Play className="w-8 h-8 text-white fill-white" />
</div>
) : (
<div className="bg-black/50 p-4 rounded-full backdrop-blur-sm">
<FileText className="w-8 h-8 text-white" />
</div>
)}
</div>
</>
)}
</div>
<div className="p-4 space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold">{title}</h3>
</div>
{description && (
<div className="text-sm text-foreground/90 line-clamp-3">
<MarkdownRenderer content={description} className="prose-sm dark:prose-invert" />
</div>
)}
<div className="flex items-center gap-4 pt-2">
<Button size="sm" variant="ghost" className="px-0 gap-1" onClick={handleLike}>
<Heart className={`h-5 w-5 ${isLiked ? "fill-red-500 text-red-500" : ""}`} />
<span>{likes}</span>
</Button>
<Button size="sm" variant="ghost" className="px-0 gap-1">
<MessageCircle className="h-5 w-5" />
<span>{comments}</span>
</Button>
</div>
</div>
</div>
);
}
// Grid Variant
return (
<div
className={`group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full ${isPlaying && tikTokId ? 'aspect-[9/16]' : 'aspect-square'}`}
onClick={(e) => {
if (isExternalVideo && !isPlaying) {
e.stopPropagation();
setIsPlaying(true);
} else {
handleCardClick(e);
}
}}
>
{isPlaying && isExternalVideo ? (
<div className="w-full h-full bg-black flex justify-center">
<iframe
src={tikTokId
? `https://www.tiktok.com/embed/v2/${tikTokId}`
: `https://www.youtube.com/embed/${ytId}?autoplay=1`
}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={title}
></iframe>
</div>
) : (
<>
<ResponsiveImage
src={displayImage}
alt={title}
className="w-full h-full"
imgClassName="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
responsiveSizes={[320, 640, 1024]}
data={responsive}
apiUrl={apiUrl}
/>
<div className="absolute top-2 right-2 p-1.5 bg-black/60 rounded-md backdrop-blur-sm z-10">
{isExternalVideo ? (
<Play className="w-4 h-4 text-white fill-white" />
) : (
<FileText className="w-4 h-4 text-white" />
)}
</div>
{isExternalVideo && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="bg-black/50 p-3 rounded-full backdrop-blur-sm group-hover:bg-black/70 transition-colors">
<Play className="w-8 h-8 text-white fill-white" />
</div>
</div>
)}
{showContent && (
<div className="hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
<div className="absolute bottom-0 left-0 right-0 p-4 pointer-events-auto">
<div className="flex items-center justify-between mb-2">
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author}
hoverStyle={true}
showDate={false}
/>
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="ghost"
onClick={handleLike}
className={`h-8 w-8 p-0 ${isLiked ? "text-red-500" : "text-white hover:text-red-500"}`}
>
<Heart className="h-4 w-4" fill={isLiked ? "currentColor" : "none"} />
</Button>
</div>
</div>
<h3 className="text-white font-medium mb-1 line-clamp-1">{title}</h3>
{description && (
<div className="text-white/80 text-sm mb-1 line-clamp-2">
<MarkdownRenderer content={description} className="prose-invert prose-white" />
</div>
)}
</div>
</div>
)}
{/* Mobile Footer used in Grid view on mobile */}
<div className="md:hidden absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/80 to-transparent">
<div className="flex items-center justify-between text-white">
<span className="text-xs font-medium truncate flex-1 mr-2">{title}</span>
{isExternalVideo ? (
<Play className="w-3 h-3 flex-shrink-0 fill-white" />
) : (
<FileText className="w-3 h-3 flex-shrink-0" />
)}
</div>
</div>
</>
)}
</div>
);
};
export default PageCard;

View File

@ -0,0 +1,312 @@
import { useState, useEffect } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Link, useNavigate } from "react-router-dom";
import { FileText, Plus, Eye, EyeOff, Trash2, Edit } from "lucide-react";
import { T, translate } from "@/i18n";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Page {
id: string;
title: string;
slug: string;
content: any;
owner: string;
parent: string | null;
type: string | null;
tags: string[] | null;
is_public: boolean;
visible: boolean;
created_at: string;
updated_at: string;
}
interface PageManagerProps {
userId: string;
isOwnProfile: boolean;
orgSlug?: string;
}
const PageManager = ({ userId, isOwnProfile, orgSlug }: PageManagerProps) => {
const { user } = useAuth();
const navigate = useNavigate();
const [pages, setPages] = useState<Page[]>([]);
const [loading, setLoading] = useState(true);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [pageToDelete, setPageToDelete] = useState<Page | null>(null);
useEffect(() => {
fetchPages();
}, [userId]);
const fetchPages = async () => {
try {
setLoading(true);
let query = supabase
.from('pages')
.select('*')
.eq('owner', userId)
.order('created_at', { ascending: false });
// If not own profile, only show public and visible pages
if (!isOwnProfile) {
query = query.eq('is_public', true).eq('visible', true);
}
const { data, error } = await query;
if (error) throw error;
setPages(data || []);
} catch (error) {
console.error('Error fetching pages:', error);
toast.error(translate('Failed to load pages'));
} finally {
setLoading(false);
}
};
const handleDeletePage = async () => {
if (!pageToDelete || !isOwnProfile) return;
try {
const { error } = await supabase
.from('pages')
.delete()
.eq('id', pageToDelete.id)
.eq('owner', user?.id);
if (error) throw error;
setPages(pages.filter(p => p.id !== pageToDelete.id));
toast.success(translate('Page deleted successfully'));
setDeleteDialogOpen(false);
setPageToDelete(null);
} catch (error) {
console.error('Error deleting page:', error);
toast.error(translate('Failed to delete page'));
}
};
const handleToggleVisibility = async (page: Page) => {
if (!isOwnProfile) return;
try {
const { error } = await supabase
.from('pages')
.update({ visible: !page.visible })
.eq('id', page.id)
.eq('owner', user?.id);
if (error) throw error;
setPages(pages.map(p =>
p.id === page.id ? { ...p, visible: !p.visible } : p
));
toast.success(translate(page.visible ? 'Page hidden' : 'Page made visible'));
} catch (error) {
console.error('Error toggling visibility:', error);
toast.error(translate('Failed to update page visibility'));
}
};
const handleTogglePublic = async (page: Page) => {
if (!isOwnProfile) return;
try {
const { error } = await supabase
.from('pages')
.update({ is_public: !page.is_public })
.eq('id', page.id)
.eq('owner', user?.id);
if (error) throw error;
setPages(pages.map(p =>
p.id === page.id ? { ...p, is_public: !p.is_public } : p
));
toast.success(translate(page.is_public ? 'Page made private' : 'Page made public'));
} catch (error) {
console.error('Error toggling public status:', error);
toast.error(translate('Failed to update page status'));
}
};
const getPageUrl = (slug: string) => {
if (orgSlug) {
return `/org/${orgSlug}/user/${userId}/pages/${slug}`;
}
return `/user/${userId}/pages/${slug}`;
};
if (loading) {
return (
<div className="text-center py-8">
<div className="text-muted-foreground"><T>Loading pages...</T></div>
</div>
);
}
if (!isOwnProfile && pages.length === 0) {
return null; // Don't show anything if not own profile and no pages
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold"><T>Pages</T></h3>
{isOwnProfile && (
<Button
size="sm"
variant="outline"
onClick={() => {
const newPageUrl = orgSlug
? `/org/${orgSlug}/user/${userId}/pages/new`
: `/user/${userId}/pages/new`;
navigate(newPageUrl);
}}
>
<Plus className="h-4 w-4 mr-2" />
<T>New Page</T>
</Button>
)}
</div>
{pages.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center">
<FileText className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
<T>{isOwnProfile ? "No pages yet. Create your first page to get started." : "No pages to display."}</T>
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{pages.map((page) => (
<Card key={page.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-base">
<Link
to={getPageUrl(page.slug)}
className="hover:text-primary transition-colors"
>
{page.title}
</Link>
</CardTitle>
<div className="mt-1 text-xs text-muted-foreground flex items-center gap-2">
{!page.visible && (
<span className="flex items-center gap-1 text-orange-500">
<EyeOff className="h-3 w-3" />
<T>Hidden</T>
</span>
)}
{!page.is_public && (
<span className="flex items-center gap-1 text-blue-500">
<T>Private</T>
</span>
)}
{page.type && (
<span className="text-muted-foreground">
{page.type}
</span>
)}
</div>
</div>
<FileText className="h-5 w-5 text-muted-foreground" />
</div>
</CardHeader>
{isOwnProfile && (
<CardContent>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleToggleVisibility(page)}
title={page.visible ? translate('Hide page') : translate('Show page')}
>
{page.visible ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleTogglePublic(page)}
title={page.is_public ? translate('Make private') : translate('Make public')}
>
{page.is_public ? (
<span className="text-xs">Public</span>
) : (
<span className="text-xs">Private</span>
)}
</Button>
<Link to={getPageUrl(page.slug)}>
<Button size="sm" variant="ghost">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button
size="sm"
variant="ghost"
onClick={() => {
setPageToDelete(page);
setDeleteDialogOpen(true);
}}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
)}
</Card>
))}
</div>
)}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle><T>Delete Page</T></AlertDialogTitle>
<AlertDialogDescription>
<T>Are you sure you want to delete "{pageToDelete?.title}"? This action cannot be undone.</T>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPageToDelete(null)}>
<T>Cancel</T>
</AlertDialogCancel>
<AlertDialogAction onClick={handleDeletePage} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
<T>Delete</T>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default PageManager;

View File

@ -0,0 +1,695 @@
import { Heart, Download, Share2, User, MessageCircle, Edit3, Trash2, Maximize, Layers } from "lucide-react";
import { Button } from "@/components/ui/button";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useState, useEffect } from "react";
import MarkdownRenderer from "@/components/MarkdownRenderer";
import EditImageModal from "@/components/EditImageModal";
import ImageLightbox from "@/components/ImageLightbox";
import MagicWizardButton from "@/components/MagicWizardButton";
import { downloadImage, generateFilename } from "@/utils/downloadUtils";
import { editImage } from "@/image-api";
import { usePostNavigation } from "@/contexts/PostNavigationContext";
import { useNavigate } from "react-router-dom";
import ResponsiveImage from "@/components/ResponsiveImage";
import { T, translate } from "@/i18n";
import { isLikelyFilename, formatDate } from "@/utils/textUtils";
import UserAvatarBlock from "@/components/UserAvatarBlock";
interface PhotoCardProps {
pictureId: string;
image: string;
title: string;
author: string;
authorId: string;
likes: number;
comments: number;
isLiked?: boolean;
description?: string | null;
onClick?: (pictureId: string) => void;
onLike?: () => void;
onDelete?: () => void;
isVid?: boolean;
onEdit?: (pictureId: string) => void;
createdAt?: string;
authorAvatarUrl?: string | null;
showContent?: boolean;
responsive?: any;
variant?: 'grid' | 'feed';
apiUrl?: string;
}
const PhotoCard = ({
pictureId,
image,
title,
author,
authorId,
likes,
comments,
isLiked = false,
description,
onClick,
onLike,
onDelete,
isVid,
onEdit,
createdAt,
authorAvatarUrl,
showContent = true,
responsive,
variant = 'grid',
apiUrl
}: PhotoCardProps) => {
const { user } = useAuth();
const navigate = useNavigate();
const { navigationData, setNavigationData, preloadImage } = usePostNavigation();
const [localIsLiked, setLocalIsLiked] = useState(isLiked);
const [localLikes, setLocalLikes] = useState(likes);
const [showEditModal, setShowEditModal] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showLightbox, setShowLightbox] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null);
const [versionCount, setVersionCount] = useState<number>(0);
const isOwner = user?.id === authorId;
// Fetch version count for owners only
useEffect(() => {
const fetchVersionCount = async () => {
if (!isOwner || !user) return;
try {
// Count pictures that have this picture as parent OR pictures that share the same parent
const { data: currentPicture } = await supabase
.from('pictures')
.select('parent_id')
.eq('id', pictureId)
.single();
if (!currentPicture) return;
let query = supabase
.from('pictures')
.select('id', { count: 'exact', head: true });
if (currentPicture.parent_id) {
// This is a version - count all versions with same parent_id + the parent itself
query = query.or(`parent_id.eq.${currentPicture.parent_id},id.eq.${currentPicture.parent_id}`);
} else {
// This is the original - count this picture + all its versions
query = query.or(`parent_id.eq.${pictureId},id.eq.${pictureId}`);
}
const { count } = await query;
// console.log('Version count:', count);
setVersionCount(count || 1);
} catch (error) {
console.error('Error fetching version count:', error);
}
};
fetchVersionCount();
}, [pictureId, isOwner, user]);
const handleLike = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!user) {
toast.error(translate('Please sign in to like pictures'));
return;
}
try {
if (localIsLiked) {
// Unlike
const { error } = await supabase
.from('likes')
.delete()
.eq('user_id', user.id)
.eq('picture_id', pictureId);
if (error) throw error;
setLocalIsLiked(false);
setLocalLikes(prev => prev - 1);
} else {
// Like
const { error } = await supabase
.from('likes')
.insert([{ user_id: user.id, picture_id: pictureId }]);
if (error) throw error;
setLocalIsLiked(true);
setLocalLikes(prev => prev + 1);
}
onLike?.();
} catch (error) {
console.error('Error toggling like:', error);
toast.error(translate('Failed to update like'));
}
};
const handleDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!user || !isOwner) {
toast.error(translate('You can only delete your own images'));
return;
}
if (!confirm(translate('Are you sure you want to delete this image? This action cannot be undone.'))) {
return;
}
setIsDeleting(true);
try {
// First get the picture details for storage cleanup
const { data: picture, error: fetchError } = await supabase
.from('pictures')
.select('image_url')
.eq('id', pictureId)
.single();
if (fetchError) throw fetchError;
// Delete from database (this will cascade delete likes and comments due to foreign keys)
const { error: deleteError } = await supabase
.from('pictures')
.delete()
.eq('id', pictureId);
if (deleteError) throw deleteError;
// Try to delete from storage as well
if (picture?.image_url) {
const urlParts = picture.image_url.split('/');
const fileName = urlParts[urlParts.length - 1];
const userIdFromUrl = urlParts[urlParts.length - 2];
const { error: storageError } = await supabase.storage
.from('pictures')
.remove([`${userIdFromUrl}/${fileName}`]);
if (storageError) {
console.error('Error deleting from storage:', storageError);
// Don't show error to user as the main deletion succeeded
}
}
toast.success(translate('Image deleted successfully'));
onDelete?.(); // Trigger refresh of the parent component
} catch (error) {
console.error('Error deleting image:', error);
toast.error(translate('Failed to delete image'));
} finally {
setIsDeleting(false);
}
};
const handleDownload = async () => {
try {
const filename = generateFilename(title);
await downloadImage(image, filename);
toast.success(translate('Image downloaded successfully'));
} catch (error) {
console.error('Error downloading image:', error);
toast.error(translate('Failed to download image'));
}
};
const handleLightboxOpen = () => {
// Update current index in navigation data
if (navigationData) {
const currentIndex = navigationData.posts.findIndex(p => p.id === pictureId);
if (currentIndex !== -1) {
setNavigationData({
...navigationData,
currentIndex
});
}
}
setShowLightbox(true);
};
const handleNavigate = (direction: 'prev' | 'next') => {
if (!navigationData || !navigationData.posts.length) {
toast.error(translate('No navigation data available'));
return;
}
const newIndex = direction === 'next'
? navigationData.currentIndex + 1
: navigationData.currentIndex - 1;
if (newIndex >= 0 && newIndex < navigationData.posts.length) {
const newPost = navigationData.posts[newIndex];
setNavigationData({
...navigationData,
currentIndex: newIndex
});
// Navigate to the new post
navigate(`/post/${newPost.id}`);
} else {
toast.info(translate(direction === 'next' ? 'No next post available' : 'No previous post available'));
}
};
const handlePreload = (direction: 'prev' | 'next') => {
if (!navigationData) return;
const targetIndex = direction === 'next'
? navigationData.currentIndex + 1
: navigationData.currentIndex - 1;
if (targetIndex >= 0 && targetIndex < navigationData.posts.length) {
const targetPost = navigationData.posts[targetIndex];
preloadImage(targetPost.image_url);
}
};
const handlePromptSubmit = async (prompt: string) => {
if (!prompt.trim()) {
toast.error(translate('Please enter a prompt'));
return;
}
setIsGenerating(true);
try {
// Convert image URL to File for API
const response = await fetch(image);
const blob = await response.blob();
const file = new File([blob], title || 'image.png', {
type: blob.type || 'image/png'
});
const result = await editImage(prompt, [file]);
if (result) {
// Convert ArrayBuffer to base64 data URL
const uint8Array = new Uint8Array(result.imageData);
const imageBlob = new Blob([uint8Array], { type: 'image/png' });
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
setGeneratedImageUrl(dataUrl);
toast.success(translate('Image generated successfully!'));
};
reader.readAsDataURL(imageBlob);
}
} catch (error) {
console.error('Error generating image:', error);
toast.error(translate('Failed to generate image. Please check your Google API key.'));
} finally {
setIsGenerating(false);
}
};
const handlePublish = async (option: 'overwrite' | 'new', imageUrl: string, newTitle: string, description?: string) => {
if (!user) {
toast.error(translate('Please sign in to publish images'));
return;
}
setIsPublishing(true);
try {
// Convert data URL to blob for upload
const response = await fetch(imageUrl);
const blob = await response.blob();
if (option === 'overwrite') {
// Overwrite the existing image
// First, get the current image URL to extract the file path
const currentImageUrl = image;
if (currentImageUrl.includes('supabase.co/storage/')) {
const urlParts = currentImageUrl.split('/');
const fileName = urlParts[urlParts.length - 1];
const bucketPath = `${authorId}/${fileName}`;
// Upload new image with same path (overwrite)
const { error: uploadError } = await supabase.storage
.from('pictures')
.update(bucketPath, blob, {
cacheControl: '0', // Disable caching to ensure immediate update
upsert: true
});
if (uploadError) throw uploadError;
toast.success(translate('Image updated successfully!'));
} else {
toast.error(translate('Cannot overwrite this image'));
return;
}
} else {
// Create new image
const fileName = `${user.id}/${Date.now()}-generated.png`;
const { data: uploadData, error: uploadError } = await supabase.storage
.from('pictures')
.upload(fileName, blob);
if (uploadError) throw uploadError;
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('pictures')
.getPublicUrl(fileName);
// Save to database
const { error: dbError } = await supabase
.from('pictures')
.insert([{
title: newTitle,
description: description || translate('Generated from') + `: ${title}`,
image_url: publicUrl,
user_id: user.id
}]);
if (dbError) throw dbError;
toast.success(translate('Image published to gallery!'));
}
setShowLightbox(false);
setGeneratedImageUrl(null);
// Trigger refresh if available
onLike?.();
} catch (error) {
console.error('Error publishing image:', error);
toast.error(translate('Failed to publish image'));
} finally {
setIsPublishing(false);
}
};
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onClick?.(pictureId);
};
const handleCardClick = (e: React.MouseEvent) => {
if (isVid) {
handleLightboxOpen();
} else {
handleClick(e);
}
};
return (
<div
data-testid="photo-card"
className="group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full"
onClick={handleCardClick}
>
{/* Image */}
<div className={`${variant === 'grid' ? "aspect-square" : ""} overflow-hidden`}>
<ResponsiveImage
src={image}
alt={title}
className="w-full h-full"
imgClassName="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
sizes={variant === 'grid'
? "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
: "100vw"
}
responsiveSizes={variant === 'grid' ? [320, 640, 1024, 1280] : [640, 1024, 1280, 1920]} // 1920 added
data={responsive}
apiUrl={apiUrl}
/>
</div>
{/* Desktop Hover Overlay - hidden on mobile, and hidden in feed variant */}
{showContent && variant === 'grid' && (
<div className="hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
<div className="absolute bottom-0 left-0 right-0 p-4 pointer-events-auto">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author}
hoverStyle={true}
showDate={false}
/>
</div>
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="ghost"
onClick={handleLike}
className={`h-8 w-8 p-0 ${localIsLiked ? "text-red-500" : "text-white hover:text-red-500"
}`}
>
<Heart className="h-4 w-4" fill={localIsLiked ? "currentColor" : "none"} />
</Button>
{localLikes > 0 && <span className="text-white text-sm">{localLikes}</span>}
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 text-white hover:text-blue-400 ml-2"
>
<MessageCircle className="h-4 w-4" />
</Button>
<span className="text-white text-sm">{comments}</span>
{isOwner && (
<>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
if (onEdit) {
onEdit(pictureId);
} else {
setShowEditModal(true);
}
}}
className="h-8 w-8 p-0 text-white hover:text-green-400 ml-2"
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleDelete}
disabled={isDeleting}
className="h-8 w-8 p-0 text-white hover:text-red-400 ml-2"
>
<Trash2 className="h-4 w-4" />
</Button>
{versionCount > 1 && (
<div className="flex items-center ml-2 px-2 py-1 bg-white/20 rounded text-white text-xs">
<Layers className="h-3 w-3 mr-1" />
{versionCount}
</div>
)}
</>
)}
</div>
</div>
{!isLikelyFilename(title) && <h3 className="text-white font-medium mb-1">{title}</h3>}
{description && (
<div className="text-white/80 text-sm mb-1 line-clamp-2 overflow-hidden">
<MarkdownRenderer content={description} className="prose-invert prose-white" />
</div>
)}
{createdAt && (
<div className="text-white/60 text-xs mb-2">
{formatDate(createdAt)}
</div>
)}
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="secondary"
className="h-6 px-2 text-xs bg-white/20 hover:bg-white/30 border-0 text-white"
onClick={(e) => {
e.stopPropagation();
handleDownload();
}}
>
<Download className="h-3 w-3 mr-1" />
<T>Save</T>
</Button>
<Button
size="sm"
variant="secondary"
className="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 border-0 text-white"
onClick={(e) => {
e.stopPropagation();
handleLightboxOpen();
}}
title="View in lightbox"
>
<Maximize className="h-2.5 w-2.5" />
</Button>
<Button size="sm" variant="secondary" className="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 border-0 text-white">
<Share2 className="h-2.5 w-2.5" />
</Button>
<MagicWizardButton
imageUrl={image}
imageTitle={title}
size="sm"
variant="ghost"
className="h-6 px-2 text-xs bg-white/20 hover:bg-white/30 border-0 text-white"
/>
</div>
</div>
</div>
)}
{/* Mobile/Feed Content - always visible below image */}
{showContent && (variant === 'feed' || (variant === 'grid' && true)) && (
<div className={`${variant === 'grid' ? "md:hidden" : ""} pb-2 space-y-2`}>
{/* Row 1: User Avatar (Left) + Actions (Right) */}
<div className="flex items-center justify-between px-2 pt-2">
{/* User Avatar Block */}
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author === 'User' ? undefined : author}
className="w-8 h-8"
showDate={false}
/>
{/* Actions */}
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
onClick={handleLike}
className={localIsLiked ? "text-red-500 hover:text-red-600" : ""}
>
<Heart className="h-6 w-6" fill={localIsLiked ? "currentColor" : "none"} />
</Button>
{localLikes > 0 && (
<span className="text-sm font-medium text-foreground mr-1">{localLikes}</span>
)}
<Button
size="icon"
variant="ghost"
className="text-foreground"
>
<MessageCircle className="h-6 w-6 -rotate-90" />
</Button>
{comments > 0 && (
<span className="text-sm font-medium text-foreground mr-1">{comments}</span>
)}
<Button
size="icon"
variant="ghost"
className="text-foreground"
onClick={(e) => {
e.stopPropagation();
handleDownload();
}}
>
<Download className="h-6 w-6" />
</Button>
<MagicWizardButton
imageUrl={image}
imageTitle={title}
size="icon"
variant="ghost"
className="text-foreground hover:text-primary"
/>
{isOwner && (
<Button
size="icon"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
if (onEdit) {
onEdit(pictureId);
} else {
setShowEditModal(true);
}
}}
className="text-foreground hover:text-green-400"
>
<Edit3 className="h-6 w-6" />
</Button>
)}
</div>
</div>
{/* Likes */}
{/* Caption / Description section */}
<div className="px-4 space-y-1">
{(!isLikelyFilename(title) && title) && (
<div className="font-semibold text-sm">{title}</div>
)}
{description && (
<div className="text-sm text-foreground/90 line-clamp-3">
<MarkdownRenderer content={description} className="prose-sm dark:prose-invert" />
</div>
)}
{createdAt && (
<div className="text-xs text-muted-foreground pt-1">
{formatDate(createdAt)}
</div>
)}
</div>
</div>
)}
{showEditModal && (
<EditImageModal
open={showEditModal}
onOpenChange={setShowEditModal}
pictureId={pictureId}
currentTitle={title}
currentDescription={description}
currentVisible={true} // Default to true until we can properly pass this
onUpdateSuccess={() => {
setShowEditModal(false);
onLike?.(); // Trigger refresh
}}
/>
)}
<ImageLightbox
isOpen={showLightbox}
onClose={() => {
setShowLightbox(false);
setGeneratedImageUrl(null);
}}
imageUrl={generatedImageUrl || image}
imageTitle={title}
onPromptSubmit={handlePromptSubmit}
onPublish={handlePublish}
isGenerating={isGenerating}
isPublishing={isPublishing}
showPrompt={true}
showPublish={!!generatedImageUrl}
generatedImageUrl={generatedImageUrl || undefined}
currentIndex={navigationData?.currentIndex}
totalCount={navigationData?.posts.length}
onNavigate={handleNavigate}
onPreload={handlePreload}
/>
</div>
);
};
export default PhotoCard;

View File

@ -0,0 +1,518 @@
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
import * as db from '../pages/Post/db';
import { UserProfile } from '../pages/Post/types';
import MediaCard from "./MediaCard";
import React, { useEffect, useState, useRef } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "react-router-dom";
import { usePostNavigation } from "@/hooks/usePostNavigation";
import { useOrganization } from "@/contexts/OrganizationContext";
import { useFeedCache } from "@/contexts/FeedCacheContext";
import { useLayoutEffect } from "react";
import { useFeedData } from "@/hooks/useFeedData";
import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry";
import { UploadCloud, Maximize } from "lucide-react";
import { toast } from "sonner";
import type { MediaType } from "@/types";
export interface MediaItemType {
id: string;
picture_id?: string;
title: string;
description: string | null;
image_url: string;
thumbnail_url: string | null;
type: MediaType;
meta: any | null;
likes_count: number;
created_at: string;
user_id: string;
comments: { count: number }[];
author_profile?: UserProfile;
job?: any;
responsive?: any; // Add responsive data
}
import type { FeedSortOption } from '@/hooks/useFeedData';
interface MediaGridProps {
customPictures?: MediaItemType[];
customLoading?: boolean;
navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'widget';
navigationSourceId?: string;
isOwner?: boolean;
onFilesDrop?: (files: File[]) => void;
showVideos?: boolean; // Toggle video display (kept for backward compatibility)
sortBy?: FeedSortOption;
supabaseClient?: any;
apiUrl?: string;
}
const MediaGrid = ({
customPictures,
customLoading,
navigationSource = 'home',
navigationSourceId,
isOwner = false,
onFilesDrop,
showVideos = true,
sortBy = 'latest',
supabaseClient,
apiUrl
}: MediaGridProps) => {
const { user } = useAuth();
// Use provided client or fallback to default
const supabase = supabaseClient || defaultSupabase;
const navigate = useNavigate();
const { setNavigationData, navigationData } = usePostNavigation();
const { getCache, saveCache } = useFeedCache();
const { orgSlug, isOrgContext } = useOrganization();
// State definitions restored
const [mediaItems, setMediaItems] = useState<MediaItemType[]>([]);
const [userLikes, setUserLikes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [isDragging, setIsDragging] = useState(false);
const dragLeaveTimeoutRef = useRef<number | null>(null);
// 1. Data Fetching
const {
posts: feedPosts,
loading: feedLoading,
hasMore,
loadMore,
isFetchingMore
} = useFeedData({
source: navigationSource,
sourceId: navigationSourceId,
isOrgContext,
orgSlug,
sortBy,
// Disable hook if we have custom pictures
enabled: !customPictures,
supabaseClient
});
// Infinite Scroll Observer
const observerTarget = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMore && !feedLoading && !isFetchingMore) {
loadMore();
}
},
{ threshold: 1.0 }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => {
if (observerTarget.current) {
observer.unobserve(observerTarget.current);
}
};
}, [hasMore, feedLoading, isFetchingMore, loadMore]);
// 2. State & Effects
useEffect(() => {
let finalMedia: MediaItemType[] = [];
if (customPictures) {
finalMedia = customPictures;
setLoading(customLoading || false);
} else {
// Map FeedPost[] -> MediaItemType[]
finalMedia = db.mapFeedPostsToMediaItems(feedPosts as any, sortBy);
setLoading(feedLoading);
}
setMediaItems(finalMedia);
// Update Navigation Data
if (finalMedia.length > 0) {
const navData = {
posts: finalMedia.map(item => ({
id: item.id,
title: item.title,
image_url: item.image_url,
user_id: item.user_id,
type: normalizeMediaType(item.type)
})),
currentIndex: 0,
source: navigationSource,
sourceId: navigationSourceId
};
setNavigationData(navData);
}
}, [feedPosts, feedLoading, customPictures, customLoading, navigationSource, navigationSourceId, setNavigationData, sortBy]);
// Scroll Restoration Logic
const cacheKey = `${navigationSource}-${navigationSourceId || ''}-${isOrgContext ? 'org' : 'personal'}-${orgSlug || ''}`;
const hasRestoredScroll = useRef(false);
// Restore scroll when mediaItems are populated
useLayoutEffect(() => {
// Enable restoration if we have items, haven't restored yet, and either it's NOT custom pictures OR it IS a widget with an ID
const shouldRestore = mediaItems.length > 0 &&
!hasRestoredScroll.current &&
(!customPictures || (navigationSource === 'widget' && navigationSourceId));
if (shouldRestore) {
const cached = getCache(cacheKey);
if (cached && cached.scrollY > 0) {
window.scrollTo(0, cached.scrollY);
}
hasRestoredScroll.current = true;
}
}, [mediaItems, cacheKey, getCache, customPictures, navigationSource, navigationSourceId]);
// Reset restored flag when source changes
useEffect(() => {
hasRestoredScroll.current = false;
}, [cacheKey]);
// Track scroll position
const lastScrollY = useRef(window.scrollY);
useEffect(() => {
const handleScroll = () => {
lastScrollY.current = window.scrollY;
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Save scroll position on unmount or before source change
useEffect(() => {
return () => {
// Save if not custom pictures OR if it is a widget with an ID
const shouldSave = !customPictures || (navigationSource === 'widget' && navigationSourceId);
if (shouldSave) {
const cached = getCache(cacheKey); // Get latest data from cache (posts etc)
// Use lastScrollY instead of window.scrollY because unmount might happen after a scroll-to-top by router
if (cached && lastScrollY.current > 0) {
saveCache(cacheKey, { ...cached, scrollY: lastScrollY.current });
}
}
};
}, [cacheKey, getCache, saveCache, customPictures, navigationSource, navigationSourceId]);
const fetchMediaFromPicturesTable = async () => {
// Manual Refresh Stub if needed - for onDelete/refresh actions
// Since the hook data is reactive, we might need a refresh function from the hook
// But for now, we can just reload the page or implement refresh in future.
window.location.reload();
};
const fetchUserLikes = async () => {
if (!user || mediaItems.length === 0) return;
try {
// Collect IDs to check (picture_id for feed, id for collection/direct pictures)
const targetIds = mediaItems
.map(item => item.picture_id || item.id)
.filter(Boolean) as string[];
if (targetIds.length === 0) return;
// Fetch likes only for the displayed items
const { data: likesData, error } = await supabase
.from('likes')
.select('picture_id')
.eq('user_id', user.id)
.in('picture_id', targetIds);
if (error) throw error;
// Merge new likes with existing set
setUserLikes(prev => {
const newSet = new Set(prev);
likesData?.forEach(l => newSet.add(l.picture_id));
return newSet;
});
} catch (error) {
console.error('Error fetching user likes:', error);
}
};
const handleMediaClick = (mediaId: string, type: MediaType, index: number) => {
// Handle Page navigation
if (type === 'page-intern') {
const item = mediaItems.find(i => i.id === mediaId);
if (item && item.meta?.slug) {
navigate(`/user/${item.user_id}/pages/${item.meta.slug}`);
return;
}
}
// Update navigation data with current index for correct Prev/Next behavior
if (navigationData) {
setNavigationData({ ...navigationData, currentIndex: index });
}
navigate(`/post/${mediaId}`);
};
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (dragLeaveTimeoutRef.current) {
clearTimeout(dragLeaveTimeoutRef.current);
}
if (isOwner && onFilesDrop) setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (dragLeaveTimeoutRef.current) {
clearTimeout(dragLeaveTimeoutRef.current);
}
dragLeaveTimeoutRef.current = window.setTimeout(() => {
setIsDragging(false);
}, 100);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (!isOwner || !onFilesDrop) return;
const files = [...e.dataTransfer.files].filter(f => f.type.startsWith('image/'));
if (files.length > 0) {
onFilesDrop(files);
}
};
const handleEditPost = async (postId: string) => {
try {
const toastId = toast.loading('Loading post for editing...');
// Fetch full post with all pictures
const { data: post, error } = await supabase
.from('posts')
.select(`*, pictures(*)`)
.eq('id', postId)
.single();
if (error) throw error;
if (!post.pictures || post.pictures.length === 0) {
throw new Error('No pictures found in post');
}
// Transform pictures for wizard
const wizardImages = post.pictures
.sort((a: any, b: any) => (a.position - b.position))
.map((p: any) => ({
id: p.id,
path: p.id,
src: p.image_url,
title: p.title,
description: p.description || '',
selected: false,
realDatabaseId: p.id
}));
toast.dismiss(toastId);
navigate('/wizard', {
state: {
mode: 'post',
initialImages: wizardImages,
postTitle: post.title,
postDescription: post.description,
editingPostId: post.id
}
});
} catch (error) {
console.error('Error opening post editor:', error);
toast.error('Failed to open post editor');
}
};
// Set up navigation data when media items change (for custom media)
useEffect(() => {
if (mediaItems.length > 0 && customPictures) {
const navData = {
posts: mediaItems
.filter(item => !isVideoType(normalizeMediaType(item.type)))
.map(item => ({
id: item.id,
title: item.title,
image_url: item.image_url,
user_id: item.user_id
})),
currentIndex: 0,
source: navigationSource,
sourceId: navigationSourceId
};
setNavigationData(navData);
}
}, [mediaItems, customPictures, navigationSource, navigationSourceId, setNavigationData]);
if (loading) {
return (
<div className="py-8">
<div className="text-center text-muted-foreground">
Loading media...
</div>
</div>
);
}
const hasItems = mediaItems.length > 0;
return (
<div className="w-full relative">
{hasItems && isOwner && onFilesDrop && navigationSource === 'collection' && (
<div
className={`my-4 border-2 border-dashed border-muted rounded-lg p-6 text-center text-muted-foreground transition-all ${isDragging ? 'ring-4 ring-primary ring-offset-2 border-solid border-primary bg-primary/5' : ''
} `}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<UploadCloud className="mx-auto h-8 w-8 text-muted-foreground" />
<p className="mt-2 text-sm">Drag and drop more images here to upload.</p>
</div>
)}
{!hasItems ? (
isOwner && navigationSource === 'collection' && onFilesDrop ? (
<div
className="py-8"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className={`border-2 border-dashed border-muted rounded-lg p-12 text-center text-muted-foreground transition-all ${isDragging ? 'ring-4 ring-primary ring-offset-2 border-solid border-primary bg-primary/5' : ''
} `}>
<UploadCloud className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold">This collection is empty</h3>
<p className="mt-2">Drag and drop images here to get started.</p>
</div>
</div>
) : (
<div className="py-8">
<div className="text-center text-muted-foreground">
<p className="text-lg">No media yet!</p>
<p>Be the first to share content with the community.</p>
</div>
</div>
)
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{mediaItems.map((item, index) => {
const itemType = normalizeMediaType(item.type);
const isVideo = isVideoType(itemType);
// For images, convert URL to optimized format
const displayUrl = item.image_url;
if (isVideo) {
return (
<div key={item.id} className="relative group">
<MediaCard
id={item.id}
pictureId={item.picture_id}
url={displayUrl}
thumbnailUrl={item.thumbnail_url}
title={item.title}
// Pass blank/undefined so UserAvatarBlock uses context data
author={undefined as any}
authorAvatarUrl={undefined}
authorId={item.user_id}
likes={item.likes_count || 0}
comments={item.comments[0]?.count || 0}
isLiked={userLikes.has(item.picture_id || item.id)}
description={item.description}
type={itemType}
meta={item.meta}
onClick={() => handleMediaClick(item.id, itemType, index)}
onLike={fetchUserLikes}
onDelete={fetchMediaFromPicturesTable}
onEdit={handleEditPost}
created_at={item.created_at}
job={item.job}
responsive={item.responsive}
apiUrl={apiUrl}
/>
<div className="absolute top-2 right-2 flex items-center justify-center w-8 h-8 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
<Maximize className="w-4 h-4 text-white" />
</div>
</div>
);
}
return (
<MediaCard
key={item.id}
id={item.id}
pictureId={item.picture_id}
url={displayUrl}
thumbnailUrl={item.thumbnail_url}
title={item.title}
author={undefined as any}
authorAvatarUrl={undefined}
authorId={item.user_id}
likes={item.likes_count || 0}
comments={item.comments[0]?.count || 0}
isLiked={userLikes.has(item.picture_id || item.id)}
description={item.description}
type={itemType}
meta={item.meta}
onClick={() => handleMediaClick(item.id, itemType, index)}
onLike={fetchUserLikes}
onDelete={fetchMediaFromPicturesTable}
onEdit={handleEditPost}
created_at={item.created_at}
job={item.job}
responsive={item.responsive}
apiUrl={apiUrl}
/>
);
})}
</div>
)}
{/* Loading Indicator / Observer Target */}
<div ref={observerTarget} className="h-10 w-full flex items-center justify-center p-4">
{isFetchingMore && (
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900 dark:border-white"></div>
)}
</div>
</div>
);
};
// Backward compatibility export
export default MediaGrid;
// Named exports for clarity
export { MediaGrid };
export const PhotoGrid = MediaGrid;

View File

@ -0,0 +1,137 @@
import React, { useEffect, useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { MediaType } from '@/types';
import MediaCard from '@/components/MediaCard';
import { Loader2 } from 'lucide-react';
import { T } from '@/i18n';
interface PostPickerProps {
onSelect: (postId: string) => void;
}
const PostPicker: React.FC<PostPickerProps> = ({ onSelect }) => {
const { user } = useAuth();
const [posts, setPosts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user) return;
fetchPosts();
}, [user]);
const fetchPosts = async () => {
try {
setLoading(true);
// Fetch user's posts
const { data, error } = await supabase
.from('posts')
.select(`
*,
pictures (
id,
image_url,
thumbnail_url,
type,
meta,
position
)
`)
.eq('user_id', user?.id)
.order('created_at', { ascending: false });
if (error) throw error;
// Transform for display
const transformed = (data || []).map(post => {
// Use first picture as cover
const pics = post.pictures as any[];
if (!pics || pics.length === 0) return null;
// Sort by position to get the first one
pics.sort((a: any, b: any) => (a.position || 0) - (b.position || 0));
const cover = pics[0];
if (!cover) return null;
return {
id: post.id,
pictureId: cover.id,
title: post.title,
url: cover.image_url,
thumbnailUrl: cover.thumbnail_url,
type: cover.type as MediaType,
meta: cover.meta,
likes: 0,
comments: 0,
author: user?.email || 'Me', // Simplified
authorId: user?.id || '',
};
}).filter(Boolean);
setPosts(transformed);
} catch (err) {
console.error("Error fetching posts:", err);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-40">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
);
}
if (posts.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<T>No posts found.</T>
</div>
);
}
return (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto p-4">
{posts.map((post: any) => (
<div
key={post.id}
className="cursor-pointer group flex flex-col gap-2"
onClick={() => onSelect(post.id)}
>
<div className="border-2 border-transparent group-hover:border-primary rounded-lg overflow-hidden transition-all relative aspect-square">
<MediaCard
id={post.id}
pictureId={post.pictureId}
url={post.url}
thumbnailUrl={post.thumbnailUrl}
title={post.title}
author={post.author}
authorId={post.authorId}
likes={0}
comments={0}
type={post.type}
meta={post.meta}
showContent={false}
// Disable default interactions
onClick={() => onSelect(post.id)}
onLike={undefined}
onDelete={undefined}
onEdit={undefined}
/>
</div>
<div className="px-1">
<h4 className="font-medium text-sm truncate">{post.title || <T>Untitled</T>}</h4>
<div className="flex items-center justify-between text-xs text-muted-foreground mt-1">
<span>{new Date(post.created_at || Date.now()).toLocaleDateString()}</span>
</div>
</div>
</div>
))}
</div>
);
};
export default PostPicker;

View File

@ -0,0 +1,405 @@
import React, { useState } from 'react';
import { Plus, Edit, Trash2, Sparkles, Save, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import { T } from '@/i18n';
export interface PromptPreset {
id: string;
name: string;
prompt: string;
createdAt: string;
}
interface PresetManagerProps {
presets: PromptPreset[];
currentPrompt: string;
onSelectPreset: (preset: PromptPreset) => void;
onSavePreset: (preset: Omit<PromptPreset, 'id' | 'createdAt'>) => Promise<void>;
onUpdatePreset: (id: string, preset: Omit<PromptPreset, 'id' | 'createdAt'>) => Promise<void>;
onDeletePreset: (id: string) => Promise<void>;
loading?: boolean;
selectedPresetId?: string; // Track which preset is active
}
const PresetManager: React.FC<PresetManagerProps> = ({
presets,
currentPrompt,
onSelectPreset,
onSavePreset,
onUpdatePreset,
onDeletePreset,
loading = false,
selectedPresetId,
}) => {
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [deletePresetId, setDeletePresetId] = useState<string | null>(null);
const [editingPreset, setEditingPreset] = useState<PromptPreset | null>(null);
const [presetName, setPresetName] = useState('');
const [presetPrompt, setPresetPrompt] = useState('');
const handleCreatePreset = async () => {
if (!presetName.trim()) {
toast.error('Please enter a preset name');
return;
}
if (!presetPrompt.trim()) {
toast.error('Please enter a prompt');
return;
}
try {
await onSavePreset({
name: presetName.trim(),
prompt: presetPrompt.trim(),
});
toast.success('Preset created successfully');
setIsCreateDialogOpen(false);
setPresetName('');
setPresetPrompt('');
} catch (error: any) {
toast.error(error.message || 'Failed to create preset');
}
};
const handleUpdatePreset = async () => {
if (!editingPreset) return;
if (!presetName.trim()) {
toast.error('Please enter a preset name');
return;
}
if (!presetPrompt.trim()) {
toast.error('Please enter a prompt');
return;
}
try {
await onUpdatePreset(editingPreset.id, {
name: presetName.trim(),
prompt: presetPrompt.trim(),
});
toast.success('Preset updated successfully');
setIsEditDialogOpen(false);
setEditingPreset(null);
setPresetName('');
setPresetPrompt('');
} catch (error: any) {
toast.error(error.message || 'Failed to update preset');
}
};
const handleDeletePreset = async () => {
if (!deletePresetId) return;
try {
await onDeletePreset(deletePresetId);
toast.success('Preset deleted successfully');
setDeletePresetId(null);
} catch (error: any) {
toast.error(error.message || 'Failed to delete preset');
}
};
const openEditDialog = (preset: PromptPreset) => {
setEditingPreset(preset);
setPresetName(preset.name);
setPresetPrompt(preset.prompt);
setIsEditDialogOpen(true);
};
const openCreateDialogWithCurrent = () => {
setPresetPrompt(currentPrompt);
setIsCreateDialogOpen(true);
};
return (
<div className="border border-blue-200/50 dark:border-blue-500/30 rounded-xl p-4 bg-gradient-to-br from-blue-50/30 to-purple-50/30 dark:from-blue-900/20 dark:to-purple-900/20">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<h4 className="text-sm font-semibold text-slate-700 dark:text-white">
<T>Presets</T>
</h4>
<Badge variant="outline" className="text-xs">
{presets.length}
</Badge>
</div>
<div className="flex gap-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={openCreateDialogWithCurrent}
disabled={loading || !currentPrompt.trim()}
className="h-6 w-6 p-0"
title="Save current prompt as preset"
>
<Save className="w-3 h-3" />
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setPresetPrompt('');
setIsCreateDialogOpen(true);
}}
disabled={loading}
className="h-6 w-6 p-0"
title="Create new preset"
>
<Plus className="w-3 h-3" />
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{presets.length === 0 ? (
<span className="text-xs text-slate-500 dark:text-slate-400">
<T>No presets saved yet. Create your first preset to quickly access your favorite prompts!</T>
</span>
) : (
presets.map((preset) => {
const isActive = selectedPresetId === preset.id;
return (
<div
key={preset.id}
className="group relative"
>
<button
type="button"
onClick={() => onSelectPreset(preset)}
disabled={loading || isActive}
className={`text-xs px-3 py-1.5 rounded-lg transition-all duration-200 border shadow-sm hover:shadow-md disabled:opacity-50 ${
isActive
? 'bg-gradient-to-r from-green-100 to-emerald-100 dark:from-green-800 dark:to-emerald-800 text-green-700 dark:text-green-200 border-green-400 dark:border-green-600 ring-2 ring-green-400 dark:ring-green-600 cursor-default'
: 'bg-gradient-to-r from-blue-100 to-purple-100 hover:from-blue-200 hover:to-purple-200 dark:from-blue-800 dark:to-purple-800 dark:hover:from-blue-700 dark:hover:to-purple-700 text-blue-700 dark:text-blue-200 border-blue-300/50 dark:border-blue-600/50 cursor-pointer disabled:cursor-not-allowed'
}`}
title={isActive ? 'Active context' : preset.prompt}
>
{isActive && '✓ '}{preset.name}
</button>
{/* Edit/Delete buttons - Always visible on mobile, hover on desktop */}
<div className="absolute -top-2 -right-2 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200 flex gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openEditDialog(preset);
}}
className="p-1 rounded-full bg-blue-500 hover:bg-blue-600 text-white shadow-lg active:scale-95 transition-transform"
title="Edit preset"
>
<Edit className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDeletePresetId(preset.id);
}}
className="p-1 rounded-full bg-red-500 hover:bg-red-600 text-white shadow-lg active:scale-95 transition-transform"
title="Delete preset"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
);
})
)}
</div>
{/* Create Preset Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<T>Create New Preset</T>
</DialogTitle>
<DialogDescription>
<T>Save a prompt preset that you can quickly access later. Perfect for your favorite styles and techniques!</T>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="text-sm font-medium mb-2 block">
<T>Preset Name</T>
</label>
<Input
placeholder="e.g., Cinematic Portrait, Fantasy Landscape"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
maxLength={50}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
<T>Prompt</T>
</label>
<Textarea
placeholder="Enter the prompt that will be used when this preset is selected..."
value={presetPrompt}
onChange={(e) => setPresetPrompt(e.target.value)}
rows={4}
maxLength={1000}
/>
<p className="text-xs text-slate-500 mt-1">
{presetPrompt.length}/1000 characters
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setIsCreateDialogOpen(false);
setPresetName('');
setPresetPrompt('');
}}
>
<T>Cancel</T>
</Button>
<Button
type="button"
onClick={handleCreatePreset}
disabled={!presetName.trim() || !presetPrompt.trim()}
>
<Save className="w-4 h-4 mr-2" />
<T>Save Preset</T>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Preset Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<T>Edit Preset</T>
</DialogTitle>
<DialogDescription>
<T>Update your preset name and prompt.</T>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="text-sm font-medium mb-2 block">
<T>Preset Name</T>
</label>
<Input
placeholder="e.g., Cinematic Portrait, Fantasy Landscape"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
maxLength={50}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
<T>Prompt</T>
</label>
<Textarea
placeholder="Enter the prompt that will be used when this preset is selected..."
value={presetPrompt}
onChange={(e) => setPresetPrompt(e.target.value)}
rows={4}
maxLength={1000}
/>
<p className="text-xs text-slate-500 mt-1">
{presetPrompt.length}/1000 characters
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setIsEditDialogOpen(false);
setEditingPreset(null);
setPresetName('');
setPresetPrompt('');
}}
>
<T>Cancel</T>
</Button>
<Button
type="button"
onClick={handleUpdatePreset}
disabled={!presetName.trim() || !presetPrompt.trim()}
>
<Save className="w-4 h-4 mr-2" />
<T>Update Preset</T>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deletePresetId !== null} onOpenChange={(open) => !open && setDeletePresetId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<T>Delete Preset?</T>
</AlertDialogTitle>
<AlertDialogDescription>
<T>Are you sure you want to delete this preset? This action cannot be undone.</T>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeletePresetId(null)}>
<T>Cancel</T>
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeletePreset}
className="bg-red-600 hover:bg-red-700"
>
<T>Delete</T>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default PresetManager;

View File

@ -0,0 +1,533 @@
import React, { useState, useEffect } from 'react';
import { ImageFile, PromptTemplate } from '../types';
import { QUICK_ACTIONS } from '../constants';
import ImageGallery from './ImageGallery';
import { useDropZone } from '../hooks/useDropZone';
import TemplateManager from './TemplateManager';
import { tauriApi } from '../lib/tauriApi';
import log from '../lib/log';
import { Eraser, Sparkles, Crop, Palette, Package, FolderOpen, Plus, History, ChevronUp, ChevronDown } from 'lucide-react';
import { T } from '../i18n';
function arrayBufferToBase64(buffer: number[]) {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
function getActionIcon(iconName: string) {
const iconMap = {
'Eraser': Eraser,
'Sparkles': Sparkles,
'Crop': Crop,
'Palette': Palette,
'Package': Package
};
const IconComponent = iconMap[iconName as keyof typeof iconMap];
return IconComponent ? <IconComponent size={16} /> : <span>{iconName}</span>;
}
interface PromptFormProps {
prompt: string;
setPrompt: (prompt: string) => void;
dst: string;
setDst: (dst: string) => void;
openSaveDialog: () => void;
openFilePicker: () => void;
files: ImageFile[];
getSelectedImages: () => ImageFile[];
clearAllFiles: () => void;
handleImageSelection: (path: string, isMultiSelect: boolean) => void;
removeFile: (path: string) => void;
isGenerating: boolean;
saveAndClose: () => void;
submit: () => void;
addImageFromUrl: (url: string) => void;
onImageDelete?: (path: string) => void;
onImageSaveAs?: (path: string) => void;
addFiles: (paths: string[]) => void;
currentIndex: number;
setCurrentIndex: (index: number) => void;
prompts: PromptTemplate[];
setPrompts: (prompts: PromptTemplate[]) => void;
savePrompts: (prompts: PromptTemplate[]) => void;
importPrompts: () => void;
exportPrompts: () => void;
quickStyles: readonly string[];
appendStyle: (style: string) => void;
quickActions: typeof QUICK_ACTIONS;
executeQuickAction: (action: { name: string; prompt: string; iconName: string }) => Promise<void>;
promptHistory: string[];
historyIndex: number;
navigateHistory: (direction: 'up' | 'down') => void;
fileHistory: string[];
showFileHistory: boolean;
setShowFileHistory: (show: boolean) => void;
openFileFromHistory: (filePath: string) => Promise<void>;
onFileHistoryCleanup: (validFiles: string[]) => Promise<void>;
onLightboxPromptSubmit: (prompt: string, imagePath: string) => Promise<void>;
errorMessage?: string | null;
setErrorMessage?: (message: string | null) => void;
}
const PromptForm: React.FC<PromptFormProps> = ({
prompt,
setPrompt,
dst,
setDst,
openSaveDialog,
openFilePicker,
files,
getSelectedImages,
clearAllFiles,
handleImageSelection,
removeFile,
isGenerating,
saveAndClose,
submit,
addImageFromUrl,
onImageDelete,
onImageSaveAs,
addFiles,
currentIndex,
setCurrentIndex,
prompts,
setPrompts,
savePrompts,
importPrompts,
exportPrompts,
quickStyles,
appendStyle,
quickActions,
executeQuickAction,
promptHistory,
historyIndex,
navigateHistory,
fileHistory,
showFileHistory,
setShowFileHistory,
openFileFromHistory,
onFileHistoryCleanup,
onLightboxPromptSubmit,
errorMessage,
setErrorMessage,
}) => {
const selectedCount = getSelectedImages().length;
const { ref: dropZoneRef, dragIn } = useDropZone({ onDrop: addFiles });
const [historyImages, setHistoryImages] = useState<ImageFile[]>([]);
const [historyCurrentIndex, setHistoryCurrentIndex] = useState(0);
// Handle clipboard paste for images
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
try {
log.info('📋 Paste event detected, checking for images...');
// Try to get images from clipboard using our stubbed API
const result = await tauriApi.parseClipboardImages('png', '');
if (result?.success && result.paths && result.paths.length > 0) {
log.info(`📋 Found ${result.paths.length} image(s) in clipboard`, {
paths: result.paths.map(p => p.split(/[/\\]/).pop())
});
// Add the clipboard images to the files
addFiles(result.paths);
// Prevent default paste behavior since we handled it
e.preventDefault();
} else if (result?.error) {
log.warn('📋 No images found in clipboard or error occurred', { error: result.error });
} else {
log.debug('📋 No images in clipboard, allowing normal paste');
}
} catch (error) {
log.error('📋 Failed to parse clipboard', { error: (error as Error).message });
}
};
// Load images for file history when modal opens
useEffect(() => {
if (showFileHistory && fileHistory.length > 0) {
const loadHistoryImages = async () => {
const imageFiles: ImageFile[] = [];
const validFiles: string[] = [];
for (const filePath of fileHistory) {
try {
// First check if file exists, then read it
log.debug(`Checking file: ${filePath}`);
const buffer = await tauriApi.fs.readFile(filePath);
// Use the same conversion method as main app
const base64 = arrayBufferToBase64(Array.from(buffer));
const ext = filePath.toLowerCase().split('.').pop();
const mimeType = ext === 'png' ? 'image/png' : 'image/jpeg';
const src = `data:${mimeType};base64,${base64}`;
imageFiles.push({
path: filePath,
src,
isGenerated: true,
selected: false
});
validFiles.push(filePath);
} catch (error) {
// File doesn't exist, skip it and log with full path
log.warn(`File not found in history: ${filePath}`, {
filename: filePath.split(/[/\\]/).pop(),
fullPath: filePath,
error: (error as Error).message
});
}
}
// If some files were invalid, update the fileHistory and store
if (validFiles.length !== fileHistory.length) {
log.info(`Cleaning up file history: ${fileHistory.length - validFiles.length} invalid files removed`);
try {
await onFileHistoryCleanup(validFiles);
log.info('💾 File history cleaned and saved');
} catch (cleanupError) {
log.error('Failed to cleanup file history', { error: (cleanupError as Error).message });
}
}
setHistoryImages(imageFiles);
setHistoryCurrentIndex(0);
};
loadHistoryImages();
} else if (showFileHistory) {
// No file history, show empty state immediately
setHistoryImages([]);
}
}, [showFileHistory, fileHistory]);
return (
<form
className="flex flex-col items-center glass-card p-4 sm:p-8 glass-shimmer shadow-2xl"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<div className="w-full space-y-6">
{/* Two-column layout: Text Input + Action Buttons */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left column: Text input area only */}
<div>
<div className="border-2 border-slate-200 dark:border-white/40 rounded-xl p-3 sm:p-4 focus-within:border-indigo-500 dark:focus-within:border-cyan-400 transition-colors duration-200">
<textarea
id="prompt-input"
value={prompt}
onChange={(e) => setPrompt(e.currentTarget.value)}
onPaste={handlePaste}
placeholder="Describe the image you want to generate or edit... (Ctrl+V to paste images)"
className="w-full bg-transparent border-none outline-none min-h-[120px] resize-none text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-300"
rows={5}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submit();
} else if (e.key === 'ArrowUp' && e.ctrlKey) {
e.preventDefault();
navigateHistory('up');
} else if (e.key === 'ArrowDown' && e.ctrlKey) {
e.preventDefault();
navigateHistory('down');
}
}}
/>
</div>
</div>
{/* Right column: Action buttons (Generate + Styles + Actions) */}
<div className="space-y-4">
{/* Generate Button + History Navigation */}
<div className="flex flex-col sm:flex-row gap-3">
<button
type="button"
onClick={submit}
disabled={isGenerating || !prompt.trim()}
className="flex-1 glass-button bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white px-6 py-3 rounded-xl font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300"
>
{isGenerating ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<T>Generating...</T>
</span>
) : (
<>🎨 <T>Generate Image</T></>
)}
</button>
{/* History Navigation */}
{promptHistory.length > 0 && (
<div className="flex gap-1">
<button
type="button"
onClick={() => navigateHistory('up')}
disabled={isGenerating}
className="glass-button px-3 py-3 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50"
title={`Previous prompt (${historyIndex + 1}/${promptHistory.length}) - Ctrl+↑`}
>
<ChevronUp size={16} />
</button>
<button
type="button"
onClick={() => navigateHistory('down')}
disabled={isGenerating}
className="glass-button px-3 py-3 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50"
title={`Next prompt - Ctrl+↓`}
>
<ChevronDown size={16} />
</button>
</div>
)}
</div>
{/* Quick Styles - Compact */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-white mb-2"><T>Styles</T></h4>
<div className="flex flex-wrap gap-1">
{quickStyles.map((style) => (
<button
key={style}
type="button"
onClick={() => appendStyle(style)}
className="text-xs px-2 py-1 rounded bg-slate-100 hover:bg-slate-200 dark:bg-slate-700 dark:hover:bg-slate-600 text-slate-700 dark:text-white transition-colors duration-200"
>
{style}
</button>
))}
</div>
</div>
{/* Quick Actions - Icons Only */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-white mb-2"><T>Actions</T></h4>
<div className="flex flex-wrap gap-1">
{quickActions.map((action) => {
const hasSelectedImages = getSelectedImages().length > 0;
const hasAnyImages = files.length > 0;
return (
<button
key={action.name}
type="button"
onClick={() => executeQuickAction(action)}
disabled={isGenerating || !hasSelectedImages}
className={`text-lg px-2 py-2 rounded transition-colors duration-200 ${
!hasAnyImages
? 'bg-slate-50 dark:bg-slate-800 text-slate-400 dark:text-slate-500 cursor-not-allowed'
: !hasSelectedImages
? 'bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 cursor-not-allowed'
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 text-blue-700 dark:text-blue-300'
} ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
title={!hasAnyImages ? 'Add an image first' : !hasSelectedImages ? 'Select an image first' : action.name}
>
{getActionIcon(action.iconName)}
</button>
);
})}
</div>
</div>
</div>
</div>
<TemplateManager
prompts={prompts}
currentPrompt={prompt}
onSelectPrompt={setPrompt}
onSavePrompt={(name, text) => {
const newPrompts = [...prompts, { name, text }];
setPrompts(newPrompts);
savePrompts(newPrompts);
}}
onImportPrompts={importPrompts}
onExportPrompts={exportPrompts}
/>
{/* Two-column layout: Destination + Source */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left: Output destination */}
<div className="border border-slate-200/50 dark:border-white/30 rounded-xl p-4 bg-slate-50/30 dark:bg-slate-800/90">
<label htmlFor="output-path" className="block text-sm font-semibold text-slate-700 dark:text-white mb-2">
<T>Output File Path</T>
</label>
<div className="flex flex-col sm:flex-row gap-3">
<input
id="output-path"
type="text"
value={dst}
onChange={(e) => setDst(e.target.value)}
placeholder="output.png"
className="flex-1 glass-input p-3 sm:p-4 rounded-xl border-2 border-slate-300 dark:border-slate-600 focus:border-indigo-500 dark:focus:border-indigo-400"
/>
<button
type="button"
onClick={openSaveDialog}
className="glass-button font-semibold py-4 px-4 rounded-xl whitespace-nowrap flex items-center gap-2"
title="Browse for save location"
>
<FolderOpen size={16} />
<T>Browse</T>
</button>
</div>
</div>
{/* Right: Source images */}
<div
ref={dropZoneRef}
className={`p-4 rounded-xl border-2 border-dashed transition-all duration-300 bg-slate-50/30 dark:bg-slate-800/90 ${dragIn ? 'border-blue-500 bg-blue-500/10' : 'border-slate-300/50 dark:border-white/30'}`}
>
<div className="space-y-3">
<button
type="button"
onClick={openFilePicker}
className="w-full glass-button font-semibold py-4 px-6 rounded-xl hover:border-slate-400/60 dark:hover-border-slate-500/60 flex items-center justify-center gap-2 border-2 border-dashed border-slate-300 dark:border-slate-600 hover:border-indigo-400 dark:hover:border-indigo-500 transition-colors"
>
<FolderOpen size={20} />
<T>Select Images (or Drop Here)</T>
</button>
<div className="flex flex-col sm:flex-row gap-2 justify-center">
<button
type="button"
onClick={() => addImageFromUrl('https://picsum.photos/640/640')}
className="glass-button py-2 px-3 rounded-lg"
title="Add random image from URL"
>
<Plus size={16} />
</button>
<button
type="button"
onClick={() => setShowFileHistory(true)}
disabled={fileHistory.length === 0}
className="glass-button py-2 px-3 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
title={fileHistory.length > 0 ? `Open from history (${fileHistory.length} files)` : 'No file history yet'}
>
<History size={16} />
</button>
</div>
</div>
</div>
</div>
{files.length > 0 && (
<div className="w-full mt-4 sm:mt-6 border border-slate-200/50 dark:border-slate-700/50 rounded-xl p-4 sm:p-6 bg-slate-50/30 dark:bg-slate-800/30">
<div className="flex justify-between items-center mb-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
Images ({files.length})
{selectedCount > 0 && (
<span className="ml-2 text-blue-600 dark:text-blue-400">
{selectedCount} selected
</span>
)}
</h3>
<button
type="button"
onClick={clearAllFiles}
className="glass-button border-red-400/50 text-red-600 hover:bg-red-500/20 text-sm px-3 py-2 rounded-lg"
title="Remove all images"
>
Clear All
</button>
</div>
<div className="border border-slate-200/30 dark:border-slate-700/30 rounded-lg p-4 bg-white/50 dark:bg-slate-900/50">
<ImageGallery
images={files}
onImageSelection={handleImageSelection}
onImageRemove={removeFile}
onImageSaveAs={onImageSaveAs}
showSelection={true}
onImageDelete={onImageDelete}
currentIndex={currentIndex}
setCurrentIndex={setCurrentIndex}
onLightboxPromptSubmit={onLightboxPromptSubmit}
promptHistory={promptHistory}
historyIndex={historyIndex}
navigateHistory={navigateHistory}
isGenerating={isGenerating}
errorMessage={errorMessage}
setErrorMessage={setErrorMessage}
/>
</div>
</div>
)}
<div className="w-full mt-8 space-y-3">
{files.some(file => file.path.startsWith('generated_')) && (
<button
type="button"
onClick={saveAndClose}
className="w-full glass-button border-green-400/50 text-green-700 dark:text-green-400 hover:bg-green-500/20 font-semibold py-3 px-6 rounded-xl shadow-md hover:shadow-lg transition-all duration-300"
disabled={isGenerating}
>
<span className="flex items-center justify-center gap-2">
💾 Save Last Generated Image and Close
</span>
</button>
)}
</div>
</div>
{/* File History Modal */}
{showFileHistory && (
<div className="fixed inset-0 bg-black/50 z-[9999] flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-hidden">
<div className="flex justify-between items-center p-6 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">File History</h3>
<button
onClick={() => setShowFileHistory(false)}
className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 text-2xl"
>
×
</button>
</div>
<div className="p-6 overflow-y-auto max-h-[60vh]">
{historyImages.length === 0 ? (
<div className="text-center text-slate-500 dark:text-slate-400 py-8">
{fileHistory.length === 0
? "No file history yet. Generated images will appear here."
: "Loading images from history..."
}
</div>
) : (
<ImageGallery
images={historyImages}
onImageSelection={(imagePath: string) => {
// Single click selects for preview
setHistoryImages(prev =>
prev.map(img => ({
...img,
selected: img.path === imagePath
}))
);
}}
onImageRemove={undefined}
onImageDelete={undefined}
onImageSaveAs={undefined}
showSelection={false}
currentIndex={historyCurrentIndex}
setCurrentIndex={setHistoryCurrentIndex}
onDoubleClick={(imagePath: string) => {
// Double click picks the image and closes modal
openFileFromHistory(imagePath);
}}
errorMessage={errorMessage}
setErrorMessage={setErrorMessage}
/>
)}
</div>
</div>
</div>
)}
</form>
);
};
export default PromptForm;

View File

@ -0,0 +1,282 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Checkbox } from '@/components/ui/checkbox';
import { Upload, RefreshCw, GitBranch, Bookmark } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
interface Collection {
id: string;
name: string;
slug: string;
}
interface PublishDialogProps {
isOpen: boolean;
onClose: () => void;
onPublish: (option: 'overwrite' | 'new' | 'version' | 'add-to-post', title?: string, description?: string, parentId?: string, collectionIds?: string[]) => void;
originalTitle: string;
originalImageId?: string;
isPublishing?: boolean;
editingPostId?: string;
}
export default function PublishDialog({
isOpen,
onClose,
onPublish,
originalTitle,
originalImageId,
isPublishing = false,
editingPostId
}: PublishDialogProps) {
const { user } = useAuth();
const [publishOption, setPublishOption] = useState<'overwrite' | 'new' | 'version' | 'add-to-post'>('new');
const [title, setTitle] = useState(originalTitle);
const [description, setDescription] = useState('');
const [collections, setCollections] = useState<Collection[]>([]);
const [selectedCollections, setSelectedCollections] = useState<string[]>([]);
const [loadingCollections, setLoadingCollections] = useState(false);
// Load user's collections when dialog opens
useEffect(() => {
if (isOpen && user) {
loadCollections();
// Default to "add-to-post" if editing a post
if (editingPostId) {
setPublishOption('add-to-post');
} else {
setPublishOption('new');
}
}
}, [isOpen, user, editingPostId]);
const loadCollections = async () => {
if (!user) return;
setLoadingCollections(true);
try {
const { data, error } = await supabase
.from('collections')
.select('id, name, slug')
.eq('user_id', user.id)
.order('name');
if (error) throw error;
setCollections(data || []);
} catch (error) {
console.error('Error loading collections:', error);
} finally {
setLoadingCollections(false);
}
};
const toggleCollection = (collectionId: string) => {
setSelectedCollections(prev =>
prev.includes(collectionId)
? prev.filter(id => id !== collectionId)
: [...prev, collectionId]
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onPublish(
publishOption,
title.trim() || undefined,
description.trim() || undefined,
originalImageId,
selectedCollections.length > 0 ? selectedCollections : undefined
);
};
const handleClose = () => {
if (!isPublishing) {
onClose();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 z-[10000] flex items-center justify-center p-4">
<div className="bg-background rounded-xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-xl font-semibold text-foreground mb-4">
Publish Generated Image
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label className="text-sm font-medium text-foreground mb-3 block">
Publishing Option
</Label>
<RadioGroup
value={publishOption}
onValueChange={(value) => setPublishOption(value as 'overwrite' | 'new' | 'version' | 'add-to-post')}
className="space-y-3"
>
<div className="flex items-center space-x-2 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<RadioGroupItem value="overwrite" id="overwrite" />
<div className="flex-1">
<Label htmlFor="overwrite" className="font-medium cursor-pointer">
<div className="flex items-center gap-2">
<RefreshCw className="h-4 w-4" />
Overwrite Original
</div>
</Label>
<p className="text-sm text-muted-foreground mt-1">
Replace the original image with the generated version
</p>
</div>
</div>
<div className="flex items-center space-x-2 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<RadioGroupItem value="new" id="new" />
<div className="flex-1">
<Label htmlFor="new" className="font-medium cursor-pointer">
<div className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Create New Post
</div>
</Label>
<p className="text-sm text-muted-foreground mt-1">
Save as a new image in your gallery
</p>
</div>
</div>
{originalImageId && (
<div className="flex items-center space-x-2 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<RadioGroupItem value="version" id="version" />
<div className="flex-1">
<Label htmlFor="version" className="font-medium cursor-pointer">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Save as Version
</div>
</Label>
<p className="text-sm text-muted-foreground mt-1">
Create a new version linked to the original image
</p>
</div>
</div>
)}
</RadioGroup>
</div>
{(publishOption === 'new' || publishOption === 'version' || publishOption === 'add-to-post') && (
<div className="space-y-4 pt-2">
<div>
<Label htmlFor="title" className="text-sm font-medium text-foreground">
Title <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter a title..."
className="mt-1"
disabled={isPublishing}
/>
</div>
<div>
<Label htmlFor="description" className="text-sm font-medium text-foreground">
Description <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description for your generated image..."
className="mt-1 resize-none"
rows={3}
disabled={isPublishing}
/>
</div>
{/* Collections Selection */}
{collections.length > 0 && (
<div>
<Label className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<Bookmark className="h-4 w-4" />
Add to Collections <span className="text-muted-foreground">(optional)</span>
</Label>
<div className="mt-2 space-y-2 max-h-32 overflow-y-auto border rounded-lg p-3">
{loadingCollections ? (
<div className="text-sm text-muted-foreground text-center py-2">
Loading collections...
</div>
) : (
collections.map((collection) => (
<div key={collection.id} className="flex items-center space-x-2">
<Checkbox
id={`collection-${collection.id}`}
checked={selectedCollections.includes(collection.id)}
onCheckedChange={() => toggleCollection(collection.id)}
disabled={isPublishing}
/>
<label
htmlFor={`collection-${collection.id}`}
className="text-sm cursor-pointer flex-1"
>
{collection.name}
</label>
</div>
))
)}
</div>
{selectedCollections.length > 0 && (
<p className="text-xs text-muted-foreground mt-2">
Will be added to {selectedCollections.length} collection{selectedCollections.length > 1 ? 's' : ''}
</p>
)}
</div>
)}
</div>
)}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="flex-1"
disabled={isPublishing}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={isPublishing}
>
{isPublishing ? (
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
Publishing...
</div>
) : (
<div className="flex items-center gap-2">
{publishOption === 'overwrite' && <RefreshCw className="h-4 w-4" />}
{publishOption === 'new' && <Upload className="h-4 w-4" />}
{publishOption === 'version' && <GitBranch className="h-4 w-4" />}
{publishOption === 'add-to-post' && <Bookmark className="h-4 w-4" />}
{publishOption === 'overwrite' ? 'Overwrite' :
publishOption === 'version' ? 'Save Version' :
publishOption === 'add-to-post' ? 'Add to Post' : 'Publish New'}
</div>
)}
</Button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,162 @@
import React, { useState, useEffect } from 'react';
import { useResponsiveImage } from '@/hooks/useResponsiveImage';
interface ResponsiveImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> {
src: string | File;
sizes?: string;
className?: string;
imgClassName?: string;
responsiveSizes?: number[];
formats?: string[];
alt: string;
onDataLoaded?: (data: ResponsiveData) => void;
rootMargin?: string;
data?: ResponsiveData;
apiUrl?: string;
}
export interface ResponsiveData {
img: {
src: string;
width: number;
height: number;
format: string;
};
sources?: {
srcset: string;
type: string;
}[];
}
const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
src,
sizes = '(max-width: 1024px) 100vw, 50vw',
className,
imgClassName,
responsiveSizes = [180, 640, 1024, 1280, 1600],
formats = ['avif', 'webp'],
alt,
onDataLoaded,
rootMargin = '800px',
onLoad,
onError,
data: providedData,
apiUrl,
...props
}) => {
// Lazy load logic
const [isInView, setIsInView] = useState(props.loading === 'eager');
const [imgLoaded, setImgLoaded] = useState(false);
const ref = React.useRef<HTMLDivElement>(null);
const imgRef = React.useRef<HTMLImageElement>(null);
useEffect(() => {
if (props.loading === 'eager' || providedData) {
setIsInView(true);
return;
}
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
}, {
rootMargin
});
if (ref.current) {
observer.observe(ref.current);
}
return () => {
observer.disconnect();
};
}, [props.loading, rootMargin, providedData]);
const hookResult = useResponsiveImage({
src: src as string | File,
responsiveSizes,
formats,
enabled: isInView && !providedData,
apiUrl: apiUrl
});
const data = providedData || hookResult.data;
const loading = !providedData && hookResult.loading;
const error = !providedData && hookResult.error;
useEffect(() => {
if (data && onDataLoaded) {
onDataLoaded(data);
}
}, [data, onDataLoaded]);
// Reset loaded state when src changes
useEffect(() => {
setImgLoaded(false);
}, [src]);
// Check if image is already loaded (from cache)
useEffect(() => {
if (imgRef.current?.complete) {
setImgLoaded(true);
}
}, [data, imgLoaded]);
// If we are enabled (isInView) but have no data and no error yet,
// we are effectively in a "pending load" state.
const isLoadingOrPending = loading || (isInView && !data && !error);
if (!isInView || isLoadingOrPending) {
// Use className for wrapper if provided, otherwise generic
// We attach the ref here to detect when this placeholder comes into view
return <div ref={ref} className={`animate-pulse bg-gray-200 w-full h-full ${className || ''}`} />;
}
if (error || !data) {
// Fallback if generation failed
if (typeof src === 'string') {
return <img src={src} alt={alt} className={imgClassName || className} {...props} />;
}
return <div className="text-red-500 text-xs">Failed to load image</div>;
}
return (
<div className={`relative w-full h-full ${className || ''}`}>
<picture>
{(data.sources || []).map((source, index) => (
<source key={index} srcSet={source.srcset} type={source.type} sizes={sizes} />
))}
<img
ref={imgRef}
src={data.img.src}
alt={alt}
// Only apply width/height if they are known (> 0)
// This fixes an issue where Data URIs (which return 0/0) render as invisible 0x0 images
width={data.img.width > 0 ? data.img.width : undefined}
height={data.img.height > 0 ? data.img.height : undefined}
className={`${imgClassName || ''} transition-opacity duration-300 ${imgLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={(e) => {
setImgLoaded(true);
onLoad?.(e);
}}
onError={(e) => {
setImgLoaded(true);
onError?.(e);
}}
loading={props.loading || "lazy"}
decoding="async"
{...props}
/>
</picture>
{!imgLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100/50 z-10 pointer-events-none">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></div>
</div>
)}
</div>
);
};
export default ResponsiveImage;

View File

@ -0,0 +1,70 @@
import React, { useRef, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from './ui/badge';
interface SimpleLogViewerProps {
logs: any[];
title?: string;
className?: string;
}
const SimpleLogViewer: React.FC<SimpleLogViewerProps> = ({ logs, title = "Stream Logs", className }) => {
const scrollAreaRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight;
}
}, [logs]);
const renderLogEntry = (log: any, index: number) => {
let content: React.ReactNode;
let type: string = 'UNKNOWN';
if (typeof log === 'string') {
content = log;
type = 'TEXT';
} else if (log.type === 'message' && log.data) {
type = `MESSAGE: ${log.data.role}`;
content = JSON.stringify(log.data.content || log.data.tool_calls, null, 2);
} else if (log.type === 'tool_call' && log.data) {
type = `TOOL CALL: ${log.data.function?.name}`;
content = log.data.function?.arguments;
} else if (log.type === 'content_chunk' && typeof log.data === 'string') {
type = 'CONTENT';
content = log.data;
} else {
type = 'RAW_OBJECT';
content = JSON.stringify(log, null, 2);
}
return (
<div key={index} className="whitespace-pre-wrap mb-2 break-words p-2 rounded bg-background">
<Badge variant="outline" className="mb-1">{type}</Badge>
<pre className="text-xs">{content}</pre>
</div>
);
};
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea ref={scrollAreaRef} className="h-64 w-full rounded-md border p-4 font-mono text-sm">
{logs.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
Waiting for stream...
</div>
) : (
logs.map(renderLogEntry)
)}
</ScrollArea>
</CardContent>
</Card>
);
};
export default SimpleLogViewer;

View File

@ -0,0 +1,53 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { QuickAction } from '@/constants';
import { cn } from '@/lib/utils';
interface StylePresetSelectorProps {
presets: QuickAction[];
onSelect: (preset: QuickAction) => void;
disabled?: boolean;
activeId?: string;
className?: string;
variant?: 'default' | 'minimal' | 'compact';
}
export const StylePresetSelector: React.FC<StylePresetSelectorProps> = ({
presets,
onSelect,
disabled = false,
activeId,
className = '',
variant = 'default',
}) => {
if (presets.length === 0) return null;
return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>
{presets.map((preset) => (
<Button
key={preset.id}
type="button"
variant={variant === 'minimal' ? 'ghost' : 'secondary'}
size="sm"
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
onSelect(preset);
}}
className={cn(
variant === 'minimal'
? 'h-6 px-2.5 text-xs text-white border border-white/20 hover:bg-white/20 hover:text-white rounded-full bg-black/40 backdrop-blur-md shadow-sm transition-all duration-200 hover:scale-105'
: 'h-8 px-3 text-xs',
activeId === preset.id && 'ring-2 ring-primary bg-primary/20',
"transition-all duration-200"
)}
title={preset.prompt}
>
{preset.icon && <span className="mr-1.5">{preset.icon}</span>}
<span>{preset.name}</span>
</Button>
))}
</div>
);
};

View File

@ -0,0 +1,93 @@
import React from 'react';
import { PromptTemplate } from '../types';
import { Save, Download, Upload } from 'lucide-react';
interface TemplateManagerProps {
prompts: PromptTemplate[];
currentPrompt: string;
onSelectPrompt: (prompt: string) => void;
onSavePrompt: (name: string, text: string) => void;
onImportPrompts: () => void;
onExportPrompts: () => void;
}
const TemplateManager: React.FC<TemplateManagerProps> = ({
prompts,
currentPrompt,
onSelectPrompt,
onSavePrompt,
onImportPrompts,
onExportPrompts,
}) => {
const handleSaveTemplate = () => {
if (!currentPrompt.trim()) return;
const name = prompt('Enter template name:');
if (name && name.trim()) {
onSavePrompt(name.trim(), currentPrompt);
}
};
return (
<div className="border border-slate-200/50 dark:border-white/30 rounded-xl p-4 bg-slate-50/30 dark:bg-slate-800/90">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Left: Template Picker */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-white mb-2">Templates</h4>
<div className="flex flex-wrap gap-1">
{prompts.length === 0 ? (
<span className="text-xs text-slate-500 dark:text-slate-200">No templates saved yet</span>
) : (
prompts.map((template) => (
<button
key={template.name}
type="button"
onClick={() => onSelectPrompt(template.text)}
className="text-xs px-2 py-1 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-700 dark:hover:bg-purple-600 text-purple-700 dark:text-white transition-colors duration-200"
title={`Load template: ${template.text.substring(0, 50)}...`}
>
{template.name}
</button>
))
)}
</div>
</div>
{/* Right: Template Management Icons */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-white mb-2">Manage</h4>
<div className="flex flex-col sm:flex-row gap-2">
<button
type="button"
onClick={handleSaveTemplate}
disabled={!currentPrompt.trim()}
className="p-2 rounded bg-green-100 hover:bg-green-200 dark:bg-green-600 dark:hover:bg-green-500 text-green-700 dark:text-white transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Save current prompt as template"
>
<Save size={16} />
</button>
<button
type="button"
onClick={onImportPrompts}
className="p-2 rounded bg-blue-100 hover:bg-blue-200 dark:bg-blue-600 dark:hover:bg-blue-500 text-blue-700 dark:text-white transition-colors duration-200"
title="Import templates from file"
>
<Upload size={16} />
</button>
<button
type="button"
onClick={onExportPrompts}
disabled={prompts.length === 0}
className="p-2 rounded bg-orange-100 hover:bg-orange-200 dark:bg-orange-600 dark:hover:bg-orange-500 text-orange-700 dark:text-white transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Export templates to file"
>
<Download size={16} />
</button>
</div>
</div>
</div>
</div>
);
};
export default TemplateManager;

View File

@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "light",
storageKey = "ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};

View File

@ -0,0 +1,20 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/components/ThemeProvider";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Button
variant="ghost"
size="sm"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className="h-9 w-9 px-0"
>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
);
}

View File

@ -0,0 +1,255 @@
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import { useOrganization } from "@/contexts/OrganizationContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Home, User, Upload, LogOut, LogIn, Camera, Wand2, Search, Grid3x3, Globe, ListFilter, Shield, Activity } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useLog } from "@/contexts/LogContext";
import { useWizardContext } from "@/hooks/useWizardContext";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useNavigate } from "react-router-dom";
import { useState, useRef } from "react";
import { T, getCurrentLang, supportedLanguages, translate, setLanguage } from "@/i18n";
import { CreationWizardPopup } from './CreationWizardPopup';
const TopNavigation = () => {
const { user, signOut, roles } = useAuth();
const { orgSlug, isOrgContext } = useOrganization();
const location = useLocation();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
const currentLang = getCurrentLang();
const { isLoggerVisible, setLoggerVisible } = useLog();
const { clearWizardImage, creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext();
const authPath = isOrgContext ? `/org/${orgSlug}/auth` : '/auth';
const isActive = (path: string) => location.pathname === path;
const handleLanguageChange = (langCode: string) => {
setLanguage(langCode as any);
};
const handleSignOut = async () => {
await signOut();
};
const handleWizardOpen = () => {
setCreationWizardOpen(true);
};
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
searchInputRef.current?.blur();
}
};
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setSearchQuery('');
searchInputRef.current?.blur();
}
};
return (
<header className="sticky top-0 z-50 w-full h-14 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 landscape:hidden lg:landscape:block">
<div className="flex items-center justify-between px-2 h-full">
{/* Logo / Brand */}
<Link to="/" className="flex items-center space-x-2">
<Camera className="h-6 w-6 text-primary" />
<span className="font-bold text-lg hidden sm:inline-block">PixelHub</span>
</Link>
{/* Search Bar - Center */}
<div className="flex-1 max-w-md mx-4 hidden sm:block">
<form onSubmit={handleSearchSubmit} className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
type="search"
placeholder={translate("Search pictures, users, collections...")}
className="pl-10 pr-4 h-9 w-full bg-muted/50 border-0 focus-visible:ring-1 focus-visible:ring-primary"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
/>
</form>
</div>
{/* Center Navigation - Desktop only, moved after search */}
<nav className="hidden lg:flex items-center space-x-6">
</nav>
{/* Right Side Actions */}
<div className="flex items-center space-x-2">
{/* Mobile Search Button */}
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/search')}
className="h-8 w-8 p-0 sm:hidden"
title={translate("Search")}
>
<Search className="h-4 w-4" />
</Button>
{/* Magic Button - AI Image Generator */}
<Button
variant="ghost"
size="sm"
onClick={handleWizardOpen}
className="h-8 w-8 p-0"
title={translate("AI Image Generator")}
>
<Wand2 className="h-4 w-4" />
</Button>
{/* Profile Grid Button - Direct to profile feed */}
{user && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/user/${user.id}`)}
className="h-8 w-8 p-0"
title={translate("My Profile")}
>
<Grid3x3 className="h-4 w-4" />
</Button>
)}
{/* Mobile Navigation */}
<div className="flex md:hidden items-center space-x-1">
<Button
variant={isActive('/') ? "default" : "ghost"}
size="sm"
asChild
className="h-8 w-8 p-0"
>
<Link to="/">
<Home className="h-4 w-4" />
</Link>
</Button>
</div>
{/* Language Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
title={translate("Language")}
>
<Globe className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{supportedLanguages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onSelect={() => handleLanguageChange(lang.code)}
className={currentLang === lang.code ? 'bg-accent' : ''}
>
{lang.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<ThemeToggle />
<Button
variant="ghost"
size="sm"
asChild
className={`h-8 w-8 p-0 ${isActive('/logs') ? "text-primary bg-accent" : ""}`}
title={translate("Logs Dashboard")}
>
<Link to="/logs">
<Activity className="h-4 w-4" />
</Link>
</Button>
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-gradient-primary text-white">
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<div className="flex items-center justify-start gap-2 p-2">
<div className="flex flex-col space-y-1 leading-none">
<p className="font-medium">User {user.id.slice(0, 8)}</p>
<p className="w-[200px] truncate text-sm text-muted-foreground">
{user.email}
</p>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={`/user/${user.id}`} className="flex items-center">
<User className="mr-2 h-4 w-4" />
<T>Profile</T>
</Link>
</DropdownMenuItem>
{roles.includes("admin") && (
<DropdownMenuItem asChild>
<Link to="/admin/users" className="flex items-center">
<Shield className="mr-2 h-4 w-4" />
<T>Admin</T>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to="/?upload=true" className="flex items-center">
<Upload className="mr-2 h-4 w-4" />
<T>Upload</T>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut} className="flex items-center">
<LogOut className="mr-2 h-4 w-4" />
<T>Sign out</T>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button variant="ghost" size="sm" asChild>
<Link to={authPath} className="flex items-center">
<LogIn className="mr-2 h-4 w-4" />
<T>Sign in</T>
</Link>
</Button>
)}
</div>
</div>
<CreationWizardPopup
isOpen={creationWizardOpen}
onClose={() => setCreationWizardOpen(false)}
preloadedImages={wizardInitialImage ? [wizardInitialImage] : []}
initialMode={creationWizardMode}
/>
</header>
);
};
export default TopNavigation;

View File

@ -0,0 +1,226 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { Upload, X } from 'lucide-react';
import MarkdownEditor from '@/components/MarkdownEditor';
import { useOrganization } from '@/contexts/OrganizationContext';
import { uploadImage } from '@/lib/uploadUtils';
const uploadSchema = z.object({
title: z.string().max(100, 'Title must be less than 100 characters').optional(),
description: z.string().max(1000, 'Description must be less than 1000 characters').optional(),
file: z.any().refine((file) => file && file.length > 0, 'Please select a file'),
});
type UploadFormData = z.infer<typeof uploadSchema>;
interface UploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onUploadSuccess: () => void;
}
const UploadModal = ({ open, onOpenChange, onUploadSuccess }: UploadModalProps) => {
const [uploading, setUploading] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const { user } = useAuth();
const { toast } = useToast();
const { orgSlug, isOrgContext } = useOrganization();
const form = useForm<UploadFormData>({
resolver: zodResolver(uploadSchema),
defaultValues: {
title: '',
description: '',
},
});
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
form.setValue('file', event.target.files);
}
};
const onSubmit = async (data: UploadFormData) => {
if (!user) return;
setUploading(true);
try {
const file = data.file[0];
// Upload file to storage (direct or via proxy)
const { publicUrl } = await uploadImage(file, user.id);
// Get organization ID if in org context
let organizationId = null;
if (isOrgContext && orgSlug) {
const { data: org } = await supabase
.from('organizations')
.select('id')
.eq('slug', orgSlug)
.single();
organizationId = org?.id || null;
}
// Save picture metadata to database
const { error: dbError } = await supabase
.from('pictures')
.insert({
user_id: user.id,
title: data.title?.trim() || null,
description: data.description || null,
image_url: publicUrl,
organization_id: organizationId,
});
if (dbError) throw dbError;
toast({
title: "Picture uploaded successfully!",
description: "Your picture has been shared with the community.",
});
form.reset();
setPreviewUrl(null);
onOpenChange(false);
onUploadSuccess();
} catch (error: any) {
toast({
title: "Upload failed",
description: error.message,
variant: "destructive",
});
} finally {
setUploading(false);
}
};
const handleClose = () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
form.reset();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload Picture
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="file"
render={() => (
<FormItem>
<FormLabel>Picture</FormLabel>
<FormControl>
<div className="space-y-2">
<Input
type="file"
accept="image/*"
onChange={handleFileChange}
className="cursor-pointer"
/>
{previewUrl && (
<div className="relative">
<img
src={previewUrl}
alt="Preview"
className="w-full h-48 object-cover rounded-lg"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 bg-black/50 hover:bg-black/70"
onClick={() => {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
form.setValue('file', null);
}}
>
<X className="h-4 w-4 text-white" />
</Button>
</div>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title (Optional)</FormLabel>
<FormControl>
<Input placeholder="Enter a title..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (Optional)</FormLabel>
<FormControl>
<MarkdownEditor
value={field.value || ''}
onChange={field.onChange}
placeholder="Describe your photo... You can use **markdown** formatting!"
className="min-h-[120px]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={handleClose}
disabled={uploading}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={uploading}
>
{uploading ? 'Uploading...' : 'Upload'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
export default UploadModal;

View File

@ -0,0 +1,86 @@
import React, { useState, useEffect } from "react";
import { User as UserIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useNavigate } from "react-router-dom";
import { useProfiles } from "@/contexts/ProfilesContext";
interface UserAvatarBlockProps {
userId: string;
avatarUrl?: string | null;
displayName?: string | null;
createdAt?: string;
className?: string;
showDate?: boolean;
onClick?: (e: React.MouseEvent) => void;
hoverStyle?: boolean;
textSize?: "xs" | "sm" | "base";
}
const UserAvatarBlock: React.FC<UserAvatarBlockProps> = ({
userId,
avatarUrl,
displayName,
createdAt,
className = "w-8 h-8",
showDate = true,
onClick,
hoverStyle = false,
textSize = "sm"
}) => {
const navigate = useNavigate();
const { profiles, fetchProfile } = useProfiles();
// Use prop if available, otherwise look up in context
const profile = profiles[userId];
const effectiveAvatarUrl = avatarUrl || profile?.avatar_url;
// Prefer prop displayName if truthy (e.g. override), else usage context
const effectiveDisplayName = displayName || profile?.display_name || `User ${userId.slice(0, 8)}`;
useEffect(() => {
// If we don't have the profile in context, ask for it
if (!profile) {
fetchProfile(userId);
}
}, [userId, profile, fetchProfile]);
const handleClick = (e: React.MouseEvent) => {
if (onClick) {
onClick(e);
return;
}
e.stopPropagation();
window.location.href = `/user/${userId}`;
};
const nameClass = hoverStyle
? `text-white text-${textSize} font-medium hover:underline cursor-pointer`
: `font-semibold hover:underline text-${textSize}`;
const dateClass = hoverStyle
? "text-white/60 text-xs"
: "text-xs text-muted-foreground";
return (
<div className="flex items-center space-x-2" onClick={handleClick}>
<Avatar className={`${className} bg-background hover:scale-105 transition-transform cursor-pointer`}>
<AvatarImage src={effectiveAvatarUrl || undefined} alt={effectiveDisplayName || "Avatar"} className="object-cover" />
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-indigo-600 text-white">
<UserIcon className="h-1/2 w-1/2" />
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className={nameClass}>
{effectiveDisplayName}
</span>
{showDate && createdAt && (
<span className={dateClass}>
{new Date(createdAt).toLocaleDateString()}
</span>
)}
</div>
</div>
);
};
export default UserAvatarBlock;

View File

@ -0,0 +1,315 @@
import { useState, useEffect } from "react";
import { supabase } from "@/integrations/supabase/client";
import { MediaItem } from "@/types";
import { normalizeMediaType, isVideoType, detectMediaType } from "@/lib/mediaRegistry";
import { T, translate } from "@/i18n";
import { Loader2, ImageOff, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { DeleteDialog } from "@/pages/Post/components/DeleteDialogs";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
interface UserPicturesProps {
userId: string;
isOwner?: boolean;
}
interface PostGroup {
postId: string;
postTitle: string;
pictures: MediaItem[];
}
const UserPictures = ({ userId, isOwner }: UserPicturesProps) => {
const [loading, setLoading] = useState(true);
const [postGroups, setPostGroups] = useState<PostGroup[]>([]);
const [orphanedPictures, setOrphanedPictures] = useState<MediaItem[]>([]);
// Deletion states
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<{ type: 'post' | 'picture', id: string, title?: string } | null>(null);
const navigate = useNavigate();
useEffect(() => {
fetchUserPictures();
}, [userId]);
const fetchUserPictures = async (showLoading = true) => {
try {
if (showLoading) setLoading(true);
// 1. Fetch all pictures for the user
const { data: picturesData, error: picturesError } = await supabase
.from('pictures')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false });
if (picturesError) throw picturesError;
const pictures = (picturesData || []) as MediaItem[];
// 2. Fetch all posts for the user to get titles
const { data: postsData, error: postsError } = await supabase
.from('posts')
.select('id, title')
.eq('user_id', userId);
if (postsError) throw postsError;
const postsMap = new Map<string, string>();
postsData?.forEach(post => {
postsMap.set(post.id, post.title);
});
// 3. Group pictures
const groups = new Map<string, MediaItem[]>();
const orphans: MediaItem[] = [];
pictures.forEach(pic => {
const picAny = pic as any;
const postId = picAny.post_id;
if (postId && postsMap.has(postId)) {
if (!groups.has(postId)) {
groups.set(postId, []);
}
groups.get(postId)?.push(pic);
} else {
orphans.push(pic);
}
});
// Convert groups map to array
const groupedResult: PostGroup[] = [];
groups.forEach((pics, postId) => {
groupedResult.push({
postId,
postTitle: postsMap.get(postId) || translate('Untitled Post'),
pictures: pics
});
});
setPostGroups(groupedResult);
setOrphanedPictures(orphans);
} catch (error) {
console.error("Error fetching user pictures:", error);
toast.error(translate("Failed to load pictures"));
} finally {
setLoading(false);
}
};
const handleMediaClick = (pic: MediaItem) => {
const picAny = pic as any;
if (picAny.post_id) {
navigate(`/post/${picAny.post_id}`);
} else {
navigate(`/post/${pic.id}`);
}
};
const initiateDelete = (type: 'post' | 'picture', id: string, title?: string) => {
setItemToDelete({ type, id, title });
setDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (!itemToDelete) return;
try {
if (itemToDelete.type === 'post') {
const { error } = await supabase
.from('posts')
.delete()
.eq('id', itemToDelete.id);
if (error) throw error;
toast.success(translate("Post deleted successfully"));
} else {
// Delete picture
const { error } = await supabase
.from('pictures')
.delete()
.eq('id', itemToDelete.id);
if (error) throw error;
// Ideally we check if we should delete from storage too, similar to Profile logic
// For now we trust triggers or standard behavior.
// The Profile implementation manually deletes from storage.
// To match that:
// We would need to fetch the picture first to get the URL, but here we can just do the DB delete
// as this is what was requested and storage cleanup often handled separately or via another call.
// Given "remove pictures" simple request, DB delete is the primary action.
toast.success(translate("Picture deleted successfully"));
}
fetchUserPictures(false);
setDeleteDialogOpen(false);
setItemToDelete(null);
} catch (error) {
console.error("Error deleting item:", error);
toast.error(translate("Failed to delete item"));
}
};
if (loading) {
return (
<div className="flex justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (postGroups.length === 0 && orphanedPictures.length === 0) {
return (
<div className="flex flex-col items-center justify-center p-12 text-muted-foreground">
<ImageOff className="h-12 w-12 mb-4 opacity-50" />
<p><T>No pictures found</T></p>
</div>
);
}
return (
<div className="space-y-8 pb-12">
<DeleteDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleConfirmDelete}
title={itemToDelete?.type === 'post' ? translate("Delete Post") : translate("Delete Picture")}
description={itemToDelete?.type === 'post'
? translate("Are you sure you want to delete this post? All associated pictures may also be deleted or orphaned depending on settings.")
: translate("Are you sure you want to delete this picture?")
}
/>
{/* Orphaned Pictures Section */}
{orphanedPictures.length > 0 && (
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<span className="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm">
{orphanedPictures.length}
</span>
<T>Unused / Orphaned Pictures</T>
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{orphanedPictures.map(pic => (
<SimpleMediaCard
key={pic.id}
item={pic}
onClick={() => handleMediaClick(pic)}
onDelete={isOwner ? () => initiateDelete('picture', pic.id, pic.title) : undefined}
/>
))}
</div>
</div>
)}
{/* Grouped by Post */}
{postGroups.map(group => (
<div key={group.postId} className="border rounded-xl p-6 bg-card/50">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-semibold truncate hover:underline cursor-pointer" onClick={() => navigate(`/post/${group.postId}`)}>
{group.postTitle}
</h3>
<div className="flex items-center gap-3">
<span className="text-muted-foreground text-sm">{group.pictures.length} <T>pictures</T></span>
{isOwner && (
<Button
variant="destructive"
size="sm"
onClick={() => initiateDelete('post', group.postId, group.postTitle)}
>
<Trash2 className="h-4 w-4 mr-2" />
<T>Delete Post</T>
</Button>
)}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{group.pictures.map(pic => (
<SimpleMediaCard
key={pic.id}
item={pic}
onClick={() => handleMediaClick(pic)}
onDelete={isOwner ? () => initiateDelete('picture', pic.id, pic.title) : undefined}
/>
))}
</div>
</div>
))}
</div>
);
};
interface SimpleMediaCardProps {
item: MediaItem;
onClick: () => void;
onDelete?: () => void;
}
const SimpleMediaCard = ({ item, onClick, onDelete }: SimpleMediaCardProps) => {
const effectiveType = item.type || detectMediaType(item.image_url);
const isVideo = isVideoType(normalizeMediaType(effectiveType));
return (
<div className="group relative aspect-square rounded-lg overflow-hidden bg-muted cursor-pointer border hover:border-primary/50 transition-all">
<div onClick={onClick} className="w-full h-full">
{/* Image/Video Content */}
{isVideo ? (
<div className="w-full h-full flex items-center justify-center bg-black/10">
{item.thumbnail_url ? (
<img src={item.thumbnail_url} className="w-full h-full object-cover" alt={item.title} />
) : (
<span className="text-xs">Video</span>
)}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center">
<div className="w-0 h-0 border-t-4 border-t-transparent border-l-8 border-l-white border-b-4 border-b-transparent ml-1"></div>
</div>
</div>
</div>
) : (
<img
src={item.thumbnail_url || item.image_url}
alt={item.title}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute bottom-0 left-0 right-0 p-3">
<p className="text-white text-xs truncate font-medium">{item.title}</p>
</div>
</div>
</div>
{/* Delete Button - Top Right Overlay */}
{onDelete && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<Button
variant="destructive"
size="icon"
className="h-8 w-8 rounded-full shadow-md"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
};
export default UserPictures;

View File

@ -0,0 +1,363 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Check, Image as ImageIcon, Eye, EyeOff, Trash2 } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { T, translate } from '@/i18n';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Version {
id: string;
title: string;
image_url: string;
is_selected: boolean;
created_at: string;
parent_id: string | null;
visible: boolean;
user_id: string; // Added for storage deletion path
}
interface VersionSelectorProps {
currentPictureId: string;
onVersionSelect: (selectedVersionId: string) => void;
}
const VersionSelector: React.FC<VersionSelectorProps> = ({
currentPictureId,
onVersionSelect
}) => {
const { user } = useAuth();
const [versions, setVersions] = useState<Version[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [toggling, setToggling] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [versionToDelete, setVersionToDelete] = useState<Version | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
loadVersions();
}, [currentPictureId]);
const loadVersions = async () => {
if (!user || !currentPictureId) return;
setLoading(true);
try {
// Get the current picture to determine if it's a parent or child
const { data: currentPicture, error: currentError } = await supabase
.from('pictures')
.select('id, parent_id, title, image_url, is_selected, created_at, visible')
.eq('id', currentPictureId)
.single();
if (currentError) throw currentError;
// Determine the root parent ID
const rootParentId = currentPicture.parent_id || currentPicture.id;
// Get all versions (parent + children) for this image tree
const { data: allVersions, error: versionsError } = await supabase
.from('pictures')
.select('id, title, image_url, is_selected, created_at, parent_id, visible, user_id')
.eq('user_id', user.id)
.or(`id.eq.${rootParentId},parent_id.eq.${rootParentId}`)
.order('created_at', { ascending: true });
if (versionsError) throw versionsError;
setVersions(allVersions || []);
} catch (error) {
console.error('Error loading versions:', error);
toast.error(translate('Failed to load image versions'));
} finally {
setLoading(false);
}
};
const handleVersionSelect = async (versionId: string) => {
if (!user) return;
setUpdating(versionId);
try {
// First, unselect all versions in this image tree
const rootParentId = versions.find(v => v.parent_id === null)?.id || currentPictureId;
await supabase
.from('pictures')
.update({ is_selected: false })
.eq('user_id', user.id)
.or(`id.eq.${rootParentId},parent_id.eq.${rootParentId}`);
// Then select the chosen version
const { error: selectError } = await supabase
.from('pictures')
.update({ is_selected: true })
.eq('id', versionId)
.eq('user_id', user.id);
if (selectError) throw selectError;
// Update local state
setVersions(prevVersions =>
prevVersions.map(v => ({
...v,
is_selected: v.id === versionId
}))
);
toast.success(translate('Version selected successfully!'));
onVersionSelect(versionId);
} catch (error) {
console.error('Error selecting version:', error);
toast.error(translate('Failed to select version'));
} finally {
setUpdating(null);
}
};
const handleToggleVisibility = async (versionId: string, currentVisibility: boolean) => {
if (!user) return;
setToggling(versionId);
try {
const { error } = await supabase
.from('pictures')
.update({ visible: !currentVisibility })
.eq('id', versionId)
.eq('user_id', user.id);
if (error) throw error;
// Update local state
setVersions(prevVersions =>
prevVersions.map(v => ({
...v,
visible: v.id === versionId ? !currentVisibility : v.visible
}))
);
toast.success(translate(!currentVisibility ? 'Version made visible successfully!' : 'Version hidden successfully!'));
} catch (error) {
console.error('Error toggling visibility:', error);
toast.error(translate('Failed to update visibility'));
} finally {
setToggling(null);
}
};
const handleDeleteClick = (version: Version) => {
setVersionToDelete(version);
setShowDeleteDialog(true);
};
const confirmDelete = async () => {
if (!versionToDelete || !user) return;
setIsDeleting(true);
try {
// 1. Find all descendants to delete (cascade)
const { data: allUserPictures, error: loadError } = await supabase
.from('pictures')
.select('*')
.eq('user_id', user.id); // Fetch all to build tree (optimization: could just fetch relevant sub-tree but this is safer)
if (loadError) throw loadError;
const findDescendants = (parentId: string): typeof versionToDelete[] => {
const descendants: typeof versionToDelete[] = [];
const children = allUserPictures.filter(p => p.parent_id === parentId);
children.forEach(child => {
descendants.push(child);
descendants.push(...findDescendants(child.id));
});
return descendants;
};
const descendantsToDelete = findDescendants(versionToDelete.id);
const allToDelete = [versionToDelete, ...descendantsToDelete];
// 2. Delete from Storage for ALL items
for (const item of allToDelete) {
if (item.image_url?.includes('supabase.co/storage/')) {
const urlParts = item.image_url.split('/');
const fileName = urlParts[urlParts.length - 1];
const bucketPath = `${item.user_id}/${fileName}`;
await supabase.storage
.from('pictures')
.remove([bucketPath]);
}
}
// 3. Delete from Database (bulk)
const { error: dbError } = await supabase
.from('pictures')
.delete()
.in('id', allToDelete.map(v => v.id));
if (dbError) throw dbError;
// 4. Update local state
const deletedIds = new Set(allToDelete.map(v => v.id));
setVersions(prev => prev.filter(v => !deletedIds.has(v.id)));
const totalDeleted = allToDelete.length;
toast.success(translate(`Deleted ${totalDeleted > 1 ? `${totalDeleted} versions` : 'version'} successfully`));
} catch (error) {
console.error('Error deleting version:', error);
toast.error(translate('Failed to delete version'));
} finally {
setIsDeleting(false);
setShowDeleteDialog(false);
setVersionToDelete(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center p-4">
<div className="text-muted-foreground"><T>Loading versions...</T></div>
</div>
);
}
if (versions.length <= 1) {
return (
<div className="text-center p-4">
<p className="text-muted-foreground"><T>No other versions available for this image.</T></p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<ImageIcon className="h-5 w-5" />
<h3 className="font-semibold"><T>Image Versions</T></h3>
<Badge variant="secondary">{versions.length}</Badge>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{versions.map((version) => (
<Card
key={version.id}
className={`cursor-pointer transition-all hover:scale-105 ${version.is_selected ? 'ring-2 ring-primary' : ''
}`}
>
<CardContent className="p-2">
<div className="aspect-square relative mb-2 overflow-hidden rounded-md">
<img
src={version.image_url}
alt={version.title}
className="w-full h-full object-cover"
/>
{version.is_selected && (
<div className="absolute top-2 right-2 bg-primary text-primary-foreground rounded-full p-1">
<Check className="h-3 w-3" />
</div>
)}
</div>
<div className="space-y-1">
<p className="text-xs font-medium truncate">{version.title}</p>
<p className="text-xs text-muted-foreground">
{new Date(version.created_at).toLocaleDateString()}
</p>
<div className="flex items-center gap-1">
{version.parent_id === null && (
<Badge variant="outline" className="text-xs"><T>Original</T></Badge>
)}
{!version.visible && (
<Badge variant="secondary" className="text-xs"><T>Hidden</T></Badge>
)}
</div>
</div>
<div className="flex gap-1 mt-2">
<Button
size="sm"
className="flex-1"
variant={version.is_selected ? "default" : "outline"}
onClick={() => handleVersionSelect(version.id)}
disabled={updating === version.id}
>
<T>{updating === version.id ? 'Selecting...' :
version.is_selected ? 'Selected' : 'Select'}</T>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleToggleVisibility(version.id, version.visible)}
disabled={toggling === version.id}
className="px-2"
>
{toggling === version.id ? (
<div className="animate-spin rounded-full h-3 w-3 border border-current border-t-transparent" />
) : version.visible ? (
<Eye className="h-3 w-3" />
) : (
<EyeOff className="h-3 w-3" />
)}
</Button>
<Button
size="sm"
variant="ghost"
className="px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDeleteClick(version)}
disabled={isDeleting || version.id === currentPictureId} // Disable deleting the currently ACTIVE one? Or just handle correctly?
title={translate("Delete version")}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle><T>Delete Version</T></AlertDialogTitle>
<AlertDialogDescription>
<T>Are you sure you want to delete this version?</T> "{versionToDelete?.title}"
<br /><br />
<span className="text-destructive font-semibold">
<T>This action cannot be undone.</T>
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel><T>Cancel</T></AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
confirmDelete();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isDeleting}
>
{isDeleting ? <T>Deleting...</T> : <T>Delete</T>}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default VersionSelector;

View File

@ -0,0 +1,712 @@
import { Heart, Download, Share2, MessageCircle, Edit3, Trash2, Layers, Loader2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useState, useEffect, useRef } from "react";
import MarkdownRenderer from "@/components/MarkdownRenderer";
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
import { useNavigate, useLocation } from "react-router-dom";
import { T, translate } from "@/i18n";
import type { MuxResolution } from "@/types";
import EditVideoModal from "@/components/EditVideoModal";
import { detectMediaType, MEDIA_TYPES } from "@/lib/mediaRegistry";
import UserAvatarBlock from "@/components/UserAvatarBlock";
import { formatDate, isLikelyFilename } from "@/utils/textUtils";
import {
MediaPlayer, MediaProvider, type MediaPlayerInstance
} from '@vidstack/react';
// Import Vidstack styles
import '@vidstack/react/player/styles/default/theme.css';
import '@vidstack/react/player/styles/default/layouts/video.css';
interface VideoCardProps {
videoId: string;
videoUrl: string;
thumbnailUrl?: string;
title: string;
author: string;
authorId: string;
likes: number;
comments: number;
isLiked?: boolean;
description?: string | null;
onClick?: (videoId: string) => void;
onLike?: () => void;
onDelete?: () => void;
maxResolution?: MuxResolution;
authorAvatarUrl?: string | null;
showContent?: boolean;
created_at?: string;
job?: any;
variant?: 'grid' | 'feed';
showPlayButton?: boolean;
apiUrl?: string;
}
const VideoCard = ({
videoId,
videoUrl,
thumbnailUrl,
title,
author,
authorId,
likes,
comments,
isLiked = false,
description,
onClick,
onLike,
onDelete,
maxResolution = '270p',
authorAvatarUrl,
showContent = true,
showPlayButton = false,
created_at,
job,
variant = 'grid',
apiUrl
}: VideoCardProps) => {
const { user } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [localIsLiked, setLocalIsLiked] = useState(isLiked);
const [localLikes, setLocalLikes] = useState(likes);
const [isDeleting, setIsDeleting] = useState(false);
const [versionCount, setVersionCount] = useState<number>(0);
const [isPlaying, setIsPlaying] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const player = useRef<MediaPlayerInstance>(null);
// Stop playback on navigation & Cleanup
useEffect(() => {
console.log(`[VideoCard ${videoId}] Mounted`);
const handleNavigation = () => {
if (isPlaying) {
console.log(`[VideoCard ${videoId}] Navigation detected - stopping`);
}
setIsPlaying(false);
player.current?.pause();
};
handleNavigation();
return () => {
console.log(`[VideoCard ${videoId}] Unmounting - pausing player`);
player.current?.pause();
};
}, [location.pathname]);
const [processingStatus, setProcessingStatus] = useState<'active' | 'completed' | 'failed' | 'unknown'>('unknown');
const [progress, setProgress] = useState(0);
const [videoMeta, setVideoMeta] = useState<any>(null);
const isOwner = user?.id === authorId;
const mediaType = detectMediaType(videoUrl);
const isInternalVideo = mediaType === MEDIA_TYPES.VIDEO_INTERN;
// Handle poster URL based on media type
const posterUrl = (() => {
if (!thumbnailUrl) return undefined;
if (isInternalVideo) {
return thumbnailUrl; // Use direct thumbnail for internal videos
}
// Default to Mux behavior
return `${thumbnailUrl}?width=640&height=640&fit_mode=smartcrop&time=0`;
})();
// Add max_resolution query parameter to video URL for bandwidth optimization
// See: https://www.mux.com/docs/guides/control-playback-resolution
const getVideoUrlWithResolution = (url: string) => {
if (isInternalVideo) return url; // Internal videos handle quality differently (via HLS/presets in future)
try {
const urlObj = new URL(url);
urlObj.searchParams.set('max_resolution', maxResolution);
return urlObj.toString();
} catch {
// If URL parsing fails, append as query string
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}max_resolution=${maxResolution}`;
}
};
const playbackUrl = getVideoUrlWithResolution(videoUrl);
// Fetch version count for owners only
useEffect(() => {
const fetchVersionCount = async () => {
if (!isOwner || !user) return;
return;
};
fetchVersionCount();
}, [videoId, isOwner, user]);
// Handle Video Processing Status (SSE)
useEffect(() => {
if (!isInternalVideo) return;
// 1. Use verified job data from server if available (e.g. from Feed)
if (job) {
if (job.status === 'completed') {
setProcessingStatus('completed');
// If we have a verified resultUrl, we might want to use it?
// But the parent component passes `videoUrl`, so we assume parent updated it or logic below handles it.
return;
}
if (job.status === 'failed') {
setProcessingStatus('failed');
return;
}
// If active, we fall through to start SSE below, but init state first
setProcessingStatus('active');
setProgress(job.progress || 0);
}
// Extract Job ID from URL
// Format: .../api/videos/jobs/:jobId/... (regex: /api/videos/jobs/([^/]+)\//)
const match = videoUrl.match(/\/api\/videos\/jobs\/([^\/]+)\//) || (job ? [null, job.id] : null);
if (!match) return;
const jobId = match[1];
// Use VITE_SERVER_IMAGE_API_URL or default. Do NOT infer from videoUrl if it logic fails.
let baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333';
// Fallback: If videoUrl is internal (same origin), use that origin.
// But for Supabase URLs, we MUST use the API server URL.
if (videoUrl.startsWith('/') || videoUrl.includes(window.location.origin)) {
baseUrl = window.location.origin;
}
let eventSource: EventSource | null = null;
let isMounted = true;
const checkStatusAndConnect = async () => {
if (!jobId) return;
try {
const res = await fetch(`${baseUrl}/api/videos/jobs/${jobId}`);
if (!res.ok) throw new Error('Failed to fetch job');
const data = await res.json();
if (!isMounted) return;
if (data.status === 'completed') {
setProcessingStatus('completed');
return;
} else if (data.status === 'failed') {
setProcessingStatus('failed');
return;
}
// Only connect SSE if still active/created/waiting
setProcessingStatus('active');
const sseUrl = `${baseUrl}/api/videos/jobs/${jobId}/progress`;
eventSource = new EventSource(sseUrl);
eventSource.addEventListener('progress', (e) => {
if (!isMounted) return;
try {
const data = JSON.parse((e as MessageEvent).data);
if (data.progress !== undefined) {
setProgress(Math.round(data.progress));
}
if (data.status) {
if (data.status === 'completed') {
setProcessingStatus('completed');
eventSource?.close();
} else if (data.status === 'failed') {
setProcessingStatus('failed');
eventSource?.close();
} else {
setProcessingStatus('active');
}
}
} catch (err) {
console.error('SSE Parse Error', err);
}
});
eventSource.onerror = (e) => {
eventSource?.close();
// Fallback check handled by initial check logic or user refresh
// But we can retry once
};
} catch (error) {
console.error('Error checking video status:', error);
if (isMounted) setProcessingStatus('unknown');
}
};
checkStatusAndConnect();
return () => {
isMounted = false;
if (eventSource) {
eventSource.close();
}
};
}, [isInternalVideo, videoUrl, job]);
const handleCancelProcessing = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm('Cancel this upload?')) return;
const match = videoUrl.match(/\/api\/videos\/jobs\/([^\/]+)\//);
if (!match) return;
const jobId = match[1];
// Use VITE_SERVER_IMAGE_API_URL or default
let baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333';
if (videoUrl.startsWith('/') || videoUrl.includes(window.location.origin)) {
baseUrl = window.location.origin;
}
try {
await fetch(`${baseUrl}/api/videos/jobs/${jobId}`, {
method: 'DELETE'
});
toast.info(translate('Upload cancelled'));
// Trigger delete to remove from UI
onDelete?.();
} catch (err) {
console.error('Failed to cancel', err);
toast.error(translate('Failed to cancel upload'));
}
};
const handleLike = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!user) {
toast.error(translate('Please sign in to like videos'));
return;
}
try {
if (localIsLiked) {
// Unlike - need to implement video likes table
toast.info('Video likes coming soon');
} else {
// Like - need to implement video likes table
toast.info('Video likes coming soon');
}
onLike?.();
} catch (error) {
console.error('Error toggling like:', error);
toast.error(translate('Failed to update like'));
}
};
const handleDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!user || !isOwner) {
toast.error(translate('You can only delete your own videos'));
return;
}
if (!confirm(translate('Are you sure you want to delete this video? This action cannot be undone.'))) {
return;
}
setIsDeleting(true);
try {
// First get the video details for storage cleanup
const { data: video, error: fetchError } = await supabase
.from('pictures')
.select('image_url')
.eq('id', videoId)
.single();
if (fetchError) throw fetchError;
// Delete from database (this will cascade delete likes and comments due to foreign keys)
const { error: deleteError } = await supabase
.from('pictures')
.delete()
.eq('id', videoId);
if (deleteError) throw deleteError;
// Try to delete from storage as well (videos use image_url field for HLS URL)
if (video?.image_url) {
const urlParts = video.image_url.split('/');
const fileName = urlParts[urlParts.length - 1];
const userIdFromUrl = urlParts[urlParts.length - 2];
const { error: storageError } = await supabase.storage
.from('videos')
.remove([`${userIdFromUrl}/${fileName}`]);
if (storageError) {
console.error('Error deleting from storage:', storageError);
// Don't show error to user as the main deletion succeeded
}
}
toast.success(translate('Video deleted successfully'));
onDelete?.(); // Trigger refresh of the parent component
} catch (error) {
console.error('Error deleting video:', error);
toast.error(translate('Failed to delete video'));
} finally {
setIsDeleting(false);
}
};
const handleDownload = async () => {
try {
const link = document.createElement('a');
link.href = videoUrl;
link.download = `${title}.mp4`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success(translate('Video download started'));
} catch (error) {
console.error('Error downloading video:', error);
toast.error(translate('Failed to download video'));
}
};
const handleClick = (e: React.MouseEvent) => {
console.log('Video clicked');
e.preventDefault();
e.stopPropagation();
onClick?.(videoId);
};
// Handle global stop-video event
useEffect(() => {
const handleStopVideo = (e: Event) => {
const customEvent = e as CustomEvent;
if (customEvent.detail?.sourceId !== videoId && isPlaying) {
console.log(`[VideoCard ${videoId}] Stopping due to global event`);
setIsPlaying(false);
player.current?.pause();
}
};
window.addEventListener('stop-video', handleStopVideo);
return () => window.removeEventListener('stop-video', handleStopVideo);
}, [isPlaying, videoId]);
const handlePlayClick = (e: React.MouseEvent) => {
console.log('Play clicked');
e.preventDefault();
e.stopPropagation();
// Stop other videos
window.dispatchEvent(new CustomEvent('stop-video', { detail: { sourceId: videoId } }));
setIsPlaying(true);
};
return (
<div
data-testid="video-card"
className="group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full"
onClick={handleClick}
>
{/* Video Player - usage square aspect to match PhotoCard unless variant is feed */}
<div className={`${variant === 'grid' ? "aspect-square" : "w-full"} overflow-hidden relative`}>
{!isPlaying ? (
// Show thumbnail with play button overlay
<>
<img
src={posterUrl || '/placeholder.svg'}
alt={title}
className="w-full h-full object-cover"
/>
{/* Processing Overlay */}
{isInternalVideo && processingStatus !== 'completed' && processingStatus !== 'unknown' && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
{processingStatus === 'failed' ? (
<div className="text-red-500 flex flex-col items-center">
<span className="text-sm font-medium">Processing Failed</span>
</div>
) : (
<div className="flex flex-col items-center text-white space-y-2">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="text-xs font-medium">Processing {progress}%</span>
<Button
size="sm"
variant="destructive"
className="h-6 text-xs mt-2"
onClick={handleCancelProcessing}
>
<X className="w-3 h-3 mr-1" /> Cancel
</Button>
</div>
)}
</div>
)}
{/* Play Button Overlay */}
{showPlayButton && (!isInternalVideo || processingStatus === 'completed' || processingStatus === 'unknown') && (
<button
onClick={handlePlayClick}
className="absolute inset-0 flex items-center justify-center bg-black/30 hover:bg-black/40 transition-colors group"
aria-label="Play video"
>
<div className="w-16 h-16 rounded-full bg-white/90 flex items-center justify-center group-hover:bg-white group-hover:scale-110 transition-all">
<svg
className="w-8 h-8 text-black ml-1"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</button>
)}
</>
) : (
// Show MediaPlayer when playing
<MediaPlayer
key={videoId}
ref={player}
title={title}
src={
playbackUrl.includes('.m3u8')
? { src: playbackUrl, type: 'application/x-mpegurl' }
: (job?.resultUrl && job.status === 'completed')
? { src: job.resultUrl, type: 'application/x-mpegurl' }
: playbackUrl.includes('/api/videos/jobs')
? { src: playbackUrl, type: 'video/mp4' }
: playbackUrl
}
poster={posterUrl}
fullscreenOrientation="any"
controls
playsInline
className={`w-full ${variant === 'grid' ? "h-full" : ""}`}
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
)}
</div>
{/* Desktop Hover Overlay - hidden on mobile, and hidden in feed variant. Also hidden when playing to avoid blocking controls. */}
{showContent && variant === 'grid' && !isPlaying && (
<div className="hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
<div className="absolute bottom-0 left-0 right-0 p-4 pointer-events-auto">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author}
hoverStyle={true}
showDate={false}
/>
</div>
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="ghost"
onClick={handleLike}
className={`h-8 w-8 p-0 ${localIsLiked ? "text-red-500" : "text-white hover:text-red-500"
}`}
>
<Heart className="h-4 w-4" fill={localIsLiked ? "currentColor" : "none"} />
</Button>
<span className="text-white text-sm">{localLikes}</span>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 text-white hover:text-blue-400 ml-2"
>
<MessageCircle className="h-4 w-4" />
</Button>
<span className="text-white text-sm">{comments}</span>
{isOwner && (
<>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
setShowEditModal(true);
}}
className="h-8 w-8 p-0 text-white hover:text-green-400 ml-2"
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleDelete}
disabled={isDeleting}
className="h-8 w-8 p-0 text-white hover:text-red-400 ml-2"
>
<Trash2 className="h-4 w-4" />
</Button>
{versionCount > 1 && (
<div className="flex items-center ml-2 px-2 py-1 bg-white/20 rounded text-white text-xs">
<Layers className="h-3 w-3 mr-1" />
{versionCount}
</div>
)}
</>
)}
</div>
</div>
<h3 className="text-white font-medium mb-1">{title}</h3>
{description && (
<div className="text-white/80 text-sm mb-3 line-clamp-3 overflow-hidden">
<MarkdownRenderer content={description} className="prose-invert prose-white" />
</div>
)}
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="secondary"
className="h-6 px-2 text-xs bg-white/20 hover:bg-white/30 border-0 text-white"
onClick={(e) => {
e.stopPropagation();
handleDownload();
}}
>
<Download className="h-3 w-3 mr-1" />
<T>Download</T>
</Button>
<Button size="sm" variant="secondary" className="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 border-0 text-white">
<Share2 className="h-2.5 w-2.5" />
</Button>
</div>
</div>
</div>
)}
{/* Mobile/Feed Content - always visible below video */}
{showContent && (variant === 'feed' || (variant === 'grid' && true)) && (
<div className={`${variant === 'grid' ? "md:hidden" : ""} pb-2 space-y-2`}>
{/* Row 1: Avatar + Actions */}
<div className="flex items-center justify-between px-2 pt-2">
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author === 'User' ? undefined : author}
className="w-8 h-8"
showDate={false}
/>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
onClick={handleLike}
className={localIsLiked ? "text-red-500 hover:text-red-600" : ""}
>
<Heart className="h-6 w-6" fill={localIsLiked ? "currentColor" : "none"} />
</Button>
{localLikes > 0 && (
<span className="text-sm font-medium text-foreground mr-1">{localLikes}</span>
)}
<Button
size="icon"
variant="ghost"
className="text-foreground"
>
<MessageCircle className="h-6 w-6 -rotate-90" />
</Button>
{comments > 0 && (
<span className="text-sm font-medium text-foreground mr-1">{comments}</span>
)}
<Button
size="icon"
variant="ghost"
className="text-foreground"
onClick={(e) => {
e.stopPropagation();
handleDownload();
}}
>
<Download className="h-6 w-6" />
</Button>
{isOwner && (
<>
<Button
size="icon"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
setShowEditModal(true);
}}
className="text-foreground hover:text-green-400"
>
<Edit3 className="h-6 w-6" />
</Button>
</>
)}
</div>
</div>
{/* Likes */}
{/* Caption / Description section */}
<div className="px-4 space-y-1">
{(!isLikelyFilename(title) && title) && (
<div className="font-semibold text-sm">{title}</div>
)}
{description && (
<div className="text-sm text-foreground/90 line-clamp-3 pl-8">
<MarkdownRenderer content={description} className="prose-sm dark:prose-invert" />
</div>
)}
{created_at && (
<div className="text-xs text-muted-foreground pt-1">
{formatDate(created_at)}
</div>
)}
</div>
</div>
)}
{showEditModal && (
<EditVideoModal
open={showEditModal}
onOpenChange={setShowEditModal}
videoId={videoId}
currentTitle={title}
currentDescription={description || null}
currentVisible={true} // Default to true until we can properly pass this
onUpdateSuccess={() => {
setShowEditModal(false);
onDelete?.(); // Trigger refresh
}}
/>
)}
</div>
);
};
export default VideoCard;

View File

@ -0,0 +1,359 @@
import React, { useState, useRef, useEffect } from 'react';
import { Mic, MicOff, Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { transcribeAudio } from '@/lib/openai';
import { T, translate } from '@/i18n';
interface VoiceRecordingPopupProps {
isOpen: boolean;
onClose: () => void;
onTranscriptionComplete: (transcribedText: string) => void;
onGenerateImage?: (prompt: string) => Promise<void>;
apiKey?: string;
showToolCalls?: boolean; // Show tool call progress
onToolCallUpdate?: (toolCall: string) => void; // Tool call callback
}
export interface VoiceRecordingPopupHandle {
addToolCall: (toolName: string) => void;
setGenerating: (generating: boolean) => void;
}
const VoiceRecordingPopup = React.forwardRef<VoiceRecordingPopupHandle, VoiceRecordingPopupProps>(({
isOpen,
onClose,
onTranscriptionComplete,
onGenerateImage,
apiKey,
showToolCalls = false,
onToolCallUpdate,
}, ref) => {
const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [transcribedText, setTranscribedText] = useState('');
const [toolCalls, setToolCalls] = useState<string[]>([]);
const [currentTool, setCurrentTool] = useState<string>('');
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const recordingTimerRef = useRef<NodeJS.Timeout | null>(null);
const streamRef = useRef<MediaStream | null>(null);
// Expose methods via ref
React.useImperativeHandle(ref, () => ({
addToolCall: (toolName: string) => {
const toolMessage = `🔧 ${toolName}`;
setToolCalls(prev => [...prev, toolMessage]);
setCurrentTool(toolName);
onToolCallUpdate?.(toolName);
},
setGenerating: (generating: boolean) => {
setIsGenerating(generating);
}
}));
// Auto-start recording when dialog opens
useEffect(() => {
if (isOpen && !isRecording && !isTranscribing && !transcribedText) {
startRecording();
}
}, [isOpen]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
};
}, []);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const options = { mimeType: 'audio/webm' };
let mediaRecorder: MediaRecorder;
try {
mediaRecorder = new MediaRecorder(stream, options);
} catch (e) {
mediaRecorder = new MediaRecorder(stream);
}
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
await handleRecordingStop();
};
mediaRecorder.start();
setIsRecording(true);
setRecordingTime(0);
// Start timer
recordingTimerRef.current = setInterval(() => {
setRecordingTime((prev) => prev + 1);
}, 1000);
toast.info(translate('Recording started...'));
} catch (error: any) {
console.error('Error accessing microphone:', error);
toast.error(translate('Failed to access microphone. Please check permissions.'));
onClose();
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
}
};
const handleRecordingStop = async () => {
setIsTranscribing(true);
try {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
if (audioBlob.size === 0) {
throw new Error('No audio recorded');
}
// Create File object for transcription
const audioFile = new File([audioBlob], 'recording.webm', {
type: 'audio/webm',
});
// Transcribe audio
const transcribedText = await transcribeAudio(audioFile, 'whisper-1', apiKey);
if (!transcribedText) {
throw new Error('Transcription failed');
}
setTranscribedText(transcribedText);
onTranscriptionComplete(transcribedText);
toast.success(translate('Transcription complete!'));
// Auto-generate image if handler provided
if (onGenerateImage) {
try {
await onGenerateImage(transcribedText);
// Close popup after generation completes (if not showing tool calls)
if (!showToolCalls) {
setTimeout(() => {
handleClose();
}, 1000);
}
} catch (error: any) {
console.error('Error generating image:', error);
toast.error('Failed to generate image');
setIsGenerating(false);
}
}
} catch (error: any) {
console.error('Error transcribing audio:', error);
toast.error(translate('Failed to transcribe audio: ') + error.message);
} finally {
setIsTranscribing(false);
}
};
const handleClose = () => {
if (isRecording) {
stopRecording();
}
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
setRecordingTime(0);
setTranscribedText('');
setToolCalls([]);
setCurrentTool('');
setIsGenerating(false);
onClose();
};
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
<T>Voice Recording</T>
</DialogTitle>
<DialogDescription>
{isRecording && <T>Recording your voice... Click stop when done.</T>}
{isTranscribing && <T>Transcribing audio...</T>}
{isGenerating && showToolCalls && <T>Agent working...</T>}
{transcribedText && !isGenerating && <T>Transcription complete!</T>}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center justify-center py-6 space-y-4">
{/* Recording Indicator */}
<div className="relative">
<div
className={`w-32 h-32 rounded-full flex items-center justify-center transition-all ${
isRecording
? 'bg-red-500 animate-pulse'
: isTranscribing
? 'bg-blue-500'
: 'bg-gray-300'
}`}
>
{isTranscribing ? (
<Loader2 className="w-16 h-16 text-white animate-spin" />
) : isRecording ? (
<Mic className="w-16 h-16 text-white" />
) : (
<MicOff className="w-16 h-16 text-white" />
)}
</div>
{/* Recording pulse effect */}
{isRecording && (
<>
<div className="absolute inset-0 rounded-full bg-red-500 animate-ping opacity-75"></div>
<div className="absolute inset-0 rounded-full bg-red-500 opacity-25"></div>
</>
)}
</div>
{/* Timer */}
{isRecording && (
<div className="text-3xl font-mono font-bold text-red-600">
{formatTime(recordingTime)}
</div>
)}
{/* Status Text */}
<div className="text-center space-y-2 min-h-[100px]">
{isRecording && (
<p className="text-sm font-medium">
<T>Recording in progress...</T>
</p>
)}
{isTranscribing && (
<p className="text-sm font-medium">
<T>Transcribing audio...</T>
</p>
)}
{transcribedText && !isGenerating && (
<div className="max-w-md">
<p className="text-sm font-medium mb-2">
<T>Transcribed text:</T>
</p>
<p className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
{transcribedText}
</p>
</div>
)}
{/* Tool Calls Progress */}
{isGenerating && showToolCalls && (
<div className="max-w-md space-y-2">
<p className="text-sm font-medium mb-2">
<T>Agent Progress:</T>
</p>
<div className="text-left bg-muted p-3 rounded-md space-y-1 max-h-48 overflow-y-auto">
{toolCalls.map((call, index) => (
<div
key={index}
className={`text-xs ${index === toolCalls.length - 1 ? 'font-semibold text-primary' : 'text-muted-foreground'}`}
>
{call}
</div>
))}
{currentTool && (
<div className="text-xs text-primary animate-pulse">
Working on: {currentTool}
</div>
)}
</div>
</div>
)}
</div>
{/* Control Buttons */}
<div className="flex gap-3">
{isRecording && (
<Button
onClick={stopRecording}
size="lg"
variant="destructive"
className="gap-2"
>
<MicOff className="w-5 h-5" />
<T>Stop Recording</T>
</Button>
)}
{!isRecording && !isTranscribing && !transcribedText && (
<Button onClick={startRecording} size="lg" className="gap-2">
<Mic className="w-5 h-5" />
<T>Start Recording</T>
</Button>
)}
<Button
onClick={handleClose}
size="lg"
variant="outline"
className="gap-2"
disabled={isGenerating}
>
<X className="w-5 h-5" />
<T>{isGenerating ? 'Close When Done' : 'Cancel'}</T>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
});
VoiceRecordingPopup.displayName = 'VoiceRecordingPopup';
export default VoiceRecordingPopup;

View File

@ -0,0 +1,569 @@
import React, { useState } from 'react';
import { Plus, Edit, Trash2, Play, Save, X, GripVertical, Wand2, Sparkles, Upload, FileText, Brain, Image as ImageIcon, ArrowUp, ArrowDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import { T } from '@/i18n';
export type WorkflowActionType =
| 'optimize_prompt'
| 'generate_image'
| 'generate_metadata'
| 'publish_image'
| 'quick_publish'
| 'download_image'
| 'enhance_image'
| 'apply_style';
export interface WorkflowAction {
id: string;
type: WorkflowActionType;
label: string;
icon?: React.ReactNode; // Optional since it won't be stored in DB
description: string;
}
export interface Workflow {
id: string;
name: string;
actions: WorkflowAction[];
createdAt: string;
}
interface WorkflowManagerProps {
workflows: Workflow[];
onSaveWorkflow: (workflow: Omit<Workflow, 'id' | 'createdAt'>) => Promise<void>;
onUpdateWorkflow: (id: string, workflow: Omit<Workflow, 'id' | 'createdAt'>) => Promise<void>;
onDeleteWorkflow: (id: string) => Promise<void>;
onExecuteWorkflow: (workflow: Workflow) => Promise<void>;
loading?: boolean;
}
// Icon mapping function to avoid serialization issues
const getActionIcon = (type: WorkflowActionType) => {
const iconMap: Record<WorkflowActionType, React.ReactNode> = {
'optimize_prompt': <Sparkles className="w-4 h-4" />,
'generate_image': <Wand2 className="w-4 h-4" />,
'generate_metadata': <FileText className="w-4 h-4" />,
'publish_image': <Upload className="w-4 h-4" />,
'quick_publish': <Upload className="w-4 h-4" />,
'download_image': <ArrowDown className="w-4 h-4" />,
'enhance_image': <Sparkles className="w-4 h-4" />,
'apply_style': <Wand2 className="w-4 h-4" />,
};
return iconMap[type] || <ImageIcon className="w-4 h-4" />;
};
// Available action palette
const AVAILABLE_ACTIONS: WorkflowAction[] = [
{
id: 'optimize_prompt',
type: 'optimize_prompt',
label: 'Optimize Prompt',
icon: <Sparkles className="w-4 h-4" />,
description: 'Enhance prompt with AI suggestions',
},
{
id: 'generate_image',
type: 'generate_image',
label: 'Generate Image',
icon: <Wand2 className="w-4 h-4" />,
description: 'Create image from prompt',
},
{
id: 'generate_metadata',
type: 'generate_metadata',
label: 'Generate Metadata',
icon: <FileText className="w-4 h-4" />,
description: 'Create title and description',
},
{
id: 'publish_image',
type: 'publish_image',
label: 'Publish Image',
icon: <Upload className="w-4 h-4" />,
description: 'Save to gallery with metadata',
},
{
id: 'quick_publish',
type: 'quick_publish',
label: 'Quick Publish',
icon: <Upload className="w-4 h-4" />,
description: 'Fast publish with prompt as description',
},
{
id: 'download_image',
type: 'download_image',
label: 'Download Image',
icon: <ImageIcon className="w-4 h-4" />,
description: 'Download image to device',
},
{
id: 'enhance_image',
type: 'enhance_image',
label: 'Enhance Image',
icon: <Sparkles className="w-4 h-4" />,
description: 'Apply AI enhancement',
},
{
id: 'apply_style',
type: 'apply_style',
label: 'Apply Style',
icon: <Brain className="w-4 h-4" />,
description: 'Apply artistic style transformation',
},
];
const WorkflowManager: React.FC<WorkflowManagerProps> = ({
workflows,
onSaveWorkflow,
onUpdateWorkflow,
onDeleteWorkflow,
onExecuteWorkflow,
loading = false,
}) => {
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [deleteWorkflowId, setDeleteWorkflowId] = useState<string | null>(null);
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
const [workflowName, setWorkflowName] = useState('');
const [selectedActions, setSelectedActions] = useState<WorkflowAction[]>([]);
const handleCreateWorkflow = async () => {
if (!workflowName.trim()) {
toast.error('Please enter a workflow name');
return;
}
if (selectedActions.length === 0) {
toast.error('Please add at least one action to the workflow');
return;
}
try {
// Strip icon field to avoid serialization issues
const actionsForDb = selectedActions.map(({ icon, ...action }) => action);
await onSaveWorkflow({
name: workflowName.trim(),
actions: actionsForDb as WorkflowAction[],
});
toast.success('Workflow created successfully');
setIsCreateDialogOpen(false);
setWorkflowName('');
setSelectedActions([]);
} catch (error: any) {
toast.error(error.message || 'Failed to create workflow');
}
};
const handleUpdateWorkflow = async () => {
if (!editingWorkflow) return;
if (!workflowName.trim()) {
toast.error('Please enter a workflow name');
return;
}
if (selectedActions.length === 0) {
toast.error('Please add at least one action to the workflow');
return;
}
try {
// Strip icon field to avoid serialization issues
const actionsForDb = selectedActions.map(({ icon, ...action }) => action);
await onUpdateWorkflow(editingWorkflow.id, {
name: workflowName.trim(),
actions: actionsForDb as WorkflowAction[],
});
toast.success('Workflow updated successfully');
setIsEditDialogOpen(false);
setEditingWorkflow(null);
setWorkflowName('');
setSelectedActions([]);
} catch (error: any) {
toast.error(error.message || 'Failed to update workflow');
}
};
const handleDeleteWorkflow = async () => {
if (!deleteWorkflowId) return;
try {
await onDeleteWorkflow(deleteWorkflowId);
toast.success('Workflow deleted successfully');
setDeleteWorkflowId(null);
} catch (error: any) {
toast.error(error.message || 'Failed to delete workflow');
}
};
const openEditDialog = (workflow: Workflow) => {
setEditingWorkflow(workflow);
setWorkflowName(workflow.name);
setSelectedActions([...workflow.actions]);
setIsEditDialogOpen(true);
};
const addAction = (action: WorkflowAction) => {
// Create a unique instance for the workflow (without icon to avoid serialization issues)
const newAction = {
id: `${action.type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: action.type,
label: action.label,
description: action.description,
icon: action.icon, // Keep icon for UI but it won't be saved to DB
};
setSelectedActions([...selectedActions, newAction]);
};
const removeAction = (actionId: string) => {
setSelectedActions(selectedActions.filter(a => a.id !== actionId));
};
const moveAction = (index: number, direction: 'up' | 'down') => {
if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === selectedActions.length - 1) return;
const newActions = [...selectedActions];
const targetIndex = direction === 'up' ? index - 1 : index + 1;
[newActions[index], newActions[targetIndex]] = [newActions[targetIndex], newActions[index]];
setSelectedActions(newActions);
};
const handleExecuteWorkflow = async (workflow: Workflow) => {
try {
await onExecuteWorkflow(workflow);
} catch (error: any) {
toast.error(error.message || 'Failed to execute workflow');
}
};
return (
<div className="border border-purple-200/50 dark:border-purple-500/30 rounded-xl p-4 bg-gradient-to-br from-purple-50/30 to-pink-50/30 dark:from-purple-900/20 dark:to-pink-900/20">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Brain className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<h4 className="text-sm font-semibold text-slate-700 dark:text-white">
<T>Workflows</T>
</h4>
<Badge variant="outline" className="text-xs">
{workflows.length}
</Badge>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setSelectedActions([]);
setWorkflowName('');
setIsCreateDialogOpen(true);
}}
disabled={loading}
className="h-7 px-2 text-xs"
title="Create new workflow"
>
<Plus className="w-3 h-3 mr-1" />
<T>New Workflow</T>
</Button>
</div>
<div className="space-y-2">
{workflows.length === 0 ? (
<span className="text-xs text-slate-500 dark:text-slate-400">
<T>No workflows saved yet. Create a workflow to automate your image generation process!</T>
</span>
) : (
workflows.map((workflow) => (
<div
key={workflow.id}
className="group relative p-3 rounded-lg border border-purple-200 dark:border-purple-700 bg-white/50 dark:bg-slate-800/50 hover:shadow-md transition-all"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h5 className="text-sm font-semibold text-slate-700 dark:text-white mb-1">
{workflow.name}
</h5>
<div className="flex flex-wrap gap-1">
{workflow.actions.map((action, idx) => (
<Badge
key={action.id}
variant="secondary"
className="text-xs flex items-center gap-1"
>
<span className="text-purple-600 dark:text-purple-400">{idx + 1}.</span>
{getActionIcon(action.type)}
{action.label}
</Badge>
))}
</div>
</div>
<div className="flex gap-1 ml-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => handleExecuteWorkflow(workflow)}
disabled={loading}
className="h-7 w-7 p-0"
title="Execute workflow"
>
<Play className="w-3 h-3 text-green-600" />
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => openEditDialog(workflow)}
disabled={loading}
className="h-7 w-7 p-0"
title="Edit workflow"
>
<Edit className="w-3 h-3 text-blue-600" />
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setDeleteWorkflowId(workflow.id)}
disabled={loading}
className="h-7 w-7 p-0"
title="Delete workflow"
>
<Trash2 className="w-3 h-3 text-red-600" />
</Button>
</div>
</div>
</div>
))
)}
</div>
{/* Create/Edit Workflow Dialog */}
<Dialog open={isCreateDialogOpen || isEditDialogOpen} onOpenChange={(open) => {
if (!open) {
setIsCreateDialogOpen(false);
setIsEditDialogOpen(false);
setEditingWorkflow(null);
setWorkflowName('');
setSelectedActions([]);
}
}}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEditDialogOpen ? <T>Edit Workflow</T> : <T>Create New Workflow</T>}
</DialogTitle>
<DialogDescription>
<T>Build a custom workflow by selecting and ordering actions from the palette below.</T>
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Workflow Name */}
<div>
<label className="text-sm font-medium mb-2 block">
<T>Workflow Name</T>
</label>
<Input
placeholder="e.g., Quick Publish Workflow, Full Generation Pipeline"
value={workflowName}
onChange={(e) => setWorkflowName(e.target.value)}
maxLength={50}
/>
</div>
{/* Action Palette */}
<div>
<label className="text-sm font-medium mb-2 block">
<T>Available Actions</T>
</label>
<div className="grid grid-cols-2 gap-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
{AVAILABLE_ACTIONS.map((action) => (
<button
key={action.id}
type="button"
onClick={() => addAction(action)}
className="flex items-center gap-2 p-2 rounded-md bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 hover:border-purple-300 dark:hover:border-purple-600 transition-all text-left group"
title={action.description}
>
<div className="text-purple-600 dark:text-purple-400">
{action.icon}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-slate-700 dark:text-slate-200 truncate">
{action.label}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 truncate">
{action.description}
</div>
</div>
<Plus className="w-4 h-4 text-slate-400 group-hover:text-purple-600" />
</button>
))}
</div>
</div>
{/* Selected Actions (Workflow Steps) */}
<div>
<label className="text-sm font-medium mb-2 block">
<T>Workflow Steps</T> ({selectedActions.length})
</label>
{selectedActions.length === 0 ? (
<div className="p-6 text-center text-sm text-slate-500 dark:text-slate-400 border-2 border-dashed border-slate-200 dark:border-slate-700 rounded-lg">
<T>No actions added yet. Click on actions above to build your workflow.</T>
</div>
) : (
<div className="space-y-2 p-3 bg-purple-50/50 dark:bg-purple-900/10 rounded-lg border border-purple-200 dark:border-purple-700">
{selectedActions.map((action, index) => (
<div
key={action.id}
className="flex items-center gap-2 p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 shadow-sm"
>
<div className="flex items-center gap-2 flex-1">
<div className="flex flex-col gap-1">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => moveAction(index, 'up')}
disabled={index === 0}
className="h-5 w-5 p-0"
title="Move up"
>
<ArrowUp className="w-3 h-3" />
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => moveAction(index, 'down')}
disabled={index === selectedActions.length - 1}
className="h-5 w-5 p-0"
title="Move down"
>
<ArrowDown className="w-3 h-3" />
</Button>
</div>
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300 text-sm font-semibold">
{index + 1}
</div>
<div className="text-purple-600 dark:text-purple-400">
{getActionIcon(action.type)}
</div>
<div className="flex-1">
<div className="text-sm font-medium text-slate-700 dark:text-slate-200">
{action.label}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{action.description}
</div>
</div>
</div>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => removeAction(action.id)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
title="Remove action"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
if (isEditDialogOpen) {
setIsEditDialogOpen(false);
setEditingWorkflow(null);
} else {
setIsCreateDialogOpen(false);
}
setWorkflowName('');
setSelectedActions([]);
}}
>
<T>Cancel</T>
</Button>
<Button
type="button"
onClick={isEditDialogOpen ? handleUpdateWorkflow : handleCreateWorkflow}
disabled={!workflowName.trim() || selectedActions.length === 0}
>
<Save className="w-4 h-4 mr-2" />
{isEditDialogOpen ? <T>Update Workflow</T> : <T>Create Workflow</T>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteWorkflowId !== null} onOpenChange={(open) => !open && setDeleteWorkflowId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<T>Delete Workflow?</T>
</AlertDialogTitle>
<AlertDialogDescription>
<T>Are you sure you want to delete this workflow? This action cannot be undone.</T>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteWorkflowId(null)}>
<T>Cancel</T>
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteWorkflow}
className="bg-red-600 hover:bg-red-700"
>
<T>Delete</T>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default WorkflowManager;

View File

@ -0,0 +1,56 @@
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar
} from "@/components/ui/sidebar";
import { T, translate } from "@/i18n";
import { LayoutDashboard, Users } from "lucide-react";
export type AdminActiveSection = 'dashboard' | 'users';
export const AdminSidebar = ({
activeSection,
onSectionChange
}: {
activeSection: AdminActiveSection;
onSectionChange: (section: AdminActiveSection) => void;
}) => {
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
const menuItems = [
{ id: 'dashboard' as AdminActiveSection, label: translate('Dashboard'), icon: LayoutDashboard },
{ id: 'users' as AdminActiveSection, label: translate('Users'), icon: Users },
];
return (
<Sidebar collapsible="icon">
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel><T>Admin</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>
</SidebarContent>
</Sidebar>
);
};

View File

@ -0,0 +1,103 @@
import { useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { T, translate } from '@/i18n';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Save } from 'lucide-react';
interface CreateUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onUserCreated: () => void;
}
export const CreateUserDialog = ({ open, onOpenChange, onUserCreated }: CreateUserDialogProps) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [copySettings, setCopySettings] = useState(true);
const [creating, setCreating] = useState(false);
const handleCreate = async () => {
if (!email || !password) {
toast.error(translate('Email and password are required.'));
return;
}
setCreating(true);
try {
const { error } = await supabase.functions.invoke('create-user', {
body: { email, password, copySettings },
});
if (error) throw error;
toast.success(translate('User created successfully'));
onUserCreated();
onOpenChange(false);
// Reset form
setEmail('');
setPassword('');
setCopySettings(true);
} catch (error) {
console.error('Error creating user:', error);
toast.error(error.message || translate('Failed to create user'));
} finally {
setCreating(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New User</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter a strong password"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="copy-settings"
checked={copySettings}
onCheckedChange={(checked) => setCopySettings(!!checked)}
/>
<label
htmlFor="copy-settings"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Copy my settings and API keys to the new user
</label>
</div>
</div>
<DialogFooter>
<Button onClick={handleCreate} disabled={creating}>
<Save className="mr-2 h-4 w-4" />
{creating ? 'Creating...' : 'Create User'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,43 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { T } from "@/i18n";
interface DeleteUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
username?: string;
}
export const DeleteUserDialog = ({ open, onOpenChange, onConfirm, username }: DeleteUserDialogProps) => {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<T>Are you sure you want to delete this user?</T>
</AlertDialogTitle>
<AlertDialogDescription>
<T>This action cannot be undone. This will permanently delete the user</T>
{username && <span className="font-bold"> {username}</span>}
<T> and all associated data.</T>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel><T>Cancel</T></AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} className="bg-destructive hover:bg-destructive/90">
<T>Delete</T>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@ -0,0 +1,143 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { T, translate } from '@/i18n';
import { Tables } from '@/integrations/supabase/types';
import { getUserSecrets, updateUserSecrets } from '@/components/ImageWizard/db';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Save } from 'lucide-react';
type Profile = Tables<'profiles'>;
interface EditUserDialogProps {
user: Profile | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onUserUpdate: (updatedUser: Profile) => void;
}
export const EditUserDialog = ({ user, open, onOpenChange, onUserUpdate }: EditUserDialogProps) => {
const [profile, setProfile] = useState<Profile | null>(null);
const [secrets, setSecrets] = useState<Record<string, string>>({});
const [updating, setUpdating] = useState(false);
useEffect(() => {
if (user) {
setProfile(user);
// Fetch secrets
getUserSecrets(user.user_id).then(data => {
if (data) setSecrets(data);
else setSecrets({});
});
}
}, [user]);
const handleUpdate = async () => {
if (!profile) return;
setUpdating(true);
try {
// 1. Update profile (non-secret fields)
const { data, error } = await supabase
.from('profiles')
.update({
username: profile.username,
display_name: profile.display_name,
bio: profile.bio,
})
.eq('id', profile.id)
.select()
.single();
if (error) throw error;
// 2. Update secrets
if (profile.user_id) {
await updateUserSecrets(profile.user_id, secrets);
}
toast.success(translate('User profile updated successfully'));
onUserUpdate(data);
onOpenChange(false);
} catch (error) {
console.error('Error updating user profile:', error);
toast.error(translate('Failed to update user profile'));
} finally {
setUpdating(false);
}
};
if (!profile) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit User: {profile.display_name || profile.username}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="general">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="api-keys">API Keys</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={profile.username || ''}
onChange={(e) => setProfile({ ...profile, username: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="display_name">Display Name</Label>
<Input
id="display_name"
value={profile.display_name || ''}
onChange={(e) => setProfile({ ...profile, display_name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Textarea
id="bio"
value={profile.bio || ''}
onChange={(e) => setProfile({ ...profile, bio: e.target.value })}
/>
</div>
</TabsContent>
<TabsContent value="api-keys" className="space-y-4 py-4">
<ApiKeyInput id="google_api_key" label="Google API Key" secrets={secrets} setSecrets={setSecrets} />
<ApiKeyInput id="openai_api_key" label="OpenAI API Key" secrets={secrets} setSecrets={setSecrets} />
<ApiKeyInput id="replicate_api_key" label="Replicate API Key" secrets={secrets} setSecrets={setSecrets} />
<ApiKeyInput id="bria_api_key" label="Bria API Key" secrets={secrets} setSecrets={setSecrets} />
<ApiKeyInput id="huggingface_api_key" label="HuggingFace API Key" secrets={secrets} setSecrets={setSecrets} />
<ApiKeyInput id="aimlapi_api_key" label="AIMLAPI API Key" secrets={secrets} setSecrets={setSecrets} />
</TabsContent>
</Tabs>
<Button onClick={handleUpdate} disabled={updating}>
<Save className="mr-2 h-4 w-4" />
{updating ? 'Saving...' : 'Save Changes'}
</Button>
</DialogContent>
</Dialog>
);
};
const ApiKeyInput = ({ id, label, secrets, setSecrets }: { id: string; label: string; secrets: Record<string, string>; setSecrets: (s: Record<string, string>) => void; }) => (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
type="password"
value={secrets[id] || ''}
onChange={(e) => setSecrets({ ...secrets, [id]: e.target.value })}
placeholder={`Enter ${label}`}
/>
</div>
);

View File

@ -0,0 +1,162 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { T, translate } from '@/i18n';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { User, MoreHorizontal, Plus } from 'lucide-react';
import { Tables } from '@/integrations/supabase/types';
import { Button } from '../ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui/dropdown-menu';
import { EditUserDialog } from './EditUserDialog';
import { DeleteUserDialog } from './DeleteUserDialog';
import { CreateUserDialog } from './CreateUserDialog';
type AdminUserView = Tables<'profiles'> & {
email?: string;
};
const UserManager = () => {
const [users, setUsers] = useState<AdminUserView[]>([]);
const [loading, setLoading] = useState(true);
const [editingUser, setEditingUser] = useState<AdminUserView | null>(null);
const [deletingUser, setDeletingUser] = useState<AdminUserView | null>(null);
const [isCreateUserOpen, setCreateUserOpen] = useState(false);
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
setLoading(true);
const { data, error } = await supabase.functions.invoke('get-users');
if (error) throw error;
setUsers(data || []);
} catch (error) {
console.error('Error fetching users:', error);
toast.error(translate('Failed to load users'));
} finally {
setLoading(false);
}
};
const handleUserUpdate = (updatedUser: AdminUserView) => {
setUsers(users.map(u => u.id === updatedUser.id ? updatedUser : u));
};
const handleDeleteUser = async () => {
if (!deletingUser) return;
try {
const { error } = await supabase.functions.invoke('delete-user', {
body: { userIdToDelete: deletingUser.user_id },
});
if (error) throw error;
setUsers(users.filter(u => u.id !== deletingUser.id));
toast.success(translate('User deleted successfully'));
} catch (error) {
console.error('Error deleting user:', error);
toast.error(translate('Failed to delete user'));
} finally {
setDeletingUser(null);
}
};
if (loading) {
return <div><T>Loading users...</T></div>;
}
return (
<div>
<div className="flex justify-end mb-4">
<Button onClick={() => setCreateUserOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create User
</Button>
</div>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Username</TableHead>
<TableHead>Joined Date</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map(user => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={user.avatar_url ?? undefined} alt={user.display_name ?? ''} />
<AvatarFallback><User className="h-4 w-4" /></AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{user.display_name || 'N/A'}</div>
<div className="text-sm text-muted-foreground">{user.email}</div>
</div>
</div>
</TableCell>
<TableCell>
{user.username ? (
<Badge variant="secondary">@{user.username}</Badge>
) : (
<span className="text-muted-foreground">N/A</span>
)}
</TableCell>
<TableCell>
{new Date(user.created_at).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setEditingUser(user)}>Edit</DropdownMenuItem>
<DropdownMenuItem
className="text-red-500"
onClick={() => setDeletingUser(user)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<EditUserDialog
user={editingUser}
open={!!editingUser}
onOpenChange={() => setEditingUser(null)}
onUserUpdate={handleUserUpdate}
/>
<DeleteUserDialog
open={!!deletingUser}
onOpenChange={() => setDeletingUser(null)}
onConfirm={handleDeleteUser}
username={deletingUser?.display_name || deletingUser?.username || undefined}
/>
<CreateUserDialog
open={isCreateUserOpen}
onOpenChange={setCreateUserOpen}
onUserCreated={fetchUsers}
/>
</div>
</div>
);
};
export default UserManager;

View File

@ -0,0 +1,171 @@
import React, { useState, useEffect } from 'react';
import { T } from '@/i18n';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { LayoutContainer } from '@/lib/unifiedLayoutManager';
interface ContainerSettingsManagerProps {
isOpen: boolean;
onClose: () => void;
onSave: (settings: Partial<LayoutContainer['settings']>) => void;
currentSettings: LayoutContainer['settings'];
containerInfo: {
id: string;
columns: number;
};
}
export const ContainerSettingsManager: React.FC<ContainerSettingsManagerProps> = ({
isOpen,
onClose,
onSave,
currentSettings,
containerInfo,
}) => {
const [settings, setSettings] = useState<LayoutContainer['settings']>(() => ({
collapsible: currentSettings?.collapsible || false,
collapsed: currentSettings?.collapsed || false,
title: currentSettings?.title || '',
showTitle: currentSettings?.showTitle || false,
}));
// Reset settings when modal opens
useEffect(() => {
if (isOpen) {
const newSettings = {
collapsible: currentSettings?.collapsible || false,
collapsed: currentSettings?.collapsed || false,
title: currentSettings?.title || '',
showTitle: currentSettings?.showTitle || false,
};
setSettings(newSettings);
}
}, [isOpen, currentSettings]);
const handleSave = () => {
onSave(settings);
onClose();
};
const handleCancel = () => {
setSettings({
collapsible: currentSettings?.collapsible || false,
collapsed: currentSettings?.collapsed || false,
title: currentSettings?.title || '',
showTitle: currentSettings?.showTitle || false,
});
onClose();
};
const updateSetting = <K extends keyof LayoutContainer['settings']>(
key: K,
value: LayoutContainer['settings'][K]
) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md max-w-[90vw]">
<DialogHeader>
<DialogTitle>
Container Settings
</DialogTitle>
<DialogDescription>
Configure display and behavior settings for container {containerInfo.id.split('-').pop()} ({containerInfo.columns} columns)
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Title Settings */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="show-title" className="text-sm font-medium">
Show Title
</Label>
<Switch
id="show-title"
checked={settings.showTitle}
onCheckedChange={(checked) => updateSetting('showTitle', checked)}
/>
</div>
{settings.showTitle && (
<div className="space-y-2">
<Label htmlFor="container-title">
Title
</Label>
<Input
id="container-title"
type="text"
value={settings.title}
onChange={(e) => updateSetting('title', e.target.value)}
placeholder={`Container (${containerInfo.columns} col${containerInfo.columns !== 1 ? 's' : ''})`}
className="w-full"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
Leave empty to use default title
</p>
</div>
)}
</div>
{/* Collapsible Settings */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="collapsible" className="text-sm font-medium">
Collapsible
</Label>
<p className="text-xs text-slate-500 dark:text-slate-400">
Allow users to collapse/expand this container
</p>
</div>
<Switch
id="collapsible"
checked={settings.collapsible}
onCheckedChange={(checked) => {
updateSetting('collapsible', checked);
// If disabling collapsible, also set collapsed to false
if (!checked) {
updateSetting('collapsed', false);
}
}}
/>
</div>
{settings.collapsible && (
<div className="flex items-center justify-between pl-4 border-l-2 border-slate-200 dark:border-slate-700">
<div>
<Label htmlFor="initially-collapsed" className="text-sm font-medium">
Initially Collapsed
</Label>
<p className="text-xs text-slate-500 dark:text-slate-400">
Start with container collapsed
</p>
</div>
<Switch
id="initially-collapsed"
checked={settings.collapsed}
onCheckedChange={(checked) => updateSetting('collapsed', checked)}
/>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSave}>
Save Settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { FeedPost } from '@/lib/db';
import { FeedCarousel } from './FeedCarousel';
import { Heart } from 'lucide-react';
import { cn } from '@/lib/utils';
import * as db from '@/lib/db';
import { useNavigate } from "react-router-dom";
import { normalizeMediaType } from "@/lib/mediaRegistry";
interface FeedCardProps {
post: FeedPost;
currentUserId?: string;
onLike?: () => void;
onComment?: () => void;
onShare?: () => void;
onNavigate?: (id: string) => void;
}
export const FeedCard: React.FC<FeedCardProps> = ({
post,
currentUserId,
onLike,
onComment,
onNavigate
}) => {
const navigate = useNavigate();
const [isLiked, setIsLiked] = useState<boolean>(false); // Need to hydrate this from props safely in real app
const [likeCount, setLikeCount] = useState(post.likes_count || 0);
const [lastTap, setLastTap] = useState<number>(0);
const [showHeartAnimation, setShowHeartAnimation] = useState(false);
// Initial check for like status (you might want to pass this in from parent if checking many)
React.useEffect(() => {
if (currentUserId && post.cover?.id) {
db.checkLikeStatus(currentUserId, post.cover.id).then(setIsLiked);
}
}, [currentUserId, post.cover?.id]);
const handleLike = async () => {
if (!currentUserId || !post.cover?.id) return;
// Optimistic update
const newStatus = !isLiked;
setIsLiked(newStatus);
setLikeCount(prev => newStatus ? prev + 1 : prev - 1);
try {
await db.toggleLike(currentUserId, post.cover.id, isLiked);
onLike?.();
} catch (e) {
// Revert
setIsLiked(!newStatus);
setLikeCount(prev => !newStatus ? prev + 1 : prev - 1);
console.error(e);
}
};
const handleDoubleTap = (e: React.SyntheticEvent) => {
const now = Date.now();
const DOUBLE_TAP_DELAY = 300;
if (now - lastTap < DOUBLE_TAP_DELAY) {
if (!isLiked) {
handleLike();
}
setShowHeartAnimation(true);
setTimeout(() => setShowHeartAnimation(false), 1000);
}
setLastTap(now);
};
// Prepare items for carousel
const carouselItems = (post.pictures && post.pictures.length > 0
? post.pictures
: [post.cover]).filter(item => !!item);
const handleItemClick = (itemId: string) => {
const item = carouselItems.find(i => i.id === itemId);
if (item) {
const type = normalizeMediaType(item.type);
if (type === 'page-intern' && item.meta?.slug) {
navigate(`/user/${item.user_id || post.user_id}/pages/${item.meta.slug}`);
return;
}
}
onNavigate?.(post.id);
};
if (carouselItems.length === 0) return null;
return (
<article className="bg-background border-b border-border pb-4 md:border md:rounded-lg md:mb-6 md:pb-0 overflow-hidden">
{/* Media Carousel */}
<div className="relative" onTouchEnd={handleDoubleTap} onClick={handleDoubleTap}>
<FeedCarousel
items={carouselItems}
aspectRatio={1}
className="w-full bg-muted"
author={post.author_profile?.display_name || post.author_profile?.username || 'User'}
authorId={post.user_id}
authorAvatarUrl={post.author_profile?.avatar_url}
onItemClick={handleItemClick}
/>
{/* Double tap heart animation overlay */}
<div className={cn(
"absolute inset-0 flex items-center justify-center pointer-events-none transition-opacity duration-300",
showHeartAnimation ? "opacity-100 scale-100" : "opacity-0 scale-50"
)}>
<Heart className="w-24 h-24 text-white fill-white drop-shadow-xl animate-bounce-short" />
</div>
</div>
{/* Actions Bar - Removed as actions are now per-item in the carousel */}
</article>
);
};

View File

@ -0,0 +1,109 @@
import React, { useState, useCallback, useEffect } from 'react';
import useEmblaCarousel from 'embla-carousel-react';
import { MediaItem } from '@/types';
import { cn } from '@/lib/utils';
import MediaCard from '@/components/MediaCard';
interface CarouselProps {
items: MediaItem[];
onIndexChanged?: (index: number) => void;
className?: string;
aspectRatio?: number;
author?: string;
authorId?: string;
authorAvatarUrl?: string | null;
onItemClick?: (id: string) => void;
showContent?: boolean;
}
export const FeedCarousel: React.FC<CarouselProps> = ({
items,
onIndexChanged,
className,
aspectRatio = 1, // Square by default, typical for feeds
author,
authorId,
authorAvatarUrl,
onItemClick,
showContent = true
}) => {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false });
const [selectedIndex, setSelectedIndex] = useState(0);
const onSelect = useCallback(() => {
if (!emblaApi) return;
const index = emblaApi.selectedScrollSnap();
setSelectedIndex(index);
if (onIndexChanged) {
onIndexChanged(index);
}
}, [emblaApi, onIndexChanged]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on('select', onSelect);
return () => {
emblaApi.off('select', onSelect);
}
}, [emblaApi, onSelect]);
if (!items || items.length === 0) return null;
// Dot indicators - positioned over the image area
const dots = items.length > 1 ? (
<div
className="absolute inset-0 pointer-events-none flex flex-col justify-end pb-4 z-10"
>
<div className="flex justify-center gap-1.5">
{items.map((_, index) => (
<div
key={index}
className={cn(
"w-1.5 h-1.5 rounded-full transition-all duration-300 shadow-md ring-1 ring-black/10",
index === selectedIndex
? "bg-white scale-125"
: "bg-white/60 hover:bg-white/80"
)}
/>
))}
</div>
</div>
) : null;
return (
<div className={cn("relative overflow-hidden", className)}>
<div ref={emblaRef} className="overflow-hidden w-full h-full cursor-grab active:cursor-grabbing">
<div className="flex w-full h-full" style={{ touchAction: 'pan-y pinch-zoom' }}>
{items.map((item, index) => {
return (
<div key={item.id} className="flex-[0_0_100%] min-w-0 relative">
<MediaCard
id={item.id}
pictureId={item.id}
url={item.image_url}
thumbnailUrl={item.thumbnail_url}
title={item.title || `Slide ${index + 1}`}
type={item.type}
author={author || 'User'}
authorId={authorId || item.user_id}
authorAvatarUrl={authorAvatarUrl}
likes={item.likes_count || 0}
comments={0}
description={item.description}
created_at={item.created_at}
showContent={showContent}
onClick={onItemClick}
job={item.job}
responsive={item.responsive}
/>
</div>
);
})}
</div>
</div>
{dots}
</div>
);
};
export default FeedCarousel;

View File

@ -0,0 +1,176 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { FeedPost } from '@/lib/db';
import { FeedCard } from './FeedCard';
import { useAuth } from '@/hooks/useAuth';
import * as db from '@/lib/db';
import { Loader2 } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { useProfiles } from '@/contexts/ProfilesContext';
import { useFeedData, FeedSortOption } from '@/hooks/useFeedData';
import { useFeedCache } from '@/contexts/FeedCacheContext';
import { useOrganization } from '@/contexts/OrganizationContext';
interface MobileFeedProps {
source?: 'home' | 'collection' | 'tag' | 'user';
sourceId?: string;
onNavigate?: (id: string) => void;
sortBy?: FeedSortOption;
}
const PRELOAD_BUFFER = 3;
export const MobileFeed: React.FC<MobileFeedProps> = ({
source = 'home',
sourceId,
onNavigate,
sortBy = 'latest'
}) => {
const { user } = useAuth();
const { getCache, saveCache } = useFeedCache();
const { orgSlug, isOrgContext } = useOrganization();
// Use centralized feed hook
const { posts, loading, error, hasMore } = useFeedData({
source,
sourceId,
sortBy
});
// Scroll Restoration Logic
const cacheKey = `${source}-${sourceId || ''}-${isOrgContext ? 'org' : 'personal'}-${orgSlug || ''}`;
const hasRestoredScroll = useRef(false);
const lastScrollY = useRef(window.scrollY);
// Track scroll position
useEffect(() => {
const handleScroll = () => {
lastScrollY.current = window.scrollY;
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Restore scroll when posts are populated
React.useLayoutEffect(() => {
if (posts.length > 0 && !hasRestoredScroll.current) {
const cached = getCache(cacheKey);
if (cached && cached.scrollY > 0) {
window.scrollTo(0, cached.scrollY);
}
hasRestoredScroll.current = true;
}
}, [posts, cacheKey, getCache]);
// Save scroll position on unmount
useEffect(() => {
return () => {
// We need to save the current state to cache
// We can't easily get the *current* state of 'posts' inside this cleanup unless we use a ref or rely on the hook's cache
// Actually, useFeedData already saves the data state. We just need to ensure scrollY is updated.
// But useFeedData saves on render/update.
// Let's simply update the existing cache entry with the new scrollY
const currentCache = getCache(cacheKey);
if (currentCache && lastScrollY.current > 0) {
saveCache(cacheKey, { ...currentCache, scrollY: lastScrollY.current });
}
};
}, [cacheKey, getCache, saveCache]);
// Preloading Logic
// We simply use <link rel="preload"> or simple JS Image object creation
// But efficiently we want to do it based on scroll position.
// Simplest robust "Load 5 ahead" implementation:
// We already have the URLs in `posts`.
// We can observe the visible index and preload index + 1 to index + 5.
// Intersection Observer for Virtual Window / Preload trigger
// Since we don't have a truly huge list (yet), we will render the list
// but attach an observer to items to trigger preloading of subsequent items.
if (loading) {
return (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="p-4 text-center text-red-500">
Failed to load feed.
</div>
);
}
if (posts.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p>No posts yet.</p>
</div>
);
}
return (
<div className="w-full pb-20">
{posts.map((post, index) => (
<FeedItemWrapper
key={post.id}
post={post}
index={index}
posts={posts} // Pass full list for lookahead access if needed, or simple preload func
currentUser={user}
onNavigate={onNavigate}
/>
))}
</div>
);
};
// Wrapper directly handles visibility logic to trigger preloads
const FeedItemWrapper: React.FC<{
post: FeedPost,
index: number,
posts: FeedPost[],
currentUser: any,
onNavigate?: (id: string) => void
}> = ({ post, index, posts, currentUser, onNavigate }) => {
const { ref, inView } = useInView({
triggerOnce: false,
rootMargin: '200px 0px', // Trigger slightly before
threshold: 0.1
});
useEffect(() => {
/*
if (inView) {
// Preload next 5 posts' Main Image
const bufferEnd = Math.min(index + 1 + PRELOAD_BUFFER, posts.length);
for (let i = index + 1; i < bufferEnd; i++) {
const nextPost = posts[i];
if (nextPost.cover?.image_url) {
const img = new Image();
img.src = nextPost.cover.image_url;
}
// If the next post has multiple images, maybe preload the second one too?
// Keeping it light: only cover for now.
}
}
*/
}, [inView, index, posts]);
return (
<div ref={ref}>
<FeedCard
post={post}
currentUserId={currentUser?.id}
onNavigate={onNavigate}
/>
</div>
);
};
export default MobileFeed;

View File

@ -0,0 +1,155 @@
/**
* Context selector component for the filter system
*/
import React from 'react';
import { TemplateContext, ContextDefinition } from '@/llm/filters/types';
import { getContextDefinition, getContextIcon } from '@/llm/filters/contexts';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Wrench, ShoppingCart, List, FileText, Plus, Settings } from 'lucide-react';
import { useState } from 'react';
interface ContextSelectorProps {
value: TemplateContext;
onChange: (context: TemplateContext) => void;
disabled?: boolean;
className?: string;
showManagement?: boolean;
}
const contextIcons = {
[TemplateContext.MAKER_TUTORIALS]: Wrench,
[TemplateContext.MARKETPLACE]: ShoppingCart,
[TemplateContext.DIRECTORY]: List,
[TemplateContext.COMMONS]: FileText,
};
export const ContextSelector: React.FC<ContextSelectorProps> = ({
value,
onChange,
disabled = false,
className = '',
showManagement = true
}) => {
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingContext, setEditingContext] = useState<ContextDefinition | null>(null);
const contexts: TemplateContext[] = [
TemplateContext.MAKER_TUTORIALS,
TemplateContext.MARKETPLACE,
TemplateContext.DIRECTORY,
TemplateContext.COMMONS
];
const getIcon = (context: TemplateContext) => {
const IconComponent = contextIcons[context];
return <IconComponent className="h-4 w-4" />;
};
const getDescription = (context: TemplateContext) => {
const definition = getContextDefinition(context);
return definition.description;
};
const handleEditContext = (context: TemplateContext) => {
const definition = getContextDefinition(context);
setEditingContext(definition);
setIsEditDialogOpen(true);
};
const handleSaveContext = () => {
// TODO: Implement context saving to database
console.log('Saving context:', editingContext);
setIsEditDialogOpen(false);
setEditingContext(null);
};
return (
<div className={`space-y-2 ${className}`}>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Context</label>
{showManagement && (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditContext(value)}
disabled={disabled}
className="h-6 w-6 p-0"
>
<Settings className="h-3 w-3" />
</Button>
</div>
)}
</div>
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a context" />
</SelectTrigger>
<SelectContent>
{contexts.map((context) => (
<SelectItem key={context} value={context}>
<div className="flex items-center gap-2">
{getIcon(context)}
<span>{getContextDefinition(context).displayName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Edit Context Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit Context</DialogTitle>
</DialogHeader>
{editingContext && (
<div className="space-y-4">
<div>
<Label htmlFor="context-name">Display Name</Label>
<Input
id="context-name"
value={editingContext.displayName}
onChange={(e) => setEditingContext({
...editingContext,
displayName: e.target.value
})}
/>
</div>
<div>
<Label htmlFor="context-description">Description</Label>
<Textarea
id="context-description"
value={editingContext.description}
onChange={(e) => setEditingContext({
...editingContext,
description: e.target.value
})}
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSaveContext}>
Save Changes
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -0,0 +1,505 @@
/**
* Main filter panel widget for the markdown text widget
*/
import React, { useState, useCallback, useEffect } from 'react';
import { TemplateContext, FilterOptions, FilterFlag } from '@/llm/filters/types';
import { filter, filterWithResult, previewFilter } from '@/llm/filters';
import { ContextSelector } from './ContextSelector';
import { ProviderSelector } from './ProviderSelector';
import { TemplateSelector } from './TemplateSelector';
import { FilterSelector } from './FilterSelector';
import { ProviderManagement } from './ProviderManagement';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import {
Wand2,
Play,
Eye,
Settings,
Loader2,
CheckCircle,
XCircle,
Clock,
Zap,
FileText,
Server
} from 'lucide-react';
import { toast } from 'sonner';
import { T, translate } from '@/i18n';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import * as db from '@/pages/Post/db';
interface FilterPanelProps {
content: string;
selectedText?: string;
onFilteredContent: (content: string) => void;
disabled?: boolean;
className?: string;
}
export const FilterPanel: React.FC<FilterPanelProps> = ({
content,
selectedText,
onFilteredContent,
disabled = false,
className = ''
}) => {
const { user } = useAuth();
// State management
const [context, setContext] = useState<TemplateContext>(TemplateContext.COMMONS);
const [provider, setProvider] = useState('openai');
const [model, setModel] = useState('gpt-4o-mini');
const [selectedTemplates, setSelectedTemplates] = useState<string[]>([]);
const [customFilters, setCustomFilters] = useState<FilterFlag[]>([]);
const [selectionMode, setSelectionMode] = useState<'entire' | 'selected'>('entire');
const [isProcessing, setIsProcessing] = useState(false);
const [lastResult, setLastResult] = useState<any>(null);
const [showPreview, setShowPreview] = useState(false);
const [previewData, setPreviewData] = useState<any>(null);
const [showProviderManagement, setShowProviderManagement] = useState(false);
const [settingsLoaded, setSettingsLoaded] = useState(false);
// Load Filter Panel settings from profile
useEffect(() => {
const loadSettings = async () => {
if (!user) {
setSettingsLoaded(true);
return;
}
try {
const settings = await db.getUserSettings(user.id);
const filterSettings = settings?.filterPanel;
if (filterSettings) {
if (filterSettings.context) setContext(filterSettings.context);
if (filterSettings.provider) setProvider(filterSettings.provider);
if (filterSettings.model) setModel(filterSettings.model);
if (filterSettings.selectionMode) setSelectionMode(filterSettings.selectionMode);
console.log('FilterPanel settings loaded:', filterSettings);
}
} catch (error) {
console.error('Error loading filter settings:', error);
} finally {
setSettingsLoaded(true);
}
};
loadSettings();
}, [user]);
// Save Filter Panel settings to profile (debounced)
useEffect(() => {
if (!user || !settingsLoaded) return;
const saveSettings = async () => {
try {
// First, get current settings to merge - reusing the cached getter is safe here
// or we could use updateUserSettings directly if we want to merge properly
// For now, let's just use what was there before
const currentSettings = await db.getUserSettings(user.id);
// Merge with new Filter Panel settings
const updatedSettings = {
...currentSettings,
filterPanel: {
context,
provider,
model,
selectionMode,
}
};
await db.updateUserSettings(user.id, updatedSettings);
console.log('FilterPanel settings saved:', updatedSettings.filterPanel);
} catch (error) {
console.error('Error saving filter settings:', error);
}
};
const timeoutId = setTimeout(saveSettings, 500);
return () => clearTimeout(timeoutId);
}, [user, context, provider, model, selectionMode, settingsLoaded]);
// Handle filter application
const handleApplyFilters = useCallback(async () => {
if (!content || content.trim().length === 0) {
toast.error(translate('No content to filter'));
return;
}
if (selectedTemplates.length === 0) {
toast.error(translate('Please select at least one template'));
return;
}
setIsProcessing(true);
try {
const targetContent = selectionMode === 'selected' && selectedText
? selectedText
: content;
const options: FilterOptions = {
context,
provider,
model,
selection: selectionMode,
customFilters: customFilters.filter(f => f.enabled),
};
// Apply filters for each selected template
let processedContent = targetContent;
for (const template of selectedTemplates) {
console.log('Applying template:', template, 'to content length:', processedContent.length);
const result = await filterWithResult(processedContent, template, options);
console.log('Filter result:', { success: result.success, contentLength: result.content?.length, error: result.error });
if (result.success && result.content) {
processedContent = result.content;
setLastResult(result);
console.log('Processed content:', processedContent);
} else {
throw new Error(result.error || 'Filter processing failed');
}
}
// Apply the processed content
if (selectionMode === 'selected' && selectedText) {
// Replace selected text in the original content
const newContent = content.replace(selectedText, processedContent);
onFilteredContent(newContent);
} else {
// Replace entire content
onFilteredContent(processedContent);
}
toast.success(translate('Content filtered successfully!'));
} catch (error: any) {
console.error('Filter processing failed:', error);
toast.error(translate('Filter processing failed: ') + error.message);
} finally {
setIsProcessing(false);
}
}, [content, selectedText, context, provider, model, selectedTemplates, customFilters, selectionMode, onFilteredContent]);
// Handle preview
const handlePreview = useCallback(() => {
if (!content || content.trim().length === 0) {
toast.error(translate('No content to preview'));
return;
}
if (selectedTemplates.length === 0) {
toast.error(translate('Please select at least one template'));
return;
}
try {
const targetContent = selectionMode === 'selected' && selectedText
? selectedText
: content;
const options: FilterOptions = {
context,
provider,
model,
selection: selectionMode,
customFilters: customFilters.filter(f => f.enabled),
};
const preview = previewFilter(targetContent, selectedTemplates[0], options);
setPreviewData(preview);
setShowPreview(true);
} catch (error: any) {
console.error('Preview generation failed:', error);
toast.error(translate('Preview generation failed: ') + error.message);
}
}, [content, selectedText, context, provider, model, selectedTemplates, customFilters, selectionMode]);
// Reset configuration
const handleReset = useCallback(() => {
setContext(TemplateContext.COMMONS);
setProvider('openai');
setModel('gpt-4o-mini');
setSelectedTemplates([]);
setCustomFilters([]);
setSelectionMode('entire');
setLastResult(null);
toast.success(translate('Configuration reset'));
}, []);
return (
<div className={`space-y-3 ${className}`}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Wand2 className="h-5 w-5" />
<h3 className="text-lg font-semibold">
<T>AI Filters</T>
</h3>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{selectedTemplates.length} templates
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => setShowProviderManagement(true)}
className="h-8 px-2"
>
<Server className="h-4 w-4" />
</Button>
</div>
</div>
{/* Content Selection */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
<T>Content Selection</T>
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<RadioGroup value={selectionMode} onValueChange={(value: 'entire' | 'selected') => setSelectionMode(value)}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="entire" id="entire" />
<Label htmlFor="entire" className="text-sm">
<T>Entire Document</T>
<Badge variant="outline" className="ml-2 text-xs">
{content.length} chars
</Badge>
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="selected" id="selected" disabled={!selectedText} />
<Label htmlFor="selected" className="text-sm">
<T>Selected Text</T>
{selectedText ? (
<Badge variant="secondary" className="ml-2 text-xs">
{selectedText.length} chars
</Badge>
) : (
<Badge variant="outline" className="ml-2 text-xs">
No selection
</Badge>
)}
</Label>
</div>
</RadioGroup>
{/* Show selected text preview */}
{selectedText && selectionMode === 'selected' && (
<div className="p-2 bg-muted rounded-md">
<div className="text-xs text-muted-foreground mb-1">Selected Text Preview:</div>
<div className="text-xs max-h-16 overflow-y-auto">
{selectedText.length > 150 ? `${selectedText.substring(0, 150)}...` : selectedText}
</div>
</div>
)}
{/* Help text for selection */}
{!selectedText && (
<div className="p-2 bg-blue-50 dark:bg-blue-950/20 rounded-md border border-blue-200 dark:border-blue-800">
<div className="text-xs text-blue-700 dark:text-blue-300">
<T>💡 Select text in editor to filter only selected portion</T>
</div>
</div>
)}
</CardContent>
</Card>
{/* Configuration - One Picker Per Row */}
<div className="space-y-2">
<ContextSelector
value={context}
onChange={setContext}
disabled={disabled || isProcessing}
className="space-y-1"
/>
<ProviderSelector
provider={provider}
model={model}
onProviderChange={setProvider}
onModelChange={setModel}
disabled={disabled || isProcessing}
className="space-y-1"
/>
</div>
{/* Templates */}
<TemplateSelector
context={context}
selectedTemplates={selectedTemplates}
onTemplatesChange={setSelectedTemplates}
disabled={disabled || isProcessing}
/>
{/* Custom Filters */}
<FilterSelector
context={context}
customFilters={customFilters}
onFiltersChange={setCustomFilters}
disabled={disabled || isProcessing}
/>
{/* Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap gap-3">
<Button
onClick={handleApplyFilters}
disabled={disabled || isProcessing || selectedTemplates.length === 0}
className="flex-1 min-w-[120px]"
>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<T>Processing...</T>
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
<T>Apply Filters</T>
</>
)}
</Button>
<Button
variant="outline"
onClick={handlePreview}
disabled={disabled || isProcessing || selectedTemplates.length === 0}
>
<Eye className="h-4 w-4 mr-2" />
<T>Preview</T>
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={disabled || isProcessing}
>
<Settings className="h-4 w-4 mr-2" />
<T>Reset</T>
</Button>
</div>
</CardContent>
</Card>
{/* Last Result */}
{lastResult && (
<Card className="border-muted">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
{lastResult.success ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<CardTitle className="text-sm font-medium">
<T>Last Result</T>
</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid grid-cols-2 gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Clock className="h-3 w-3" />
<span>{lastResult.processingTime}ms</span>
</div>
<div className="flex items-center gap-2">
<Zap className="h-3 w-3" />
<span>{lastResult.filtersApplied?.length || 0} filters</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Preview Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
<T>Filter Preview</T>
</DialogTitle>
</DialogHeader>
{previewData && (
<div className="space-y-4">
<div>
<Label className="text-sm font-medium">
<T>Generated Prompt</T>
</Label>
<Textarea
value={previewData.prompt}
readOnly
rows={6}
className="mt-1 font-mono text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium">
<T>Base Filters</T>
</Label>
<div className="flex flex-wrap gap-1 mt-1">
{previewData.baseFilters && previewData.baseFilters.length > 0 ? (
previewData.baseFilters.map((filter: string) => (
<Badge key={filter} variant="secondary" className="text-xs">
{filter}
</Badge>
))
) : (
<span className="text-xs text-muted-foreground">None</span>
)}
</div>
</div>
<div>
<Label className="text-sm font-medium">
<T>Instruction Flags</T>
</Label>
<div className="flex flex-wrap gap-1 mt-1">
{previewData.instructionFlags && previewData.instructionFlags.length > 0 ? (
previewData.instructionFlags.map((flag: string) => (
<Badge key={flag} variant="outline" className="text-xs">
{flag}
</Badge>
))
) : (
<span className="text-xs text-muted-foreground">None</span>
)}
</div>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Provider Management Dialog */}
<Dialog open={showProviderManagement} onOpenChange={setShowProviderManagement}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<ProviderManagement />
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -0,0 +1,303 @@
/**
* Filter selector component for the filter system
*/
import React, { useState, useEffect } from 'react';
import { FilterFlag, FilterCategory, TemplateContext } from '@/llm/filters/types';
import { getInstructionsByCategory, INSTRUCTIONS } from '@/llm/filters/instructions';
import { getContextDefaultFilters } from '@/llm/filters/contexts';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Info, Settings, Shield, FileText, Wand2, Plus, Edit, Trash2 } from 'lucide-react';
interface FilterSelectorProps {
context: TemplateContext;
customFilters: FilterFlag[];
onFiltersChange: (filters: FilterFlag[]) => void;
disabled?: boolean;
className?: string;
showManagement?: boolean;
}
const categoryIcons: Record<string, any> = {
tone: Wand2,
content: FileText,
moderation: Shield,
format: Settings,
context: Info,
};
const categoryDescriptions: Record<string, string> = {
tone: 'Adjust the tone and style of the content',
content: 'Modify and clean up the content itself',
moderation: 'Filter out inappropriate or suspicious content',
format: 'Change the structure and formatting',
context: 'Context-specific instructions and settings',
};
const predefinedFilters: Record<FilterCategory, FilterFlag[]> = {
[FilterCategory.TONE]: [
{ category: FilterCategory.TONE, flag: 'formal', enabled: false },
{ category: FilterCategory.TONE, flag: 'casual', enabled: false },
{ category: FilterCategory.TONE, flag: 'professional', enabled: false },
],
[FilterCategory.CONTENT]: [
{ category: FilterCategory.CONTENT, flag: 'spellCheck', enabled: false },
{ category: FilterCategory.CONTENT, flag: 'removeEmojis', enabled: false },
{ category: FilterCategory.CONTENT, flag: 'convertUnits', enabled: false },
{ category: FilterCategory.CONTENT, flag: 'removeLinks', enabled: false },
{ category: FilterCategory.CONTENT, flag: 'shorten', enabled: false },
],
[FilterCategory.MODERATION]: [
{ category: FilterCategory.MODERATION, flag: 'mafiaFilter', enabled: false },
{ category: FilterCategory.MODERATION, flag: 'deprogramming', enabled: false },
],
[FilterCategory.FORMAT]: [
{ category: FilterCategory.FORMAT, flag: 'formatStructured', enabled: false },
],
};
export const FilterSelector: React.FC<FilterSelectorProps> = ({
context,
customFilters,
onFiltersChange,
disabled = false,
className = '',
showManagement = true
}) => {
const [availableFilters, setAvailableFilters] = useState<Record<string, Array<{flag: string; text: string}>>>({});
const [contextDefaults, setContextDefaults] = useState<Record<string, string[]>>({});
const [isCustomDialogOpen, setIsCustomDialogOpen] = useState(false);
const [customFilterName, setCustomFilterName] = useState('');
const [customFilterDescription, setCustomFilterDescription] = useState('');
useEffect(() => {
const loadFilters = async () => {
try {
// Load instructions from the instruction system
const allInstructions: Record<string, Array<{flag: string; text: string}>> = {};
Object.entries(INSTRUCTIONS).forEach(([category, instructions]) => {
allInstructions[category] = instructions;
});
setAvailableFilters(allInstructions);
const defaults = getContextDefaultFilters(context);
setContextDefaults(defaults);
} catch (error) {
console.error('Failed to load filters:', error);
}
};
loadFilters();
}, [context]);
const handleFilterToggle = (category: FilterCategory, flag: string) => {
if (disabled) return;
const existingFilter = customFilters.find(f => f.category === category && f.flag === flag);
if (existingFilter) {
// Toggle existing filter
const updatedFilters = customFilters.map(f =>
f === existingFilter ? { ...f, enabled: !f.enabled } : f
);
onFiltersChange(updatedFilters);
} else {
// Add new filter
const newFilter: FilterFlag = { category, flag, enabled: true };
onFiltersChange([...customFilters, newFilter]);
}
};
const isFilterEnabled = (category: FilterCategory, flag: string) => {
const filter = customFilters.find(f => f.category === category && f.flag === flag);
return filter?.enabled || false;
};
const isFilterDefault = (category: FilterCategory, flag: string) => {
const categoryDefaults = contextDefaults[category] || [];
return categoryDefaults.includes(flag);
};
const getFilterDescription = (category: string, flag: string) => {
const categoryInstructions = availableFilters[category] || [];
const instruction = categoryInstructions.find(i => i.flag === flag);
return instruction?.text || 'Custom filter';
};
const handleAddCustomFilter = () => {
if (customFilterName.trim()) {
// TODO: Implement custom filter creation
console.log('Adding custom filter:', { name: customFilterName, description: customFilterDescription });
setCustomFilterName('');
setCustomFilterDescription('');
setIsCustomDialogOpen(false);
}
};
return (
<div className={`space-y-4 ${className}`}>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Custom Filters</label>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{customFilters.filter(f => f.enabled).length} enabled
</Badge>
{showManagement && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsCustomDialogOpen(true)}
disabled={disabled}
className="h-6 w-6 p-0"
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
</div>
<div className="space-y-4">
{Object.entries(availableFilters).map(([category, instructions]) => {
const IconComponent = categoryIcons[category] || Settings;
const isContextDefault = Object.keys(contextDefaults).includes(category);
return (
<Card key={category} className={isContextDefault ? 'border-primary/20' : ''}>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<IconComponent className="h-4 w-4" />
<CardTitle className="text-sm font-medium capitalize">
{category.replace(/([A-Z])/g, ' $1').trim()}
</CardTitle>
{isContextDefault && (
<Badge variant="secondary" className="text-xs">
Default
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{categoryDescriptions[category] || 'Filter instructions'}
</p>
</CardHeader>
<CardContent className="pt-0">
<div className="grid gap-3">
{instructions.map((instruction) => {
const isEnabled = isFilterEnabled(category as FilterCategory, instruction.flag);
const isDefault = isFilterDefault(category as FilterCategory, instruction.flag);
return (
<div key={instruction.flag} className="flex items-center space-x-3">
<Checkbox
id={`${category}-${instruction.flag}`}
checked={isEnabled}
onCheckedChange={() => handleFilterToggle(category as FilterCategory, instruction.flag)}
disabled={disabled}
/>
<div className="flex-1">
<Label
htmlFor={`${category}-${instruction.flag}`}
className="text-sm font-medium cursor-pointer"
>
{instruction.flag}
{isDefault && (
<Badge variant="outline" className="ml-2 text-xs">
Default
</Badge>
)}
</Label>
<p className="text-xs text-muted-foreground">
{instruction.text}
</p>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Context Default Filters Info */}
{Object.keys(contextDefaults).length > 0 && (
<Card className="border-muted">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">Context Defaults</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
{Object.entries(contextDefaults).map(([category, filters]) => (
<div key={category} className="text-xs">
<span className="font-medium capitalize">
{category.replace(/([A-Z])/g, ' $1').trim()}:
</span>
<div className="flex flex-wrap gap-1 mt-1">
{filters.map((filter) => (
<Badge key={filter} variant="secondary" className="text-xs">
{filter}
</Badge>
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Add Custom Filter Dialog */}
<Dialog open={isCustomDialogOpen} onOpenChange={setIsCustomDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Custom Filter</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="filter-name">Filter Name</Label>
<Input
id="filter-name"
value={customFilterName}
onChange={(e) => setCustomFilterName(e.target.value)}
placeholder="e.g., myCustomFilter"
/>
</div>
<div>
<Label htmlFor="filter-description">Description</Label>
<Textarea
id="filter-description"
value={customFilterDescription}
onChange={(e) => setCustomFilterDescription(e.target.value)}
placeholder="Describe what this filter does..."
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsCustomDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddCustomFilter} disabled={!customFilterName.trim()}>
Add Filter
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -0,0 +1,971 @@
/**
* Full CRUD interface for managing AI provider configurations
* Manages provider_configs table including API keys in settings field
* User-scoped: Shows only the current user's providers
*/
import React, { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { Tables, TablesInsert, TablesUpdate } from '@/integrations/supabase/types';
import { User } from '@supabase/supabase-js';
import { useAuth } from '@/hooks/useAuth';
import { DEFAULT_PROVIDERS, fetchProviderModelInfo } from '@/llm/filters/providers';
import { groupModelsByCompany } from '@/llm/filters/providers/openrouter';
import { groupOpenAIModelsByType } from '@/llm/filters/providers/openai';
import { getUserSecrets, updateUserSecrets } from '@/components/ImageWizard/db';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Plus,
Pencil,
Trash2,
Loader2,
Server,
Brain,
Zap,
Globe,
Eye,
EyeOff,
Save,
X,
AlertCircle,
CheckCircle2,
Check,
ChevronsUpDown
} from 'lucide-react';
import { toast } from 'sonner';
import { Alert, AlertDescription } from '@/components/ui/alert';
type ProviderConfig = Tables<'provider_configs'>;
interface ProviderSettings {
apiKey?: string;
defaultModel?: string;
[key: string]: any;
}
const providerIcons = {
openai: Brain,
anthropic: Zap,
google: Globe,
openrouter: Server,
};
const getProviderIcon = (name: string) => {
const IconComponent = providerIcons[name as keyof typeof providerIcons] || Server;
return IconComponent;
};
export const ProviderManagement: React.FC = () => {
const { user, loading: authLoading } = useAuth();
const [providers, setProviders] = useState<ProviderConfig[]>([]);
const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<ProviderConfig | null>(null);
const [deletingProvider, setDeletingProvider] = useState<ProviderConfig | null>(null);
// Load user's providers from database
const loadProviders = async () => {
if (!user) {
setProviders([]);
setLoading(false);
return;
}
setLoading(true);
try {
// Fetch both providers and user secrets in parallel
const [providersParams, userSecrets] = await Promise.all([
supabase
.from('provider_configs')
.select('*')
.eq('user_id', user.id)
.order('display_name', { ascending: true }),
getUserSecrets(user.id)
]);
if (providersParams.error) throw providersParams.error;
let loadedProviders = providersParams.data || [];
// Merge secrets into provider configurations for display
if (userSecrets) {
loadedProviders = loadedProviders.map(p => {
if (p.name === 'openai' && userSecrets['openai_api_key']) {
const settings = (p.settings as ProviderSettings) || {};
return { ...p, settings: { ...settings, apiKey: userSecrets['openai_api_key'] } };
}
if (p.name === 'google' && userSecrets['google_api_key']) {
const settings = (p.settings as ProviderSettings) || {};
return { ...p, settings: { ...settings, apiKey: userSecrets['google_api_key'] } };
}
return p;
});
}
setProviders(loadedProviders);
} catch (error: any) {
console.error('Failed to load providers:', error);
toast.error('Failed to load providers: ' + error.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!authLoading) {
loadProviders();
}
}, [user, authLoading]);
const handleCreateProvider = () => {
setEditingProvider(null);
setIsCreateDialogOpen(true);
};
const handleEditProvider = (provider: ProviderConfig) => {
setEditingProvider(provider);
setIsCreateDialogOpen(true);
};
const handleDeleteProvider = async () => {
if (!deletingProvider) return;
try {
const { error } = await supabase
.from('provider_configs')
.delete()
.eq('id', deletingProvider.id);
if (error) throw error;
toast.success(`Provider "${deletingProvider.display_name}" deleted successfully`);
setDeletingProvider(null);
loadProviders();
} catch (error: any) {
console.error('Failed to delete provider:', error);
toast.error('Failed to delete provider: ' + error.message);
}
};
if (authLoading || loading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!user) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4 opacity-50" />
<h3 className="text-lg font-semibold mb-2">Authentication Required</h3>
<p className="text-sm text-muted-foreground mb-4">
Please sign in to manage your AI providers
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">My AI Providers</h2>
<p className="text-muted-foreground">
Manage your personal AI service providers and configurations
</p>
</div>
<Button onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-2" />
Add Provider
</Button>
</div>
{/* Providers List */}
{providers.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
<Server className="h-12 w-12 text-muted-foreground mb-4 opacity-50" />
<h3 className="text-lg font-semibold mb-2">No providers yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Add your first AI provider to start using custom LLM services
</p>
<Button onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-2" />
Add Your First Provider
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{providers.map((provider) => {
const Icon = getProviderIcon(provider.name);
const settings = (provider.settings as ProviderSettings) || {};
const hasApiKey = !!settings.apiKey;
return (
<Card key={provider.id} className={!provider.is_active ? 'opacity-60' : ''}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg">{provider.display_name}</CardTitle>
<CardDescription className="text-xs">{provider.name}</CardDescription>
</div>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditProvider(provider)}
className="h-8 w-8 p-0"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeletingProvider(provider)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Status:</span>
<Badge variant={provider.is_active ? 'default' : 'secondary'}>
{provider.is_active ? 'Active' : 'Inactive'}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">API Key:</span>
<Badge variant={hasApiKey ? 'default' : 'outline'}>
{hasApiKey ? (
<>
<CheckCircle2 className="h-3 w-3 mr-1" />
Configured
</>
) : (
<>
<AlertCircle className="h-3 w-3 mr-1" />
Not Set
</>
)}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Models:</span>
<span className="font-medium">
{Array.isArray(provider.models) ? provider.models.length : 0}
</span>
</div>
</div>
<Separator />
<div className="text-xs text-muted-foreground truncate" title={provider.base_url}>
{provider.base_url}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Create/Edit Dialog */}
<ProviderEditDialog
provider={editingProvider}
user={user}
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
onSave={() => {
loadProviders();
setIsCreateDialogOpen(false);
}}
/>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deletingProvider} onOpenChange={() => setDeletingProvider(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Provider</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{deletingProvider?.display_name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingProvider(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteProvider}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
// Provider Edit Dialog Component
interface ProviderEditDialogProps {
provider: ProviderConfig | null;
user: User;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: () => void;
}
const ProviderEditDialog: React.FC<ProviderEditDialogProps> = ({
provider,
user,
open,
onOpenChange,
onSave,
}) => {
const isEdit = !!provider;
const [saving, setSaving] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
// Form state
const [formData, setFormData] = useState({
name: '',
display_name: '',
base_url: '',
models: [] as string[],
rate_limits: {} as Record<string, number>,
is_active: true,
settings: {} as ProviderSettings,
});
const [selectedModel, setSelectedModel] = useState('');
const [apiKeyInput, setApiKeyInput] = useState('');
const [rateLimitRPM, setRateLimitRPM] = useState('');
const [rateLimitTPM, setRateLimitTPM] = useState('');
const [fetchingModels, setFetchingModels] = useState(false);
const [modelInfoList, setModelInfoList] = useState<any[]>([]);
const [comboboxOpen, setComboboxOpen] = useState(false);
// Load preset from DEFAULT_PROVIDERS
const handleLoadPreset = (presetName: string) => {
const preset = DEFAULT_PROVIDERS[presetName];
if (!preset) return;
setFormData({
name: preset.name,
display_name: preset.displayName,
base_url: preset.baseUrl,
models: preset.models,
rate_limits: preset.rateLimits,
is_active: preset.isActive,
settings: preset.settings || {},
});
setRateLimitRPM(preset.rateLimits.requests_per_minute?.toString() || '');
setRateLimitTPM(preset.rateLimits.tokens_per_minute?.toString() || '');
// Set first model as default if available
if (preset.models.length > 0) {
setSelectedModel(preset.models[0]);
}
toast.success(`Loaded ${preset.displayName} preset`);
};
// Fetch models from provider API
const handleFetchModels = async () => {
if (!formData.name) {
toast.error('Please set provider name first');
return;
}
setFetchingModels(true);
try {
const models = await fetchProviderModelInfo(formData.name, apiKeyInput);
// Store full model info
setModelInfoList(models);
// Update form data models array
const modelIds = models.map((m: any) => m.id);
setFormData(prev => ({ ...prev, models: modelIds }));
// Set first as default if available
if (modelIds.length > 0) {
setSelectedModel(modelIds[0]);
}
toast.success(`Fetched ${modelIds.length} models from ${formData.display_name || formData.name}`);
} catch (error: any) {
console.error('Failed to fetch models:', error);
toast.error('Failed to fetch models: ' + error.message);
} finally {
setFetchingModels(false);
}
};
// Check if provider supports model fetching
const canFetchModels = () => {
// OpenRouter and OpenAI are implemented
return formData.name === 'openrouter' || formData.name === 'openai';
};
useEffect(() => {
if (provider) {
const settings = (provider.settings as ProviderSettings) || {};
const models = Array.isArray(provider.models) ? (provider.models as string[]) : [];
setFormData({
name: provider.name,
display_name: provider.display_name,
base_url: provider.base_url,
models: models,
rate_limits: (provider.rate_limits as Record<string, number>) || {},
is_active: provider.is_active ?? true,
settings: settings,
});
setSelectedModel(settings.defaultModel || '');
setApiKeyInput(settings.apiKey || '');
const limits = (provider.rate_limits as Record<string, number>) || {};
setRateLimitRPM(limits.requests_per_minute?.toString() || '');
setRateLimitTPM(limits.tokens_per_minute?.toString() || '');
// For existing providers, create simple modelInfo from saved models
if (models.length > 0) {
const simpleModelInfo = models.map(modelId => ({
id: modelId,
name: modelId,
description: '',
isFree: false,
supportsTools: false,
supportsImages: false,
supportsText: true,
}));
setModelInfoList(simpleModelInfo);
}
} else {
// Reset form for new provider
setFormData({
name: '',
display_name: '',
base_url: '',
models: [],
rate_limits: {},
is_active: true,
settings: {},
});
setSelectedModel('');
setApiKeyInput('');
setRateLimitRPM('');
setRateLimitTPM('');
setModelInfoList([]);
}
setShowApiKey(false);
}, [provider, open]);
const handleSave = async () => {
// Validation
if (!formData.name.trim()) {
toast.error('Provider name is required');
return;
}
if (!formData.display_name.trim()) {
toast.error('Display name is required');
return;
}
if (!formData.base_url.trim()) {
toast.error('Base URL is required');
return;
}
setSaving(true);
try {
// Build rate limits object
const rate_limits: Record<string, number> = {};
if (rateLimitRPM) {
rate_limits.requests_per_minute = parseInt(rateLimitRPM, 10);
}
if (rateLimitTPM) {
rate_limits.tokens_per_minute = parseInt(rateLimitTPM, 10);
}
// Build settings object
const settings: ProviderSettings = { ...formData.settings };
if (apiKeyInput.trim()) {
settings.apiKey = apiKeyInput.trim();
}
if (selectedModel) {
settings.defaultModel = selectedModel;
}
const data = {
name: formData.name.trim(),
display_name: formData.display_name.trim(),
base_url: formData.base_url.trim(),
models: formData.models,
rate_limits: rate_limits,
is_active: formData.is_active,
settings: settings,
};
if (isEdit) {
// Special handling for OpenAI/Google keys -> user_secrets
if (formData.name === 'openai' || formData.name === 'google') {
try {
const secretUpdate: Record<string, string> = {};
if (formData.name === 'openai') secretUpdate['openai_api_key'] = settings.apiKey || '';
if (formData.name === 'google') secretUpdate['google_api_key'] = settings.apiKey || '';
await updateUserSecrets(user.id, secretUpdate);
} catch (secretError) {
console.error('Failed to update user secrets:', secretError);
toast.error('Failed to update secure storage, but saving config...');
}
}
// Update existing provider
const { error } = await supabase
.from('provider_configs')
.update(data)
.eq('id', provider.id);
if (error) throw error;
toast.success('Provider updated successfully');
} else {
// Create new provider with user_id
// Handle secrets for new provider too
if (formData.name === 'openai' || formData.name === 'google') {
try {
const secretUpdate: Record<string, string> = {};
if (formData.name === 'openai') secretUpdate['openai_api_key'] = settings.apiKey || '';
if (formData.name === 'google') secretUpdate['google_api_key'] = settings.apiKey || '';
await updateUserSecrets(user.id, secretUpdate);
} catch (secretError) {
console.error('Failed to update user secrets:', secretError);
}
}
const { error } = await supabase
.from('provider_configs')
.insert([{ ...data, user_id: user.id }]);
if (error) throw error;
toast.success('Provider created successfully');
}
onSave();
} catch (error: any) {
console.error('Failed to save provider:', error);
toast.error('Failed to save provider: ' + error.message);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Provider' : 'Create New Provider'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Update the provider configuration and API settings'
: 'Add a new AI service provider to the system'}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Preset Selector - Only show when creating */}
{!isEdit && (
<Card className="bg-muted/50">
<CardContent className="pt-6">
<div className="space-y-2">
<Label htmlFor="preset">Load from Preset (Optional)</Label>
<Select onValueChange={handleLoadPreset}>
<SelectTrigger>
<SelectValue placeholder="Choose a provider preset..." />
</SelectTrigger>
<SelectContent>
{Object.keys(DEFAULT_PROVIDERS).map((key) => {
const preset = DEFAULT_PROVIDERS[key];
return (
<SelectItem key={key} value={key}>
{preset.displayName}
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Pre-fill form with default configuration for popular providers
</p>
</div>
</CardContent>
</Card>
)}
{/* Basic Information */}
<div className="space-y-4">
<h3 className="text-sm font-semibold">Basic Information</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Provider Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., openai, anthropic"
disabled={isEdit} // Don't allow changing name on edit
/>
<p className="text-xs text-muted-foreground">
Unique identifier (lowercase, no spaces)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="display_name">Display Name *</Label>
<Input
id="display_name"
value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
placeholder="e.g., OpenAI, Anthropic"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="base_url">Base URL *</Label>
<Input
id="base_url"
value={formData.base_url}
onChange={(e) => setFormData({ ...formData, base_url: e.target.value })}
placeholder="https://api.example.com/v1"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="is_active"
checked={formData.is_active}
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
/>
<Label htmlFor="is_active">Provider is active</Label>
</div>
</div>
<Separator />
{/* API Settings */}
<div className="space-y-4">
<h3 className="text-sm font-semibold">API Settings</h3>
<div className="space-y-2">
<Label htmlFor="api_key">API Key</Label>
<div className="relative">
<Input
id="api_key"
type={showApiKey ? 'text' : 'password'}
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder="sk-..."
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Stored securely in the settings field
</p>
</div>
</div>
<Separator />
{/* Models */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Available Models</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleFetchModels}
disabled={!canFetchModels() || fetchingModels}
className="text-xs"
>
{fetchingModels ? (
<>
<Loader2 className="h-3 w-3 mr-2 animate-spin" />
Fetching...
</>
) : (
<>
<Server className="h-3 w-3 mr-2" />
{canFetchModels() ? 'Fetch Models' : 'Fetch Models (Coming Soon)'}
</>
)}
</Button>
</div>
{/* Show model count when models are available */}
{formData.models.length > 0 && (
<div className="p-3 bg-muted/50 rounded-lg">
<div className="text-sm font-medium">
{formData.models.length} models available
</div>
<div className="text-xs text-muted-foreground mt-1">
Models fetched from {formData.display_name || formData.name}
</div>
</div>
)}
{/* Default Model Selector */}
{formData.models.length > 0 && (
<div className="space-y-2">
<Label htmlFor="default_model">Default Model</Label>
<Popover open={comboboxOpen} onOpenChange={setComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={comboboxOpen}
className="w-full justify-between"
>
{selectedModel ? (
<div className="flex items-center gap-2">
<span className="truncate">
{modelInfoList.find((m: any) => m.id === selectedModel)?.name || selectedModel}
</span>
{(() => {
const modelInfo = modelInfoList.find((m: any) => m.id === selectedModel);
return modelInfo && (
<div className="flex gap-1">
{modelInfo.isFree && (
<Badge variant="default" className="text-xs px-1 py-0">Free</Badge>
)}
{modelInfo.supportsTools && (
<Badge variant="outline" className="text-xs px-1 py-0">Tools</Badge>
)}
{modelInfo.supportsImages && (
<Badge variant="outline" className="text-xs px-1 py-0">Images</Badge>
)}
</div>
);
})()}
</div>
) : (
"Select default model..."
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0"
align="start"
side="bottom"
sideOffset={5}
style={{ width: 'var(--radix-popover-trigger-width)' }}
>
<Command>
<CommandInput placeholder="Search models..." className="h-9" />
<CommandEmpty>No models found.</CommandEmpty>
<div className="max-h-[400px] overflow-y-auto">
<CommandList>
{(() => {
// Use different grouping based on provider
const groupedModels = formData.name === 'openai'
? groupOpenAIModelsByType(modelInfoList)
: groupModelsByCompany(modelInfoList);
return Object.entries(groupedModels)
.sort(([a], [b]) => a.localeCompare(b))
.map(([groupName, models]) => (
<CommandGroup key={groupName} heading={groupName}>
{models.map((modelInfo: any) => (
<CommandItem
key={modelInfo.id}
value={`${modelInfo.id} ${modelInfo.name}`}
onSelect={() => {
setSelectedModel(modelInfo.id);
setComboboxOpen(false);
}}
>
<div className="flex items-center gap-2 w-full">
<Check
className={cn(
"h-4 w-4",
selectedModel === modelInfo.id ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">
{modelInfo.name}
</div>
<div className="flex gap-1 mt-1">
<Badge variant={modelInfo.isFree ? 'default' : 'secondary'} className="text-xs px-1 py-0">
{modelInfo.isFree ? 'Free' : 'Paid'}
</Badge>
{modelInfo.supportsTools && (
<Badge variant="outline" className="text-xs px-1 py-0">
Tools
</Badge>
)}
{modelInfo.supportsImages && (
<Badge variant="outline" className="text-xs px-1 py-0">
Images
</Badge>
)}
{!modelInfo.supportsImages && modelInfo.supportsText && (
<Badge variant="outline" className="text-xs px-1 py-0">
Text Only
</Badge>
)}
</div>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
));
})()}
</CommandList>
</div>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
This model will be used by default when using this provider
</p>
</div>
)}
{/* Help text when no models */}
{formData.models.length === 0 && !canFetchModels() && (
<div className="p-3 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="text-xs text-blue-700 dark:text-blue-300">
💡 Model fetching is not yet available for this provider. You can still create the provider and models will be loaded from presets.
</div>
</div>
)}
</div>
<Separator />
{/* Rate Limits */}
<div className="space-y-4">
<h3 className="text-sm font-semibold">Rate Limits</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="rpm">Requests per Minute</Label>
<Input
id="rpm"
type="number"
value={rateLimitRPM}
onChange={(e) => setRateLimitRPM(e.target.value)}
placeholder="60"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tpm">Tokens per Minute</Label>
<Input
id="tpm"
type="number"
value={rateLimitTPM}
onChange={(e) => setRateLimitTPM(e.target.value)}
placeholder="150000"
/>
</div>
</div>
</div>
{/* Info Alert */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
This provider will be private to your account. API keys are stored securely in the settings field and are only accessible by you.
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
{isEdit ? 'Update' : 'Create'}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ProviderManagement;

View File

@ -0,0 +1,293 @@
/**
* Provider selector component for the filter system
*/
import React, { useState, useEffect } from 'react';
import { ProviderConfig } from '@/llm/filters/types';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ProviderManagement } from './ProviderManagement';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Loader2, Zap, Globe, Brain, Server, Settings, Check, ChevronsUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ProviderSelectorProps {
provider: string;
model: string;
onProviderChange: (provider: string) => void;
onModelChange: (model: string) => void;
disabled?: boolean;
className?: string;
showManagement?: boolean;
}
const providerIcons = {
openai: Brain,
anthropic: Zap,
google: Globe,
openrouter: Server,
};
export const ProviderSelector: React.FC<ProviderSelectorProps> = ({
provider,
model,
onProviderChange,
onModelChange,
disabled = false,
className = '',
showManagement = true
}) => {
const { user, loading: authLoading } = useAuth();
const [providers, setProviders] = useState<ProviderConfig[]>([]);
const [loading, setLoading] = useState(true);
const [showProviderManagement, setShowProviderManagement] = useState(false);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
// Load providers function (extracted for reuse)
const loadProviders = async () => {
if (!user) {
setProviders([]);
setLoading(false);
return;
}
setLoading(true);
try {
const { data: userProviders, error } = await supabase
.from('provider_configs')
.select('*')
.eq('user_id', user.id)
.eq('is_active', true)
.order('display_name', { ascending: true });
if (error) {
console.error('Failed to load user providers:', error);
setProviders([]);
} else if (userProviders) {
// Convert database providers to ProviderConfig
const providers = userProviders.map(dbProvider => ({
name: dbProvider.name,
displayName: dbProvider.display_name,
baseUrl: dbProvider.base_url,
models: Array.isArray(dbProvider.models) ? dbProvider.models as string[] : [],
rateLimits: (dbProvider.rate_limits as Record<string, number>) || {},
isActive: dbProvider.is_active ?? true,
settings: (dbProvider.settings as Record<string, any>) || {},
}));
setProviders(providers);
} else {
setProviders([]);
}
} catch (error) {
console.error('Failed to load providers:', error);
setProviders([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!authLoading) {
loadProviders();
}
}, [user, authLoading]);
const handleProviderChange = (newProvider: string) => {
onProviderChange(newProvider);
// Reset model when provider changes
const models = getCurrentModels(newProvider);
if (models.length > 0) {
// Use default model from settings if available, otherwise first model
const selectedProvider = providers.find(p => p.name === newProvider);
const defaultModel = selectedProvider?.settings?.defaultModel;
const modelToSelect = defaultModel && models.includes(defaultModel) ? defaultModel : models[0];
onModelChange(modelToSelect);
}
};
const getProviderIcon = (providerName: string) => {
const IconComponent = providerIcons[providerName as keyof typeof providerIcons] || Server;
return <IconComponent className="h-4 w-4" />;
};
const getCurrentModels = (providerName?: string) => {
const targetProvider = providerName || provider;
const selectedProvider = providers.find(p => p.name === targetProvider);
return selectedProvider?.models || [];
};
if (authLoading || loading) {
return (
<div className={`space-y-2 ${className}`}>
<label className="text-sm font-medium">Provider</label>
<div className="flex items-center gap-2 p-3 border rounded-md">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">Loading your providers...</span>
</div>
</div>
);
}
if (!user) {
return (
<div className={`space-y-2 ${className}`}>
<label className="text-sm font-medium">Provider</label>
<div className="p-3 border rounded-md border-dashed">
<div className="text-sm text-muted-foreground text-center">
Sign in to manage AI providers
</div>
</div>
</div>
);
}
return (
<div className={`space-y-3 ${className}`}>
{/* Provider Selection */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Provider</label>
{showManagement && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowProviderManagement(true)}
disabled={disabled}
className="h-6 w-6 p-0"
>
<Settings className="h-3 w-3" />
</Button>
)}
</div>
<Select value={provider} onValueChange={handleProviderChange} disabled={disabled}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{providers.length === 0 ? (
<div className="p-3 text-center space-y-2">
<div className="text-sm text-muted-foreground">No providers configured</div>
<div className="text-xs text-muted-foreground">
Click the settings button to add your first provider
</div>
</div>
) : (
providers.map((providerConfig) => (
<SelectItem key={providerConfig.name} value={providerConfig.name}>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
{getProviderIcon(providerConfig.name)}
<span>{providerConfig.displayName}</span>
</div>
<Badge variant="secondary" className="text-xs">
{providerConfig.models.length} models
</Badge>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* Model Selection */}
<div className="space-y-2">
<label className="text-sm font-medium">Model</label>
{getCurrentModels().length === 0 ? (
<div className="p-3 border rounded-md border-dashed">
<div className="text-sm text-muted-foreground text-center">
No models available
</div>
<div className="text-xs text-muted-foreground text-center mt-1">
Select a provider with configured models
</div>
</div>
) : (
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modelComboboxOpen}
className="w-full justify-between"
disabled={disabled}
>
{model ? (
<span className="truncate">{model}</span>
) : (
"Select a model..."
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0"
align="start"
side="bottom"
sideOffset={5}
style={{ width: 'var(--radix-popover-trigger-width)' }}
>
<Command>
<CommandInput placeholder="Search models..." className="h-9" />
<CommandEmpty>No models found.</CommandEmpty>
<div className="max-h-[300px] overflow-y-auto">
<CommandList>
{getCurrentModels().map((modelName) => (
<CommandItem
key={modelName}
value={modelName}
onSelect={() => {
onModelChange(modelName);
setModelComboboxOpen(false);
}}
>
<div className="flex items-center gap-2 w-full">
<Check
className={cn(
"h-4 w-4",
model === modelName ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{modelName}</span>
</div>
</CommandItem>
))}
</CommandList>
</div>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{/* Provider Management Dialog */}
<Dialog open={showProviderManagement} onOpenChange={(open) => {
setShowProviderManagement(open);
// Refresh providers when dialog closes to show any new/updated providers
if (!open && !authLoading) {
loadProviders();
}
}}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<ProviderManagement />
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -0,0 +1,303 @@
/**
* Template selector component for the filter system
*/
import React, { useState, useEffect } from 'react';
import { TemplateContext, TemplateConfig } from '@/llm/filters/types';
import { getContextTemplateConfigs, getContextTemplates } from '@/llm/filters/templates';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, Edit, Trash2, Eye, EyeOff, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
interface TemplateSelectorProps {
context: TemplateContext;
selectedTemplates: string[];
onTemplatesChange: (templates: string[]) => void;
disabled?: boolean;
className?: string;
}
export const TemplateSelector: React.FC<TemplateSelectorProps> = ({
context,
selectedTemplates,
onTemplatesChange,
disabled = false,
className = ''
}) => {
const [templates, setTemplates] = useState<TemplateConfig[]>([]);
const [loading, setLoading] = useState(true);
const [editingTemplate, setEditingTemplate] = useState<TemplateConfig | null>(null);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [showPreview, setShowPreview] = useState<Record<string, boolean>>({});
useEffect(() => {
const loadTemplates = async () => {
try {
const contextTemplates = getContextTemplateConfigs(context);
setTemplates(contextTemplates);
} catch (error) {
console.error('Failed to load templates:', error);
toast.error('Failed to load templates');
} finally {
setLoading(false);
}
};
loadTemplates();
}, [context]);
const handleTemplateToggle = (templateName: string) => {
if (disabled) return;
const newSelection = selectedTemplates.includes(templateName)
? selectedTemplates.filter(t => t !== templateName)
: [...selectedTemplates, templateName];
onTemplatesChange(newSelection);
};
const handleEditTemplate = (template: TemplateConfig) => {
setEditingTemplate(template);
setIsEditDialogOpen(true);
};
const handleSaveTemplate = () => {
// TODO: Implement template saving to database
toast.success('Template saved successfully');
setIsEditDialogOpen(false);
setEditingTemplate(null);
};
const handleDeleteTemplate = (templateName: string) => {
// TODO: Implement template deletion
toast.success('Template deleted');
};
const handleDuplicateTemplate = (template: TemplateConfig) => {
const duplicated = {
...template,
name: `${template.name} (Copy)`,
editable: true
};
setEditingTemplate(duplicated);
setIsEditDialogOpen(true);
};
const togglePreview = (templateName: string) => {
setShowPreview(prev => ({
...prev,
[templateName]: !prev[templateName]
}));
};
if (loading) {
return (
<div className={`space-y-2 ${className}`}>
<label className="text-sm font-medium">Templates</label>
<div className="flex items-center gap-2 p-3 border rounded-md">
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm text-muted-foreground">Loading templates...</span>
</div>
</div>
);
}
return (
<div className={`space-y-3 ${className}`}>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Templates</label>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{selectedTemplates.length} selected
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => setIsAddDialogOpen(true)}
disabled={disabled}
className="h-6 w-6 p-0"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
<div className="grid gap-3">
{templates.map((template) => (
<Card key={template.name} className="relative">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">{template.name}</CardTitle>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => togglePreview(template.name)}
disabled={disabled}
className="h-6 w-6 p-0"
>
{showPreview[template.name] ? (
<EyeOff className="h-3 w-3" />
) : (
<Eye className="h-3 w-3" />
)}
</Button>
{template.editable && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditTemplate(template)}
disabled={disabled}
className="h-6 w-6 p-0"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDuplicateTemplate(template)}
disabled={disabled}
className="h-6 w-6 p-0"
>
<Copy className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTemplate(template.name)}
disabled={disabled}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">{template.description}</p>
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center space-x-2 mb-3">
<Checkbox
id={template.name}
checked={selectedTemplates.includes(template.name)}
onCheckedChange={() => handleTemplateToggle(template.name)}
disabled={disabled}
/>
<Label htmlFor={template.name} className="text-sm">
Use this template
</Label>
</div>
{showPreview[template.name] && (
<div className="mt-3 p-3 bg-muted rounded-md">
<div className="text-xs text-muted-foreground mb-2">Prompt Preview:</div>
<div className="text-xs font-mono whitespace-pre-wrap max-h-32 overflow-y-auto">
{template.prompt}
</div>
<div className="flex gap-1 mt-2">
{template.filters.map((filter) => (
<Badge key={filter} variant="secondary" className="text-xs">
{filter}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
{/* Edit Template Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingTemplate?.name === `${editingTemplate?.name} (Copy)` ? 'Create New Template' : 'Edit Template'}
</DialogTitle>
</DialogHeader>
{editingTemplate && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="template-name">Name</Label>
<Input
id="template-name"
value={editingTemplate.name}
onChange={(e) => setEditingTemplate({
...editingTemplate,
name: e.target.value
})}
/>
</div>
<div>
<Label htmlFor="template-format">Format</Label>
<select
id="template-format"
value={editingTemplate.format}
onChange={(e) => setEditingTemplate({
...editingTemplate,
format: e.target.value as any
})}
className="w-full p-2 border rounded-md"
>
<option value="text">Text</option>
<option value="markdown">Markdown</option>
<option value="json">JSON</option>
</select>
</div>
</div>
<div>
<Label htmlFor="template-description">Description</Label>
<Input
id="template-description"
value={editingTemplate.description}
onChange={(e) => setEditingTemplate({
...editingTemplate,
description: e.target.value
})}
/>
</div>
<div>
<Label htmlFor="template-prompt">Prompt</Label>
<Textarea
id="template-prompt"
value={editingTemplate.prompt}
onChange={(e) => setEditingTemplate({
...editingTemplate,
prompt: e.target.value
})}
rows={6}
className="font-mono text-sm"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSaveTemplate}>
Save Template
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -0,0 +1,10 @@
/**
* Filter components exports
*/
export { FilterPanel } from './FilterPanel';
export { ProviderSelector } from './ProviderSelector';
export { ProviderManagement } from './ProviderManagement';
export { ContextSelector } from './ContextSelector';
export { TemplateSelector } from './TemplateSelector';
export { FilterSelector } from './FilterSelector';

View File

@ -0,0 +1,394 @@
import React, { useState, useEffect } from 'react';
import { T } from '@/i18n';
import { Button } from '@/components/ui/button';
import { Download, Upload, Grid3X3 } from 'lucide-react';
import { useLayout } from '@/contexts/LayoutContext';
import { LayoutContainer } from './LayoutContainer';
import { WidgetPalette } from './WidgetPalette';
interface GenericCanvasProps {
pageId: string;
pageName: string;
isEditMode?: boolean;
showControls?: boolean;
className?: string;
}
const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
pageId,
pageName,
isEditMode = false,
showControls = true,
className = '',
}) => {
const {
loadedPages,
loadPageLayout,
addWidgetToPage,
removeWidgetFromPage,
moveWidgetInPage,
updatePageContainerColumns,
updatePageContainerSettings,
addPageContainer,
removePageContainer,
movePageContainer,
exportPageLayout,
importPageLayout,
saveToApi,
isLoading
} = useLayout();
const layout = loadedPages.get(pageId);
// Load the page layout on mount
useEffect(() => {
if (!layout) {
loadPageLayout(pageId, pageName);
}
}, [pageId, pageName, layout, loadPageLayout]);
const [selectedContainer, setSelectedContainer] = useState<string | null>(null);
const [showWidgetPalette, setShowWidgetPalette] = useState(false);
const [targetContainerId, setTargetContainerId] = useState<string | null>(null);
const [targetColumn, setTargetColumn] = useState<number | undefined>(undefined);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
if (isLoading || !layout) {
return (
<div className={`flex items-center justify-center min-h-[400px] ${className}`}>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-slate-500 dark:text-slate-400">Loading {pageName.toLowerCase()}...</p>
</div>
</div>
);
}
const handleSelectContainer = (containerId: string) => {
setSelectedContainer(containerId);
};
const handleAddWidget = (containerId: string, columnIndex?: number) => {
setTargetContainerId(containerId);
setTargetColumn(columnIndex);
setShowWidgetPalette(true);
};
const handleWidgetAdd = async (widgetId: string) => {
if (targetContainerId) {
try {
await addWidgetToPage(pageId, targetContainerId, widgetId, targetColumn);
} catch (error) {
console.error('Failed to add widget:', error);
}
}
setShowWidgetPalette(false);
setTargetContainerId(null);
setTargetColumn(undefined);
};
const handleCanvasClick = () => {
if (isEditMode) {
setSelectedContainer(null);
}
};
const handleExportLayout = async () => {
try {
const jsonData = await exportPageLayout(pageId);
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${layout.name.toLowerCase().replace(/\s+/g, '-')}-layout.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to export layout:', error);
}
};
const handleImportLayout = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const jsonData = e.target?.result as string;
await importPageLayout(pageId, jsonData);
setSelectedContainer(null);
} catch (error) {
console.error('Failed to import layout:', error);
alert('Failed to import layout. Please check the file format.');
}
};
reader.readAsText(file);
}
};
input.click();
};
const handleSaveToApi = async () => {
if (isSaving) return;
setIsSaving(true);
setSaveStatus('idle');
try {
const success = await saveToApi();
if (success) {
setSaveStatus('success');
setTimeout(() => setSaveStatus('idle'), 2000); // Clear success status after 2s
} else {
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000); // Clear error status after 3s
}
} catch (error) {
console.error('Failed to save to API:', error);
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000);
} finally {
setIsSaving(false);
}
};
const totalWidgets = layout.containers.reduce((total, container) => {
const getContainerWidgetCount = (cont: any): number => {
let count = cont.widgets.length;
cont.children.forEach((child: any) => {
count += getContainerWidgetCount(child);
});
return count;
};
return total + getContainerWidgetCount(container);
}, 0);
if (!isEditMode && totalWidgets === 0) {
return null;
}
return (
<div className={`${className.includes('p-0') ? 'space-y-2' : 'space-y-6'} ${className}`}>
{/* Header with Controls */}
{showControls && (
<div className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold glass-text">
{layout.name}
</h2>
</div>
{/* Edit Mode Controls */}
{isEditMode && (
<div className="flex items-center gap-2 flex-wrap justify-end">
<Button
onClick={async () => {
try {
await addPageContainer(pageId);
} catch (error) {
console.error('Failed to add container:', error);
}
}}
size="sm"
className="glass-button status-gradient-connected text-white border-0"
title="Add container"
>
<Grid3X3 className="h-4 w-4 mr-2" />
<T>Add Container</T>
</Button>
<Button
onClick={handleSaveToApi}
size="sm"
disabled={isSaving}
className={`glass-button ${
saveStatus === 'success'
? 'bg-green-500 text-white'
: saveStatus === 'error'
? 'bg-red-500 text-white'
: 'bg-blue-500 text-white'
}`}
title="Save layout to server"
>
{isSaving ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
<T>Saving...</T>
</>
) : saveStatus === 'success' ? (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<T>Saved!</T>
</>
) : saveStatus === 'error' ? (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<T>Failed</T>
</>
) : (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3-3m0 0l-3 3m3-3v12" />
</svg>
<T>Save</T>
</>
)}
</Button>
<Button
onClick={handleImportLayout}
size="sm"
className="glass-button"
title="Import layout"
>
<Upload className="h-4 w-4 mr-2" />
<T>Import</T>
</Button>
<Button
onClick={handleExportLayout}
size="sm"
className="glass-button"
title="Export layout"
>
<Download className="h-4 w-4 mr-2" />
<T>Export</T>
</Button>
</div>
)}
</div>
{/* Layout Info */}
<div className="mt-3 pt-3 border-t border-slate-300/30 dark:border-white/10">
<p className="text-xs text-slate-500 dark:text-slate-400">
<T>Containers</T>: {layout.containers.length} | <T>Widgets</T>: {totalWidgets} | <T>Last updated</T>: {new Date(layout.updatedAt).toLocaleString()}
</p>
</div>
</div>
)}
{/* Container Canvas */}
<div
className={`${className.includes('p-0') ? 'space-y-2' : 'space-y-4'}`}
onClick={handleCanvasClick}
>
{layout.containers
.sort((a, b) => (a.order || 0) - (b.order || 0))
.map((container, index, array) => (
<LayoutContainer
key={container.id}
container={container}
isEditMode={isEditMode}
pageId={pageId}
selectedContainerId={selectedContainer}
onSelect={handleSelectContainer}
onAddWidget={handleAddWidget}
isCompactMode={className.includes('p-0')}
onRemoveWidget={async (widgetId) => {
try {
await removeWidgetFromPage(pageId, widgetId);
} catch (error) {
console.error('Failed to remove widget:', error);
}
}}
onMoveWidget={async (widgetId, direction) => {
try {
await moveWidgetInPage(pageId, widgetId, direction);
} catch (error) {
console.error('Failed to move widget:', error);
}
}}
onUpdateColumns={async (containerId, columns) => {
try {
await updatePageContainerColumns(pageId, containerId, columns);
} catch (error) {
console.error('Failed to update columns:', error);
}
}}
onUpdateSettings={async (containerId, settings) => {
try {
await updatePageContainerSettings(pageId, containerId, settings);
} catch (error) {
console.error('Failed to update settings:', error);
}
}}
onAddContainer={async (parentId) => {
try {
await addPageContainer(pageId, parentId);
} catch (error) {
console.error('Failed to add container:', error);
}
}}
onMoveContainer={async (containerId, direction) => {
try {
await movePageContainer(pageId, containerId, direction);
} catch (error) {
console.error('Failed to move container:', error);
}
}}
canMoveContainerUp={index > 0}
canMoveContainerDown={index < array.length - 1}
onRemoveContainer={async (containerId) => {
try {
await removePageContainer(pageId, containerId);
} catch (error) {
console.error('Failed to remove container:', error);
}
}}
/>
))}
{layout.containers.length === 0 && (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
{isEditMode ? (
<>
<Grid3X3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">
<T>No containers yet</T>
</p>
<p className="text-sm">
<T>Click "Add Container" to start building your layout</T>
</p>
</>
) : (
<>
<p className="text-lg font-medium mb-2">
<T>Empty Layout</T>
</p>
<p className="text-sm">
<T>Switch to edit mode to add containers</T>
</p>
</>
)}
</div>
)}
</div>
{/* Widget Palette Modal */}
<WidgetPalette
isVisible={showWidgetPalette}
onClose={() => {
setShowWidgetPalette(false);
setTargetContainerId(null);
}}
onWidgetAdd={handleWidgetAdd}
/>
</div>
);
};
export const GenericCanvas = GenericCanvasComponent;

View File

@ -0,0 +1,593 @@
import React, { useState } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Plus, Minus, Grid3X3, Trash2, Settings, ArrowUp, ArrowDown } from 'lucide-react';
import { LayoutContainer as LayoutContainerType, WidgetInstance } from '@/lib/unifiedLayoutManager';
import { widgetRegistry } from '@/lib/widgetRegistry';
import { WidgetSettingsManager } from '@/components/widgets/WidgetSettingsManager';
import { WidgetMovementControls } from '@/components/widgets/WidgetMovementControls';
import { useLayout } from '@/contexts/LayoutContext';
import CollapsibleSection from '@/components/CollapsibleSection';
import { ContainerSettingsManager } from '@/components/containers/ContainerSettingsManager';
interface LayoutContainerProps {
container: LayoutContainerType;
isEditMode: boolean;
pageId: string;
selectedContainerId?: string | null;
onSelect?: (containerId: string) => void;
onAddWidget?: (containerId: string, targetColumn?: number) => void;
onRemoveWidget?: (widgetInstanceId: string) => void;
onMoveWidget?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
onUpdateColumns?: (containerId: string, columns: number) => void;
onUpdateSettings?: (containerId: string, settings: Partial<LayoutContainerType['settings']>) => void;
onAddContainer?: (parentContainerId: string) => void;
onRemoveContainer?: (containerId: string) => void;
onMoveContainer?: (containerId: string, direction: 'up' | 'down') => void;
canMoveContainerUp?: boolean;
canMoveContainerDown?: boolean;
depth?: number;
isCompactMode?: boolean;
}
const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
container,
isEditMode,
pageId,
selectedContainerId,
onSelect,
onAddWidget,
onRemoveWidget,
onMoveWidget,
onUpdateColumns,
onUpdateSettings,
onAddContainer,
onRemoveContainer,
onMoveContainer,
canMoveContainerUp,
canMoveContainerDown,
depth = 0,
isCompactMode = false,
}) => {
const maxDepth = 3; // Limit nesting depth
const canNest = depth < maxDepth;
const isSelected = selectedContainerId === container.id;
const [showContainerSettings, setShowContainerSettings] = useState(false);
// Generate responsive grid classes based on container.columns
const getGridClasses = (columns: number) => {
const baseClass = "grid gap-4"; // Always grid with gap
// Mobile: always 1 column, Desktop: respect container.columns
switch (columns) {
case 1: return `${baseClass} grid-cols-1`;
case 2: return `${baseClass} grid-cols-1 md:grid-cols-2`;
case 3: return `${baseClass} grid-cols-1 md:grid-cols-2 lg:grid-cols-3`;
case 4: return `${baseClass} grid-cols-1 md:grid-cols-2 lg:grid-cols-4`;
case 5: return `${baseClass} grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5`;
case 6: return `${baseClass} grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6`;
default:
// For 7+ columns, use a more conservative approach
return `${baseClass} grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-${Math.min(columns, 12)}`;
}
};
const gridClasses = getGridClasses(container.columns);
// Extract container content rendering logic
const renderContainerContent = () => (
<>
{/* Grid Column Indicators (only in edit mode when selected) */}
{isEditMode && isSelected && container.widgets.length === 0 && container.children.length === 0 && (
<>
{Array.from({ length: container.columns }, (_, i) => (
<div
key={i}
className="min-h-[80px] flex items-center justify-center text-blue-500 dark:text-blue-400 text-sm cursor-pointer hover:bg-blue-100/20 dark:hover:bg-blue-800/20 transition-colors"
onDoubleClick={(e) => {
e.stopPropagation();
onAddWidget?.(container.id, i);
}}
title={`Double-click to add widget to column ${i + 1}`}
>
Col {i + 1}
</div>
))}
</>
)}
{/* Render Widgets */}
{container.widgets
.sort((a, b) => (a.order || 0) - (b.order || 0))
.map((widget, index) => (
<WidgetItem
key={widget.id}
widget={widget}
isEditMode={isEditMode}
pageId={pageId}
canMoveUp={index > 0}
canMoveDown={index < container.widgets.length - 1}
onRemove={onRemoveWidget}
onMove={onMoveWidget}
/>
))}
{/* Add Widget Buttons - one per column for non-empty containers (in edit mode) */}
{isEditMode && container.widgets.length > 0 && container.children.length === 0 && (
<>
{Array.from({ length: container.columns }, (_, colIndex) => (
<div
key={`add-widget-${colIndex}`}
className="flex items-center justify-center min-h-[60px] rounded-lg hover:border-blue-400 dark:hover:border-blue-500 transition-colors cursor-pointer group"
onClick={(e) => {
e.stopPropagation();
onAddWidget?.(container.id, colIndex);
}}
onDoubleClick={(e) => {
e.stopPropagation();
onAddWidget?.(container.id, colIndex);
}}
title={`Click to add widget to column ${colIndex + 1}`}
>
<div className="text-center text-slate-500 dark:text-slate-400 group-hover:text-blue-500 dark:group-hover:text-blue-400 transition-colors">
<Plus className="h-5 w-5 mx-auto mb-1" />
<p className="text-xs">Add Widget</p>
{container.columns > 1 && <p className="text-xs opacity-60">Col {colIndex + 1}</p>}
</div>
</div>
))}
</>
)}
{/* Render Nested Containers */}
{container.children
.sort((a, b) => (a.order || 0) - (b.order || 0))
.map((childContainer) => (
<div key={childContainer.id} className="col-span-full">
<LayoutContainer
container={childContainer}
isEditMode={isEditMode}
pageId={pageId}
selectedContainerId={selectedContainerId}
onSelect={onSelect}
onAddWidget={onAddWidget}
onRemoveWidget={onRemoveWidget}
onMoveWidget={onMoveWidget}
onUpdateColumns={onUpdateColumns}
onUpdateSettings={onUpdateSettings}
onAddContainer={onAddContainer}
onRemoveContainer={onRemoveContainer}
onMoveContainer={onMoveContainer}
canMoveContainerUp={canMoveContainerUp}
canMoveContainerDown={canMoveContainerDown}
depth={depth + 1}
isCompactMode={isCompactMode}
/>
</div>
))}
{/* Empty State - only show when not showing column indicators */}
{container.widgets.length === 0 && container.children.length === 0 && !(isEditMode && isSelected) && (
<div
className={cn(
"col-span-full flex items-center justify-center min-h-[80px] text-slate-500 dark:text-slate-400",
isEditMode && "cursor-pointer hover:bg-slate-100/20 dark:hover:bg-slate-800/20 transition-colors"
)}
onDoubleClick={isEditMode ? (e) => {
e.stopPropagation();
onSelect?.(container.id);
setTimeout(() => onAddWidget?.(container.id), 100); // Small delay to ensure selection happens first, no column = append
} : undefined}
title={isEditMode ? "Double-click to add widget" : undefined}
>
{isEditMode ? (
<div className="text-center">
<Plus className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">Double-click to add widgets</p>
</div>
) : (
<p className="text-sm"></p>
)}
</div>
)}
</>
);
return (
<div className="space-y-0">
{/* Edit Mode Controls */}
{isEditMode && (
<div className={cn(
"text-white px-2 sm:px-3 py-1 rounded-t-lg text-xs overflow-hidden",
isSelected ? "bg-blue-500" : "bg-slate-500"
)}>
{/* Responsive layout: title and buttons wrap on small screens */}
<div className="flex items-center justify-between gap-x-2 gap-y-1 min-w-0 flex-wrap">
<div className="flex items-center gap-1 min-w-0 flex-grow">
<Grid3X3 className="h-3 w-3 shrink-0" />
<span className="truncate text-xs font-semibold">
{container.settings?.showTitle && container.settings?.title
? container.settings.title
: `Container (${container.columns} col${container.columns !== 1 ? 's' : ''})`}
{(container.settings?.collapsible || container.settings?.showTitle) && (
<span className="ml-1 opacity-75 font-normal"></span>
)}
</span>
</div>
{/* Minimalist button row - wraps and justifies to the end */}
<div className="flex items-center gap-0.5 flex-wrap justify-end">
{/* Move controls for root containers */}
{depth === 0 && (
<div className="flex items-center gap-0.5 mr-1 border-r border-white/20 pr-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onMoveContainer?.(container.id, 'up');
}}
disabled={!canMoveContainerUp}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0"
title="Move container up"
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onMoveContainer?.(container.id, 'down');
}}
disabled={!canMoveContainerDown}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0"
title="Move container down"
>
<ArrowDown className="h-3 w-3" />
</Button>
</div>
)}
{/* Column controls */}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onUpdateColumns?.(container.id, container.columns - 1);
}}
disabled={container.columns <= 1}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0"
title="Decrease columns"
>
<Minus className="h-3 w-3" />
</Button>
<span className="min-w-[12px] text-center text-xs font-medium px-1">{container.columns}</span>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onUpdateColumns?.(container.id, container.columns + 1);
}}
disabled={container.columns >= 12}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0"
title="Increase columns"
>
<Plus className="h-3 w-3" />
</Button>
{/* Add widget button */}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAddWidget?.(container.id);
}}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0 ml-1"
title="Add widget"
>
<Plus className="h-3 w-3" />
</Button>
{/* Add nested container button */}
{canNest && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAddContainer?.(container.id);
}}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0"
title="Add nested container"
>
<Grid3X3 className="h-3 w-3" />
</Button>
)}
{/* Container settings button */}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowContainerSettings(true);
}}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0"
title="Container settings"
>
<Settings className="h-3 w-3" />
</Button>
{/* Remove container button */}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onRemoveContainer?.(container.id);
}}
className="h-4 px-1 text-white hover:bg-red-400 shrink-0"
title="Remove container"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</div>
)}
{/* Container Content */}
<div
className={cn(
"relative transition-all duration-200 min-w-0 overflow-hidden",
isEditMode ? "rounded-b-lg" : "rounded-lg",
isEditMode && "hover:border-blue-300 cursor-pointer",
isSelected && isEditMode && "border-blue-500 bg-blue-50/20 dark:bg-blue-900/20",
!isSelected && isEditMode && "border-slate-300/50 dark:border-white/20",
!isEditMode && "border-transparent"
)}
onClick={(e) => {
e.stopPropagation();
if (isEditMode) {
onSelect?.(container.id);
}
}}
>
{container.settings?.collapsible ? (
<CollapsibleSection
title={
container.settings?.showTitle
? (container.settings?.title || `Container (${container.columns} col${container.columns !== 1 ? 's' : ''})`)
: `Container (${container.columns} col${container.columns !== 1 ? 's' : ''})`
}
initiallyOpen={!container.settings?.collapsed}
storageKey={`container-${container.id}-collapsed`}
className="border-0 rounded-none shadow-none bg-transparent"
minimal={true}
>
<div
className={cn(
isCompactMode ? "p-1 sm:p-2 min-h-[80px]" : isEditMode ? "p-2 min-h-[120px]" : "min-h-[120px]",
gridClasses,
isEditMode && isSelected && "bg-blue-50/10 dark:bg-blue-900/10"
)}
style={{
gap: `${container.gap}px`,
}}
>
{renderContainerContent()}
</div>
</CollapsibleSection>
) : (
<div>
{/* Title for non-collapsible containers */}
{container.settings?.showTitle && (
<div className="px-4 pt-3 pb-1 border-b border-slate-300/30 dark:border-white/10">
<h3 className="text-sm font-medium text-slate-700 dark:text-white">
{container.settings?.title || `Container (${container.columns} col${container.columns !== 1 ? 's' : ''})`}
</h3>
</div>
)}
<div
className={cn(
isCompactMode ? "p-1 sm:p-2 min-h-[80px]" : isEditMode ? "p-2 min-h-[120px]" : "min-h-[120px]",
gridClasses,
isEditMode && isSelected && "bg-blue-50/10 dark:bg-blue-900/10"
)}
style={{
gap: `${container.gap}px`,
}}
>
{renderContainerContent()}
</div>
</div>
)}
</div>
{/* Container Settings Dialog */}
{showContainerSettings && (
<ContainerSettingsManager
isOpen={showContainerSettings}
onClose={() => setShowContainerSettings(false)}
onSave={(settings) => {
onUpdateSettings?.(container.id, settings);
setShowContainerSettings(false);
}}
currentSettings={container.settings}
containerInfo={{
id: container.id,
columns: container.columns,
}}
/>
)}
</div>
);
};
interface WidgetItemProps {
widget: WidgetInstance;
isEditMode: boolean;
pageId: string;
canMoveUp: boolean;
canMoveDown: boolean;
onRemove?: (widgetInstanceId: string) => void;
onMove?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
}
const WidgetItem: React.FC<WidgetItemProps> = ({
widget,
isEditMode,
pageId,
canMoveUp,
canMoveDown,
onRemove,
onMove,
}) => {
const widgetDefinition = widgetRegistry.get(widget.widgetId);
const { updateWidgetProps } = useLayout();
const [showSettingsModal, setShowSettingsModal] = useState(false);
// pageId is now passed as a prop from the parent component
if (!widgetDefinition) {
return (
<div className="relative group border-red-500 bg-red-100 dark:bg-red-900/20 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">
Widget "{widget.widgetId}" not found in registry
</p>
{isEditMode && (
<Button
size="icon"
variant="destructive"
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onRemove?.(widget.id);
}}
title="Remove invalid widget"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
);
}
const WidgetComponent = widgetDefinition.component;
const handleSettingsSave = async (settings: Record<string, any>) => {
try {
await updateWidgetProps(pageId, widget.id, settings);
} catch (error) {
console.error('Failed to save widget settings:', error);
}
};
return (
<div className="relative group">
{/* Edit Mode Controls */}
{isEditMode && (
<>
{/* Widget Info Overlay */}
<div className="absolute top-0 left-0 right-0 bg-green-500/90 text-white text-xs px-2 py-1 rounded-t-lg z-10">
<div className="flex items-center justify-between">
<span>{widgetDefinition.metadata.name}</span>
<div className="flex items-center gap-1">
{/* Settings Gear Icon - For widgets with configSchema */}
{widgetDefinition.metadata.configSchema && (
<Button
size="icon"
variant="ghost"
className="h-4 w-4 text-white hover:bg-white/20"
onClick={(e) => {
e.stopPropagation();
setShowSettingsModal(true);
}}
title="Widget settings"
>
<Settings className="h-2 w-2" />
</Button>
)}
{/* Remove Button */}
<Button
size="icon"
variant="ghost"
className="h-4 w-4 text-white hover:bg-white/20"
onClick={(e) => {
e.stopPropagation();
onRemove?.(widget.id);
}}
title="Remove widget"
>
<Trash2 className="h-2 w-2" />
</Button>
</div>
</div>
</div>
{/* Move Controls - Cross Pattern */}
<WidgetMovementControls
className="absolute top-8 left-2 z-10"
onMove={(direction) => onMove?.(widget.id, direction)}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
/>
</>
)}
{/* Widget Content - Always 100% width */}
<div className="w-full bg-white dark:bg-slate-800 overflow-hidden">
<WidgetComponent
{...(widget.props || {})}
widgetInstanceId={widget.id}
isEditMode={isEditMode}
onPropsChange={async (newProps: Record<string, any>) => {
try {
await updateWidgetProps(pageId, widget.id, newProps);
} catch (error) {
console.error('Failed to update widget props:', error);
}
}}
/>
</div>
{/* Generic Settings Modal */}
{widgetDefinition.metadata.configSchema && showSettingsModal && (
<WidgetSettingsManager
isOpen={showSettingsModal}
onClose={() => setShowSettingsModal(false)}
widgetDefinition={widgetDefinition}
currentProps={widget.props || {}}
onSave={handleSettingsSave}
/>
)}
</div>
);
};
// Conservative comparison function for React.memo
const areLayoutContainerPropsEqual = (
prevProps: LayoutContainerProps,
nextProps: LayoutContainerProps
): boolean => {
// Conservative approach: only prevent re-render for truly identical props
// Since we're using deep cloning in LayoutContext, object references will change
// when the layout data actually changes, so we can be more conservative here
return (
prevProps.isEditMode === nextProps.isEditMode &&
prevProps.selectedContainerId === nextProps.selectedContainerId &&
prevProps.depth === nextProps.depth &&
prevProps.container === nextProps.container // Reference equality check
);
};
// Export memoized component with conservative comparison
export const LayoutContainer = React.memo(LayoutContainerComponent, areLayoutContainerPropsEqual);

View File

@ -0,0 +1,162 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { widgetRegistry } from '@/lib/widgetRegistry';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Search, Plus, X } from 'lucide-react';
import { T } from '@/i18n';
interface WidgetPaletteProps {
isVisible: boolean;
onClose: () => void;
onWidgetAdd: (widgetId: string) => void;
}
export const WidgetPalette: React.FC<WidgetPaletteProps> = ({
isVisible,
onClose,
onWidgetAdd
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
// Handle Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isVisible) {
onClose();
}
};
if (isVisible) {
document.addEventListener('keydown', handleKeyDown);
// Focus the search input when opened
setTimeout(() => {
const searchInput = document.querySelector('#widget-search-input') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
}, 100);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isVisible, onClose]);
if (!isVisible) return null;
const widgets = searchQuery
? widgetRegistry.search(searchQuery)
: selectedCategory === 'all'
? widgetRegistry.getAll()
: widgetRegistry.getByCategory(selectedCategory);
const categories = ['all', 'control', 'display', 'chart', 'system', 'custom'];
const modalContent = (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[99999]"
onClick={onClose}
style={{ zIndex: 99999 }}
>
<Card
className="glass-card w-96 max-w-[90vw] max-h-[80vh] overflow-hidden shadow-2xl"
onClick={(e) => e.stopPropagation()}
style={{ zIndex: 100000 }}
>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-lg"><T>Add Widget</T></CardTitle>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
id="widget-search-input"
placeholder="Search widgets..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 glass-input"
/>
</div>
{/* Categories */}
<div className="flex flex-wrap gap-1">
{categories.map(category => (
<Button
key={category}
size="sm"
variant={selectedCategory === category ? "default" : "outline"}
onClick={() => setSelectedCategory(category)}
className="text-xs h-7"
>
{category.charAt(0).toUpperCase() + category.slice(1)}
</Button>
))}
</div>
{/* Widget List */}
<div className="space-y-2 max-h-64 overflow-y-auto">
{widgets.map(widget => (
<div
key={widget.metadata.id}
className="flex items-center justify-between p-3 border border-slate-300/30 dark:border-white/10 rounded-lg bg-white/5 dark:bg-black/5 hover:bg-white/10 dark:hover:bg-black/10 transition-colors cursor-pointer"
onClick={() => {
onWidgetAdd(widget.metadata.id);
onClose();
}}
>
<div className="flex items-center space-x-3 flex-1 min-w-0">
{widget.metadata.icon && (
<div className="h-5 w-5 text-slate-600 dark:text-slate-300 shrink-0">
{React.createElement(widget.metadata.icon, {})}
</div>
)}
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">{widget.metadata.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-400 truncate">
{widget.metadata.description}
</div>
</div>
</div>
<Button
size="icon"
onClick={(e) => {
e.stopPropagation(); // Prevent the div's onClick from firing
onWidgetAdd(widget.metadata.id);
onClose();
}}
className="h-8 w-8 glass-button shrink-0"
title={`Add ${widget.metadata.name}`}
>
<Plus className="h-4 w-4" />
</Button>
</div>
))}
{widgets.length === 0 && (
<div className="text-center text-slate-500 dark:text-slate-400 py-8">
<T>No widgets found</T>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
// Render in portal to ensure it's at the top level
return createPortal(modalContent, document.body);
};

View File

@ -0,0 +1,155 @@
import React, { useEffect } from 'react';
import {
MDXEditor, headingsPlugin, linkPlugin,
linkDialogPlugin, diffSourcePlugin, toolbarPlugin, imagePlugin,
quotePlugin, thematicBreakPlugin, tablePlugin,
UndoRedo, BoldItalicUnderlineToggles,
DiffSourceToggleWrapper, BlockTypeSelect, CreateLink, InsertThematicBreak,
InsertTable, ListsToggle, listsPlugin, codeBlockPlugin, codeMirrorPlugin, sandpackPlugin,
SandpackConfig
} from '@mdxeditor/editor';
import '@mdxeditor/editor/style.css';
import '@/styles/mdx-editor-theme.css';
import { InsertCustomImage, MicrophoneTranscribe, FullscreenToggle, SaveButton } from '@/components/EditorActions';
import { useTheme } from '@/components/ThemeProvider';
import { useState } from 'react';
export interface MDXEditorWithImagePickerProps {
value: string;
onChange: (markdown: string) => void;
placeholder: string;
onRequestImage: () => Promise<string>;
onTextInsert: (text: string) => void;
onSelectionChange?: (selectedText: string) => void;
isFullscreen: boolean;
onToggleFullscreen: () => void;
onSave?: () => void;
editorRef: React.MutableRefObject<any>;
}
const defaultSnippetContent = `
export default function App() {
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
</div>
);
}
`.trim()
const simpleSandpackConfig: SandpackConfig = {
defaultPreset: 'react',
presets: [
{
label: 'React',
name: 'react',
meta: 'live react',
sandpackTemplate: 'react',
sandpackTheme: 'light',
snippetFileName: '/App.js',
snippetLanguage: 'jsx',
initialSnippetContent: defaultSnippetContent
}
]
}
export default function MDXEditorInternal({
value,
onChange,
placeholder,
onRequestImage,
onTextInsert,
onSelectionChange,
isFullscreen,
onToggleFullscreen,
onSave,
editorRef
}: MDXEditorWithImagePickerProps) {
// Handle text selection changes
useEffect(() => {
const handleSelectionChange = () => {
if (onSelectionChange) {
const selection = window.getSelection();
if (selection && selection.toString().trim()) {
onSelectionChange(selection.toString().trim());
} else {
onSelectionChange('');
}
}
};
// Add selection change listener
document.addEventListener('selectionchange', handleSelectionChange);
return () => {
document.removeEventListener('selectionchange', handleSelectionChange);
};
}, [onSelectionChange]);
const { theme } = useTheme();
const [isDarkMode, setIsDarkMode] = useState(() => {
if (typeof window === 'undefined') return false;
if (theme === 'system') return window.matchMedia('(prefers-color-scheme: dark)').matches;
return theme === 'dark';
});
useEffect(() => {
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => setIsDarkMode(e.matches);
// Set initial
setIsDarkMode(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
} else {
setIsDarkMode(theme === 'dark');
}
}, [theme]);
return (
<MDXEditor
className={isDarkMode ? 'dark-theme dark-editor' : ''}
ref={editorRef}
markdown={value || ''}
onChange={onChange}
plugins={[
headingsPlugin(),
linkPlugin(),
linkDialogPlugin(),
imagePlugin(),
quotePlugin(),
thematicBreakPlugin(),
listsPlugin(),
tablePlugin(),
codeBlockPlugin({ defaultCodeBlockLanguage: 'js' }),
codeMirrorPlugin({ codeBlockLanguages: { js: 'JavaScript', css: 'CSS', 'json': 'JSON', '': 'Text' } }),
sandpackPlugin({ sandpackConfig: simpleSandpackConfig }),
toolbarPlugin({
toolbarClassName: 'mdx-toolbar',
toolbarContents: () => (
<>
<UndoRedo />
<ListsToggle />
<BoldItalicUnderlineToggles />
<BlockTypeSelect />
<CreateLink />
<InsertThematicBreak />
<InsertTable />
<DiffSourceToggleWrapper>
<InsertCustomImage onRequestImage={onRequestImage} />
<MicrophoneTranscribe onTranscribed={onTextInsert} />
<FullscreenToggle isFullscreen={isFullscreen} onToggle={onToggleFullscreen} />
<SaveButton onSave={onSave} />
</DiffSourceToggleWrapper>
</>
)
})
]}
placeholder={placeholder}
contentEditableClassName="prose prose-sm max-w-none dark:prose-invert"
/>
);
}

View File

@ -0,0 +1,149 @@
import React, { useEffect, useRef } from 'react';
import { CrepeBuilder } from '@milkdown/crepe/builder';
import { blockEdit } from '@milkdown/crepe/feature/block-edit';
import { toolbar } from '@milkdown/crepe/feature/toolbar';
import '@milkdown/crepe/theme/common/prosemirror.css';
import '@milkdown/crepe/theme/common/reset.css';
import '@milkdown/crepe/theme/common/block-edit.css';
import '@milkdown/crepe/theme/common/toolbar.css';
import '@milkdown/crepe/theme/common/image-block.css';
import '@milkdown/crepe/theme/common/style.css';
import '@milkdown/crepe/theme/frame.css';
interface MilkdownEditorInternalProps {
value: string;
onChange: (value: string) => void;
className?: string;
}
const MilkdownEditorInternal: React.FC<MilkdownEditorInternalProps> = ({
value,
onChange,
className
}) => {
const ref = useRef<HTMLDivElement>(null);
const builderRef = useRef<CrepeBuilder | null>(null);
const editorReady = useRef(false);
const debounceTimer = useRef<NodeJS.Timeout>();
const isUpdatingFromOutside = useRef(false);
const lastEmittedValue = useRef<string>(value);
// Memoize onChange to prevent effect re-runs
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
// Effect for editor setup and mutation observer
useEffect(() => {
if (!ref.current) return;
editorReady.current = false;
// Create builder with basic config
const builder = new CrepeBuilder({
root: ref.current,
defaultValue: value || '',
});
// Add features manually
builder
.addFeature(blockEdit)
.addFeature(toolbar);
// Create the editor and wait for it to be ready
builder.create().then(() => {
editorReady.current = true;
builderRef.current = builder;
lastEmittedValue.current = value; // Initialize with current value
});
// Listen to updates via mutation observer
const handleUpdate = () => {
if (isUpdatingFromOutside.current || !editorReady.current) return;
clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
if (!builderRef.current || !editorReady.current) return;
try {
const markdown = builderRef.current.getMarkdown();
// Only call onChange if content actually changed
if (markdown !== lastEmittedValue.current) {
lastEmittedValue.current = markdown;
onChangeRef.current(markdown);
}
} catch (error) {
console.error('Error getting markdown:', error);
}
}, 300);
};
const observer = new MutationObserver(handleUpdate);
observer.observe(ref.current, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
observer.disconnect();
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
editorReady.current = false;
builderRef.current?.destroy();
builderRef.current = null;
};
}, []); // Only run once on mount
// Effect for handling external value changes
useEffect(() => {
if (!builderRef.current || !editorReady.current) return;
try {
const editorContent = builderRef.current.getMarkdown();
// Only update if value is different from both editor and last emitted value
if (value !== editorContent && value !== lastEmittedValue.current) {
isUpdatingFromOutside.current = true;
// Note: Builder doesn't have setMarkdown, need to use internal methods or recreate if drastic
// But for Crepe, we might need a way to set content.
// Just updating the ref is not enough if the editor doesn't update.
// However, the original code didn't actually implementing 'setMarkdown' logic for Crepe properly
// beyond setting isUpdatingFromOutside to true...
// Wait, looking at original code:
// "Note: Builder doesn't have setMarkdown, need to recreate or use editor directly"
// It seems the original code FAILED to actually update the editor content visually?
// Ah, lines 186-206 in original file.
// It sets isUpdatingFromOutside = true, updates lastEmittedValue.
// But it DOES NOT actually update the editor content. This looks like a bug in the original code?
// Or maybe Crepe updates automatically? No.
// Let's keep strict parity with original code first.
lastEmittedValue.current = value;
// Allow DOM to update
setTimeout(() => {
isUpdatingFromOutside.current = false;
}, 150);
}
} catch (error) {
console.error('Error syncing external value:', error);
}
}, [value]);
return (
<div
ref={ref}
className={`milkdown-editor p-3 min-h-[120px] prose prose-sm max-w-none dark:prose-invert ${className || ''}`}
/>
);
};
export default MilkdownEditorInternal;

View File

@ -0,0 +1,20 @@
import React from 'react';
import LogViewer from '@/components/LogViewer';
import { T } from '@/i18n';
import ConnectionManager from '@/components/ConnectionManager';
const LogViewerPage: React.FC = () => {
return (
<div className="p-4 bg-background text-foreground min-h-screen flex flex-col">
<div className="flex justify-between items-start mb-4 flex-shrink-0">
<h1 className="text-2xl font-bold">
<T>Log Viewer</T>
</h1>
<ConnectionManager />
</div>
<LogViewer />
</div>
);
};
export default LogViewerPage;

View File

@ -0,0 +1,75 @@
import React, { useState } from 'react';
import LogViewer from '@/components/LogViewer';
import { useServerLogs } from '@/hooks/useServerLogs';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Wifi, Activity, Image as ImageIcon, Laptop } from 'lucide-react';
const ServerLogView = ({ source }: { source: 'system' | 'images' | 'videos' }) => {
const { logs, isConnected, clearLogs } = useServerLogs(source);
return (
<div className="h-full">
<LogViewer
logs={logs}
clearLogs={clearLogs}
title={`${source.charAt(0).toUpperCase() + source.slice(1)} Logs`}
sourceInfo={
<Badge variant={isConnected ? "default" : "destructive"} className="ml-2 text-xs">
{isConnected ? <Wifi className="w-3 h-3 mr-1" /> : <Wifi className="w-3 h-3 mr-1 text-muted-foreground" />}
{isConnected ? 'LIVE' : 'DISCONNECTED'}
</Badge>
}
/>
</div>
);
};
const LogsPage = () => {
const [currentTab, setCurrentTab] = useState('client');
return (
<div className="container mx-auto p-4 h-[calc(100vh-4rem)] flex flex-col">
<div className="mb-4">
<h1 className="text-2xl font-bold mb-2">System Logs</h1>
</div>
<Tabs value={currentTab} onValueChange={setCurrentTab} className="flex flex-col flex-1 min-h-0">
<TabsList className="w-full justify-start">
<TabsTrigger value="client" className="flex items-center gap-2">
<Laptop className="w-4 h-4" /> Client
</TabsTrigger>
<TabsTrigger value="system" className="flex items-center gap-2">
<Activity className="w-4 h-4" /> System
</TabsTrigger>
<TabsTrigger value="images" className="flex items-center gap-2">
<ImageIcon className="w-4 h-4" /> Images Product
</TabsTrigger>
<TabsTrigger value="videos" className="flex items-center gap-2">
<ImageIcon className="w-4 h-4" /> Videos Product
</TabsTrigger>
</TabsList>
<TabsContent value="client" className="flex-1 mt-2 min-h-0">
<div className="h-full">
<LogViewer title="Client Activity Logs" />
</div>
</TabsContent>
<TabsContent value="system" className="flex-1 mt-2 min-h-0">
<ServerLogView source="system" />
</TabsContent>
<TabsContent value="images" className="flex-1 mt-2 min-h-0">
<ServerLogView source="images" />
</TabsContent>
<TabsContent value="videos" className="flex-1 mt-2 min-h-0">
<ServerLogView source="videos" />
</TabsContent>
</Tabs>
</div>
);
};
export default LogsPage;

Some files were not shown because too many files have changed in this diff Show More