2025-07-22 11:53:44 -04:00
|
|
|
import React, { useState, useEffect } from 'react';
|
|
|
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
|
|
|
|
|
|
|
interface UserPreferences {
|
|
|
|
|
defaultView: string;
|
|
|
|
|
itemsPerPage: number;
|
|
|
|
|
enableAnimations: boolean;
|
|
|
|
|
enableOcr: boolean;
|
|
|
|
|
theme: string;
|
|
|
|
|
privacySettings: {
|
|
|
|
|
collections_public: boolean;
|
|
|
|
|
decks_public: boolean;
|
|
|
|
|
};
|
|
|
|
|
ocrSettings: {
|
|
|
|
|
preferred_service: 'openai' | 'ollama';
|
|
|
|
|
openai_api_key: string;
|
|
|
|
|
ollama_url: string;
|
|
|
|
|
auto_add_to_collection: boolean;
|
|
|
|
|
confidence_threshold: number;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const Settings: React.FC = () => {
|
|
|
|
|
const { user } = useAuth();
|
|
|
|
|
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
|
|
|
const [activeTab, setActiveTab] = useState('general');
|
|
|
|
|
const [isTestingOpenAI, setIsTestingOpenAI] = useState(false);
|
|
|
|
|
const [isTestingOllama, setIsTestingOllama] = useState(false);
|
|
|
|
|
const [testResults, setTestResults] = useState<{ openai?: string; ollama?: string }>({});
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadPreferences();
|
2025-07-22 11:56:54 -04:00
|
|
|
}, [user]); // eslint-disable-line react-hooks/exhaustive-deps
|
2025-07-22 11:53:44 -04:00
|
|
|
|
|
|
|
|
const loadPreferences = async () => {
|
|
|
|
|
if (!user) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const token = localStorage.getItem('token');
|
|
|
|
|
const response = await fetch('/api/user/preferences', {
|
|
|
|
|
headers: {
|
|
|
|
|
'Authorization': `Bearer ${token}`,
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
setPreferences(data.preferences);
|
|
|
|
|
} else {
|
|
|
|
|
setMessage({ type: 'error', text: 'Failed to load preferences' });
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error loading preferences:', error);
|
|
|
|
|
setMessage({ type: 'error', text: 'Error loading preferences' });
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const savePreferences = async () => {
|
|
|
|
|
if (!preferences) return;
|
|
|
|
|
|
|
|
|
|
setIsSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const token = localStorage.getItem('token');
|
|
|
|
|
const response = await fetch('/api/user/preferences', {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
headers: {
|
|
|
|
|
'Authorization': `Bearer ${token}`,
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify(preferences)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
setMessage({ type: 'success', text: 'Settings saved successfully!' });
|
|
|
|
|
setPreferences(data.preferences);
|
|
|
|
|
} else {
|
|
|
|
|
setMessage({ type: 'error', text: data.error || 'Failed to save settings' });
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error saving preferences:', error);
|
|
|
|
|
setMessage({ type: 'error', text: 'Error saving settings' });
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const testOpenAI = async () => {
|
|
|
|
|
if (!preferences?.ocrSettings.openai_api_key) {
|
|
|
|
|
setTestResults(prev => ({ ...prev, openai: '❌ API key required' }));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsTestingOpenAI(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('https://api.openai.com/v1/models', {
|
|
|
|
|
headers: {
|
|
|
|
|
'Authorization': `Bearer ${preferences.ocrSettings.openai_api_key}`,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
setTestResults(prev => ({ ...prev, openai: '✅ API key valid' }));
|
|
|
|
|
} else {
|
|
|
|
|
setTestResults(prev => ({ ...prev, openai: `❌ API error: ${response.status}` }));
|
|
|
|
|
}
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
setTestResults(prev => ({ ...prev, openai: `❌ Connection failed: ${error.message}` }));
|
|
|
|
|
} finally {
|
|
|
|
|
setIsTestingOpenAI(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const testOllama = async () => {
|
|
|
|
|
if (!preferences?.ocrSettings.ollama_url) return;
|
|
|
|
|
|
|
|
|
|
setIsTestingOllama(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${preferences.ocrSettings.ollama_url}/api/tags`);
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
const hasVisionModel = data.models?.some((model: any) =>
|
|
|
|
|
model.name.includes('llava') || model.name.includes('vision')
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (hasVisionModel) {
|
|
|
|
|
setTestResults(prev => ({ ...prev, ollama: '✅ Ollama with vision models available' }));
|
|
|
|
|
} else {
|
|
|
|
|
setTestResults(prev => ({ ...prev, ollama: '⚠️ Ollama running but no vision models found' }));
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
setTestResults(prev => ({ ...prev, ollama: `❌ Ollama error: ${response.status}` }));
|
|
|
|
|
}
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
setTestResults(prev => ({ ...prev, ollama: `❌ Cannot reach Ollama: ${error.message}` }));
|
|
|
|
|
} finally {
|
|
|
|
|
setIsTestingOllama(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updatePreference = (key: keyof UserPreferences, value: any) => {
|
|
|
|
|
if (!preferences) return;
|
|
|
|
|
setPreferences({ ...preferences, [key]: value });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateOcrSetting = (key: keyof UserPreferences['ocrSettings'], value: any) => {
|
|
|
|
|
if (!preferences) return;
|
|
|
|
|
setPreferences({
|
|
|
|
|
...preferences,
|
|
|
|
|
ocrSettings: { ...preferences.ocrSettings, [key]: value }
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
|
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!preferences) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="text-center py-8">
|
|
|
|
|
<p className="text-red-600">Failed to load preferences</p>
|
|
|
|
|
<button onClick={loadPreferences} className="mt-4 text-indigo-600 hover:underline">
|
|
|
|
|
Try Again
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="max-w-4xl mx-auto p-6">
|
|
|
|
|
<div className="mb-8">
|
|
|
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Settings</h1>
|
|
|
|
|
<p className="text-gray-600">Manage your account preferences and OCR configuration</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Message Display */}
|
|
|
|
|
{message && (
|
|
|
|
|
<div className={`mb-6 p-4 rounded-lg ${
|
|
|
|
|
message.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' :
|
|
|
|
|
'bg-red-50 text-red-800 border border-red-200'
|
|
|
|
|
}`}>
|
|
|
|
|
{message.text}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Tab Navigation */}
|
|
|
|
|
<div className="border-b border-gray-200 mb-6">
|
|
|
|
|
<nav className="-mb-px flex space-x-8">
|
|
|
|
|
{[
|
|
|
|
|
{ id: 'general', label: 'General', icon: '⚙️' },
|
|
|
|
|
{ id: 'ocr', label: 'AI OCR', icon: '🤖' },
|
|
|
|
|
{ id: 'privacy', label: 'Privacy', icon: '🔒' }
|
|
|
|
|
].map(tab => (
|
|
|
|
|
<button
|
|
|
|
|
key={tab.id}
|
|
|
|
|
onClick={() => setActiveTab(tab.id)}
|
|
|
|
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
|
|
|
|
activeTab === tab.id
|
|
|
|
|
? 'border-indigo-500 text-indigo-600'
|
|
|
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<span className="mr-2">{tab.icon}</span>
|
|
|
|
|
{tab.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</nav>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Tab Content */}
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{activeTab === 'general' && (
|
|
|
|
|
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
|
|
|
|
<h3 className="text-lg font-medium mb-4">General Preferences</h3>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
|
|
|
Default View
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
value={preferences.defaultView}
|
|
|
|
|
onChange={(e) => updatePreference('defaultView', e.target.value)}
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
|
|
|
|
|
>
|
|
|
|
|
<option value="card">Card View</option>
|
|
|
|
|
<option value="table">Table View</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
|
|
|
Items per Page
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
value={preferences.itemsPerPage}
|
|
|
|
|
onChange={(e) => updatePreference('itemsPerPage', parseInt(e.target.value))}
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
|
|
|
|
|
>
|
|
|
|
|
<option value={10}>10</option>
|
|
|
|
|
<option value={20}>20</option>
|
|
|
|
|
<option value={50}>50</option>
|
|
|
|
|
<option value={100}>100</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
|
|
|
Theme
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
value={preferences.theme}
|
|
|
|
|
onChange={(e) => updatePreference('theme', e.target.value)}
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
|
|
|
|
|
>
|
|
|
|
|
<option value="light">Light</option>
|
|
|
|
|
<option value="dark">Dark</option>
|
|
|
|
|
<option value="system">System</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<label className="flex items-center">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={preferences.enableAnimations}
|
|
|
|
|
onChange={(e) => updatePreference('enableAnimations', e.target.checked)}
|
|
|
|
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
|
|
|
/>
|
|
|
|
|
<span className="ml-2 text-sm text-gray-700">Enable animations</span>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<label className="flex items-center">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={preferences.enableOcr}
|
|
|
|
|
onChange={(e) => updatePreference('enableOcr', e.target.checked)}
|
|
|
|
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
|
|
|
/>
|
|
|
|
|
<span className="ml-2 text-sm text-gray-700">Enable OCR scanning</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{activeTab === 'ocr' && (
|
|
|
|
|
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
|
|
|
|
<h3 className="text-lg font-medium mb-4">AI OCR Configuration</h3>
|
|
|
|
|
|
|
|
|
|
{/* Service Selection */}
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
|
|
|
Preferred OCR Service
|
|
|
|
|
</label>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<label className="flex items-center">
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
value="openai"
|
|
|
|
|
checked={preferences.ocrSettings.preferred_service === 'openai'}
|
|
|
|
|
onChange={(e) => updateOcrSetting('preferred_service', e.target.value as 'openai')}
|
|
|
|
|
className="text-indigo-600 focus:ring-indigo-500"
|
|
|
|
|
/>
|
|
|
|
|
<span className="ml-2 text-sm text-gray-700">OpenAI Vision API (Recommended)</span>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="flex items-center">
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
value="ollama"
|
|
|
|
|
checked={preferences.ocrSettings.preferred_service === 'ollama'}
|
|
|
|
|
onChange={(e) => updateOcrSetting('preferred_service', e.target.value as 'ollama')}
|
|
|
|
|
className="text-indigo-600 focus:ring-indigo-500"
|
|
|
|
|
/>
|
|
|
|
|
<span className="ml-2 text-sm text-gray-700">Ollama (Local/Private)</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* OpenAI Settings */}
|
|
|
|
|
<div className="border rounded-lg p-4 mb-6">
|
|
|
|
|
<h4 className="font-medium text-gray-900 mb-3">OpenAI Configuration</h4>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
|
|
|
API Key
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="password"
|
|
|
|
|
value={preferences.ocrSettings.openai_api_key}
|
|
|
|
|
onChange={(e) => updateOcrSetting('openai_api_key', e.target.value)}
|
|
|
|
|
placeholder="sk-..."
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
|
|
|
Get your API key from{' '}
|
|
|
|
|
<a
|
|
|
|
|
href="https://platform.openai.com/api-keys"
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="text-indigo-600 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
OpenAI Platform
|
|
|
|
|
</a>
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={testOpenAI}
|
|
|
|
|
disabled={isTestingOpenAI || !preferences.ocrSettings.openai_api_key}
|
|
|
|
|
className="bg-blue-600 text-white px-3 py-1 rounded text-sm disabled:bg-gray-400 hover:bg-blue-700"
|
|
|
|
|
>
|
|
|
|
|
{isTestingOpenAI ? 'Testing...' : 'Test API Key'}
|
|
|
|
|
</button>
|
|
|
|
|
{testResults.openai && (
|
|
|
|
|
<span className="text-sm">{testResults.openai}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Ollama Settings */}
|
|
|
|
|
<div className="border rounded-lg p-4 mb-6">
|
|
|
|
|
<h4 className="font-medium text-gray-900 mb-3">Ollama Configuration</h4>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
|
|
|
Ollama URL
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={preferences.ocrSettings.ollama_url}
|
|
|
|
|
onChange={(e) => updateOcrSetting('ollama_url', e.target.value)}
|
|
|
|
|
placeholder="http://localhost:11434"
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
|
|
|
Requires LLaVA or similar vision model:{' '}
|
|
|
|
|
<code className="bg-gray-100 px-1 rounded">ollama pull llava</code>
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={testOllama}
|
|
|
|
|
disabled={isTestingOllama}
|
|
|
|
|
className="bg-green-600 text-white px-3 py-1 rounded text-sm disabled:bg-gray-400 hover:bg-green-700"
|
|
|
|
|
>
|
|
|
|
|
{isTestingOllama ? 'Testing...' : 'Test Ollama'}
|
|
|
|
|
</button>
|
|
|
|
|
{testResults.ollama && (
|
|
|
|
|
<span className="text-sm">{testResults.ollama}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* OCR Options */}
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
|
|
|
Confidence Threshold ({preferences.ocrSettings.confidence_threshold}%)
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min="0"
|
|
|
|
|
max="100"
|
|
|
|
|
value={preferences.ocrSettings.confidence_threshold}
|
|
|
|
|
onChange={(e) => updateOcrSetting('confidence_threshold', parseInt(e.target.value))}
|
|
|
|
|
className="w-full"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
|
|
|
Minimum confidence required to accept OCR results
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<label className="flex items-center">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={preferences.ocrSettings.auto_add_to_collection}
|
|
|
|
|
onChange={(e) => updateOcrSetting('auto_add_to_collection', e.target.checked)}
|
|
|
|
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
|
|
|
/>
|
|
|
|
|
<span className="ml-2 text-sm text-gray-700">
|
|
|
|
|
Automatically add scanned cards to selected collection
|
|
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{activeTab === 'privacy' && (
|
|
|
|
|
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
|
|
|
|
<h3 className="text-lg font-medium mb-4">Privacy Settings</h3>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<label className="flex items-center">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={preferences.privacySettings.collections_public}
|
|
|
|
|
onChange={(e) => updatePreference('privacySettings', {
|
|
|
|
|
...preferences.privacySettings,
|
|
|
|
|
collections_public: e.target.checked
|
|
|
|
|
})}
|
|
|
|
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
|
|
|
/>
|
|
|
|
|
<span className="ml-2 text-sm text-gray-700">
|
|
|
|
|
Make collections public by default
|
|
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<label className="flex items-center">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={preferences.privacySettings.decks_public}
|
|
|
|
|
onChange={(e) => updatePreference('privacySettings', {
|
|
|
|
|
...preferences.privacySettings,
|
|
|
|
|
decks_public: e.target.checked
|
|
|
|
|
})}
|
|
|
|
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
|
|
|
/>
|
|
|
|
|
<span className="ml-2 text-sm text-gray-700">
|
|
|
|
|
Make decks public by default
|
|
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Save Button */}
|
|
|
|
|
<div className="mt-8 flex justify-end">
|
|
|
|
|
<button
|
|
|
|
|
onClick={savePreferences}
|
|
|
|
|
disabled={isSaving}
|
|
|
|
|
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 disabled:bg-gray-400 flex items-center gap-2"
|
|
|
|
|
>
|
|
|
|
|
{isSaving && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>}
|
|
|
|
|
{isSaving ? 'Saving...' : 'Save Settings'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default Settings;
|