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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ dist-ssr
*.sln
*.sw?
.env

# Development files
.claude/
*.tmp
*.temp
.scratch/
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Header } from './components/Header';
import { PromptComposer } from './components/PromptComposer';
import { ImageCanvas } from './components/ImageCanvas';
import { HistoryPanel } from './components/HistoryPanel';
import { NotificationContainer } from './components/NotificationContainer';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { useAppStore } from './store/useAppStore';

Expand Down Expand Up @@ -52,6 +53,7 @@ function AppContent() {
<HistoryPanel />
</div>
</div>
<NotificationContainer />
</div>
);
}
Expand Down
13 changes: 12 additions & 1 deletion src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import React, { useState } from 'react';
import { Button } from './ui/Button';
import { HelpCircle } from 'lucide-react';
import { HelpCircle, Settings } from 'lucide-react';
import { InfoModal } from './InfoModal';
import { SettingsModal } from './SettingsModal';
import { useAppStore } from '../store/useAppStore';

export const Header: React.FC = () => {
const [showInfoModal, setShowInfoModal] = useState(false);
const { showSettings, setShowSettings } = useAppStore();

return (
<>
Expand All @@ -25,6 +28,13 @@ export const Header: React.FC = () => {
</div>

<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="icon"
onClick={() => setShowSettings(true)}
>
<Settings className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
Expand All @@ -36,6 +46,7 @@ export const Header: React.FC = () => {
</header>

<InfoModal open={showInfoModal} onOpenChange={setShowInfoModal} />
<SettingsModal open={showSettings} onOpenChange={setShowSettings} />
</>
);
};
23 changes: 23 additions & 0 deletions src/components/NotificationContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { Toast } from './ui/Toast';
import { useNotificationStore } from '../store/useNotificationStore';

export const NotificationContainer: React.FC = () => {
const { notifications, removeNotification } = useNotificationStore();

if (notifications.length === 0) {
return null;
}

return (
<div className="fixed top-4 right-4 z-50 space-y-3 pointer-events-none">
{notifications.map((notification) => (
<Toast
key={notification.id}
notification={notification}
onClose={removeNotification}
/>
))}
</div>
);
};
182 changes: 182 additions & 0 deletions src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import React, { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { X, Eye, EyeOff, Check, Loader2 } from 'lucide-react';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { useAppStore } from '../store/useAppStore';
import { useNotifications } from '../hooks/useNotifications';
import { geminiService } from '../services/geminiService';

interface SettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}

export const SettingsModal: React.FC<SettingsModalProps> = ({ open, onOpenChange }) => {
const { apiKey, setApiKey } = useAppStore();
const { showSuccess, showError } = useNotifications();

const [inputKey, setInputKey] = useState(apiKey || '');
const [showKey, setShowKey] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [isTestingKey, setIsTestingKey] = useState(false);

const handleSave = async () => {
const trimmedKey = inputKey.trim();

if (!trimmedKey) {
setApiKey(null);
showSuccess('Settings saved', 'API key cleared. Using environment variable if available.');
onOpenChange(false);
return;
}

setIsValidating(true);
try {
const validation = await geminiService.validateApiKey(trimmedKey);

if (validation.valid) {
setApiKey(trimmedKey);
showSuccess('Settings saved', 'API key is valid and has been saved.');
onOpenChange(false);
} else {
showError('Invalid API key', validation.error?.userMessage || 'Please check your API key.');
}
} catch (error) {
showError('Validation failed', 'Unable to validate API key. It will be saved anyway.');
setApiKey(trimmedKey);
onOpenChange(false);
} finally {
setIsValidating(false);
}
};

const handleTestKey = async () => {
const trimmedKey = inputKey.trim();
if (!trimmedKey) {
showError('No API key', 'Please enter an API key to test.');
return;
}

setIsTestingKey(true);
try {
const validation = await geminiService.validateApiKey(trimmedKey);

if (validation.valid) {
showSuccess('API key is valid', 'Your API key works correctly with Gemini API.');
} else {
showError('Invalid API key', validation.error?.userMessage || 'Please check your API key.');
}
} catch (error) {
showError('Test failed', 'Unable to test API key. Please check your connection.');
} finally {
setIsTestingKey(false);
}
};

const handleCancel = () => {
setInputKey(apiKey || '');
onOpenChange(false);
};

return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-gray-900 border border-gray-700 rounded-lg p-6 w-full max-w-md z-50">
<div className="flex items-center justify-between mb-6">
<Dialog.Title className="text-lg font-semibold text-gray-100">
Settings
</Dialog.Title>
<Dialog.Close asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<X className="h-4 w-4" />
</Button>
</Dialog.Close>
</div>

<div className="space-y-4">
<div>
<label htmlFor="api-key" className="block text-sm font-medium text-gray-300 mb-2">
Gemini API Key
</label>
<p className="text-xs text-gray-500 mb-3">
Get your API key from{' '}
<a
href="https://aistudio.google.com/"
target="_blank"
rel="noopener noreferrer"
className="text-yellow-400 hover:text-yellow-300 underline"
>
Google AI Studio
</a>
</p>

<div className="relative">
<Input
id="api-key"
type={showKey ? 'text' : 'password'}
value={inputKey}
onChange={(e) => setInputKey(e.target.value)}
placeholder="Enter your Gemini API key..."
className="pr-10"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-300"
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>

<div className="flex space-x-2 mt-3">
<Button
variant="outline"
size="sm"
onClick={handleTestKey}
disabled={isTestingKey || !inputKey.trim()}
>
{isTestingKey ? (
<>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
Testing...
</>
) : (
'Test Key'
)}
</Button>
</div>
</div>

<div className="flex justify-end space-x-2 pt-4 border-t border-gray-800">
<Button
variant="outline"
onClick={handleCancel}
disabled={isValidating}
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={isValidating}
>
{isValidating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Validating...
</>
) : (
<>
<Check className="h-4 w-4 mr-2" />
Save
</>
)}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};
75 changes: 75 additions & 0 deletions src/components/ui/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { useEffect } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react';
import { cn } from '../../utils/cn';
import { Notification } from '../../store/useNotificationStore';

const toastVariants = cva(
'pointer-events-auto w-full max-w-sm rounded-lg border p-4 shadow-lg transition-all duration-300 ease-in-out',
{
variants: {
variant: {
success: 'bg-green-900/90 border-green-700 text-green-100',
error: 'bg-red-900/90 border-red-700 text-red-100',
warning: 'bg-yellow-900/90 border-yellow-700 text-yellow-100',
info: 'bg-gray-900/90 border-gray-700 text-gray-100',
}
},
defaultVariants: {
variant: 'info'
}
}
);

const iconMap = {
success: CheckCircle,
error: AlertCircle,
warning: AlertTriangle,
info: Info
};

const iconColorMap = {
success: 'text-green-400',
error: 'text-red-400',
warning: 'text-yellow-400',
info: 'text-gray-400'
};

interface ToastProps extends VariantProps<typeof toastVariants> {
notification: Notification;
onClose: (id: string) => void;
}

export const Toast: React.FC<ToastProps> = ({ notification, onClose }) => {
const Icon = iconMap[notification.type];

useEffect(() => {
if (notification.duration && notification.duration > 0) {
const timer = setTimeout(() => {
onClose(notification.id);
}, notification.duration);

return () => clearTimeout(timer);
}
}, [notification.duration, notification.id, onClose]);

return (
<div className={cn(toastVariants({ variant: notification.type }))}>
<div className="flex items-start space-x-3">
<Icon className={cn('h-5 w-5 flex-shrink-0 mt-0.5', iconColorMap[notification.type])} />
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold">{notification.title}</h4>
{notification.message && (
<p className="mt-1 text-sm opacity-90">{notification.message}</p>
)}
</div>
<button
onClick={() => onClose(notification.id)}
className="flex-shrink-0 rounded-lg p-1 hover:bg-black/20 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
};
Loading