🔧 Authentication Fix:
- Change localStorage.getItem('token') to localStorage.getItem('tcg_vault_token')
- Match the token key used in AuthContext ('tcg_vault_token')
- Fix both loadPreferences and savePreferences functions
This resolves the 'Invalid or expired token' error when accessing settings.
The token was being stored correctly but retrieved with wrong key.
496 lines
No EOL
19 KiB
TypeScript
496 lines
No EOL
19 KiB
TypeScript
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();
|
|
}, [user]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const loadPreferences = async () => {
|
|
if (!user) return;
|
|
|
|
try {
|
|
const token = localStorage.getItem('tcg_vault_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('tcg_vault_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;
|