Move OCR settings to comprehensive user settings system
🏗️ User Settings System: - Create comprehensive Settings page with tabbed interface - Move OCR configuration from modal to dedicated settings tab - Add API endpoints for user preferences (GET/PUT) - Store settings in database user_preferences table with ocr_settings column - Add Settings link to navbar user dropdown ⚙️ Settings Features: - General tab: default view, items per page, theme, animations - AI OCR tab: service selection, API keys, confidence threshold - Privacy tab: public collections/decks defaults - Real-time API key testing for OpenAI and Ollama - Persistent storage in database instead of localStorage 🔄 Integration: - Remove old OCRSettings modal from Scanner page - Add Settings route to App.tsx routing - Update navbar with Settings link in user dropdown - Prepare for AI OCR service to read from user preferences Next: Update AI OCR service to use saved user preferences instead of localStorage
This commit is contained in:
parent
e86782335e
commit
7463551065
6 changed files with 781 additions and 12 deletions
65
api/admin/update-preferences-schema.js
Normal file
65
api/admin/update-preferences-schema.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
|
||||
});
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('Updating user_preferences schema to add OCR settings...');
|
||||
|
||||
// Check if ocr_settings column already exists
|
||||
const columnCheck = await client.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'user_preferences'
|
||||
AND column_name = 'ocr_settings'
|
||||
);
|
||||
`);
|
||||
|
||||
if (columnCheck.rows[0].exists) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'OCR settings column already exists',
|
||||
already_updated: true
|
||||
});
|
||||
}
|
||||
|
||||
// Add ocr_settings column
|
||||
await client.query(`
|
||||
ALTER TABLE user_preferences
|
||||
ADD COLUMN ocr_settings JSONB DEFAULT '{
|
||||
"preferred_service": "openai",
|
||||
"openai_api_key": "",
|
||||
"ollama_url": "http://localhost:11434",
|
||||
"auto_add_to_collection": false,
|
||||
"confidence_threshold": 80
|
||||
}'::jsonb;
|
||||
`);
|
||||
|
||||
console.log('✅ OCR settings column added to user_preferences table');
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'User preferences schema updated successfully with OCR settings'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Schema update failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Schema update failed',
|
||||
details: error.message
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
202
api/user/preferences.js
Normal file
202
api/user/preferences.js
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
const { Pool } = require('pg');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
|
||||
});
|
||||
|
||||
// Verify JWT token and extract user info
|
||||
function verifyAuth(req) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new Error('No authorization token provided');
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production';
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, jwtSecret);
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
throw new Error('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const user = verifyAuth(req);
|
||||
|
||||
if (req.method === 'GET') {
|
||||
// Get user preferences
|
||||
const result = await client.query(`
|
||||
SELECT
|
||||
default_view,
|
||||
items_per_page,
|
||||
enable_animations,
|
||||
enable_ocr,
|
||||
theme,
|
||||
privacy_settings,
|
||||
ocr_settings,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM user_preferences
|
||||
WHERE user_id = $1
|
||||
`, [user.userId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
// Create default preferences if none exist
|
||||
const defaultPrefs = {
|
||||
default_view: 'card',
|
||||
items_per_page: 20,
|
||||
enable_animations: true,
|
||||
enable_ocr: true,
|
||||
theme: 'light',
|
||||
privacy_settings: { collections_public: false, decks_public: false },
|
||||
ocr_settings: {
|
||||
preferred_service: 'openai',
|
||||
openai_api_key: '',
|
||||
ollama_url: 'http://localhost:11434',
|
||||
auto_add_to_collection: false,
|
||||
confidence_threshold: 80
|
||||
}
|
||||
};
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO user_preferences (
|
||||
user_id, default_view, items_per_page, enable_animations,
|
||||
enable_ocr, theme, privacy_settings, ocr_settings
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, [
|
||||
user.userId,
|
||||
defaultPrefs.default_view,
|
||||
defaultPrefs.items_per_page,
|
||||
defaultPrefs.enable_animations,
|
||||
defaultPrefs.enable_ocr,
|
||||
defaultPrefs.theme,
|
||||
JSON.stringify(defaultPrefs.privacy_settings),
|
||||
JSON.stringify(defaultPrefs.ocr_settings)
|
||||
]);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
preferences: defaultPrefs
|
||||
});
|
||||
}
|
||||
|
||||
const preferences = result.rows[0];
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
preferences: {
|
||||
defaultView: preferences.default_view,
|
||||
itemsPerPage: preferences.items_per_page,
|
||||
enableAnimations: preferences.enable_animations,
|
||||
enableOcr: preferences.enable_ocr,
|
||||
theme: preferences.theme,
|
||||
privacySettings: preferences.privacy_settings,
|
||||
ocrSettings: preferences.ocr_settings || {
|
||||
preferred_service: 'openai',
|
||||
openai_api_key: '',
|
||||
ollama_url: 'http://localhost:11434',
|
||||
auto_add_to_collection: false,
|
||||
confidence_threshold: 80
|
||||
},
|
||||
createdAt: preferences.created_at,
|
||||
updatedAt: preferences.updated_at
|
||||
}
|
||||
});
|
||||
|
||||
} else if (req.method === 'PUT') {
|
||||
// Update user preferences
|
||||
const {
|
||||
defaultView,
|
||||
itemsPerPage,
|
||||
enableAnimations,
|
||||
enableOcr,
|
||||
theme,
|
||||
privacySettings,
|
||||
ocrSettings
|
||||
} = req.body;
|
||||
|
||||
// Validate OCR settings if provided
|
||||
if (ocrSettings) {
|
||||
const allowedServices = ['openai', 'ollama'];
|
||||
if (ocrSettings.preferred_service && !allowedServices.includes(ocrSettings.preferred_service)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid OCR service. Must be "openai" or "ollama"'
|
||||
});
|
||||
}
|
||||
|
||||
if (ocrSettings.confidence_threshold && (ocrSettings.confidence_threshold < 0 || ocrSettings.confidence_threshold > 100)) {
|
||||
return res.status(400).json({
|
||||
error: 'Confidence threshold must be between 0 and 100'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update preferences (upsert)
|
||||
const result = await client.query(`
|
||||
INSERT INTO user_preferences (
|
||||
user_id, default_view, items_per_page, enable_animations,
|
||||
enable_ocr, theme, privacy_settings, ocr_settings, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
default_view = COALESCE($2, user_preferences.default_view),
|
||||
items_per_page = COALESCE($3, user_preferences.items_per_page),
|
||||
enable_animations = COALESCE($4, user_preferences.enable_animations),
|
||||
enable_ocr = COALESCE($5, user_preferences.enable_ocr),
|
||||
theme = COALESCE($6, user_preferences.theme),
|
||||
privacy_settings = COALESCE($7, user_preferences.privacy_settings),
|
||||
ocr_settings = COALESCE($8, user_preferences.ocr_settings),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING *
|
||||
`, [
|
||||
user.userId,
|
||||
defaultView,
|
||||
itemsPerPage,
|
||||
enableAnimations,
|
||||
enableOcr,
|
||||
theme,
|
||||
privacySettings ? JSON.stringify(privacySettings) : null,
|
||||
ocrSettings ? JSON.stringify(ocrSettings) : null
|
||||
]);
|
||||
|
||||
const preferences = result.rows[0];
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Preferences updated successfully',
|
||||
preferences: {
|
||||
defaultView: preferences.default_view,
|
||||
itemsPerPage: preferences.items_per_page,
|
||||
enableAnimations: preferences.enable_animations,
|
||||
enableOcr: preferences.enable_ocr,
|
||||
theme: preferences.theme,
|
||||
privacySettings: preferences.privacy_settings,
|
||||
ocrSettings: preferences.ocr_settings,
|
||||
updatedAt: preferences.updated_at
|
||||
}
|
||||
});
|
||||
|
||||
} else {
|
||||
res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('User preferences error:', error);
|
||||
|
||||
if (error.message.includes('authorization') || error.message.includes('token')) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: 'Failed to manage preferences',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import Collections from './pages/Collections';
|
|||
import Decks from './pages/Decks';
|
||||
import Cards from './pages/Cards';
|
||||
import Scanner from './pages/Scanner';
|
||||
import Settings from './pages/Settings';
|
||||
import LoginForm from './components/auth/LoginForm';
|
||||
import RegisterForm from './components/auth/RegisterForm';
|
||||
import AdminPanel from './components/admin/AdminPanel';
|
||||
|
|
@ -132,6 +133,14 @@ function App() {
|
|||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/settings" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Settings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route path="/admin" element={
|
||||
<ProtectedRoute>
|
||||
|
|
|
|||
|
|
@ -133,6 +133,14 @@ const Navbar: React.FC = () => {
|
|||
📊 Dashboard
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
⚙️ Settings
|
||||
</Link>
|
||||
|
||||
{isAdmin() && (
|
||||
<Link
|
||||
to="/admin"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
|
|||
import CameraScanner from '../components/CameraScanner';
|
||||
import GlowingCard from '../components/GlowingCard';
|
||||
import CardImageDisplay from '../components/CardImageDisplay';
|
||||
import OCRSettings from '../components/OCRSettings';
|
||||
import { cardMatcher } from '../services/cardMatcher';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
|
|
@ -34,7 +33,6 @@ const Scanner: React.FC = () => {
|
|||
const [selectedCollectionId, setSelectedCollectionId] = useState<string | null>(null);
|
||||
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
|
||||
const [isAddingToCollection, setIsAddingToCollection] = useState(false);
|
||||
const [showOCRSettings, setShowOCRSettings] = useState(false);
|
||||
|
||||
// Load user's collections on component mount
|
||||
useEffect(() => {
|
||||
|
|
@ -273,12 +271,6 @@ const Scanner: React.FC = () => {
|
|||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Card Scanner</h1>
|
||||
<button
|
||||
onClick={() => setShowOCRSettings(true)}
|
||||
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span>⚙️</span> AI OCR Settings
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Use AI-powered OCR to quickly scan and identify your trading cards
|
||||
|
|
@ -621,10 +613,7 @@ const Scanner: React.FC = () => {
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
{/* OCR Settings Modal */}
|
||||
{showOCRSettings && (
|
||||
<OCRSettings onClose={() => setShowOCRSettings(false)} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
496
src/pages/Settings.tsx
Normal file
496
src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
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]);
|
||||
|
||||
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;
|
||||
Loading…
Reference in a new issue