diff --git a/api/admin/update-preferences-schema.js b/api/admin/update-preferences-schema.js new file mode 100644 index 0000000..e897699 --- /dev/null +++ b/api/admin/update-preferences-schema.js @@ -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(); + } +} \ No newline at end of file diff --git a/api/user/preferences.js b/api/user/preferences.js new file mode 100644 index 0000000..2ec0fa6 --- /dev/null +++ b/api/user/preferences.js @@ -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(); + } +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 4a0726e..b2e6e78 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> + + + + + + } /> + {/* Admin Routes */} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 46b302c..a2c7327 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -132,6 +132,14 @@ const Navbar: React.FC = () => { > 📊 Dashboard + + setIsUserMenuOpen(false)} + className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" + > + ⚙️ Settings + {isAdmin() && ( { const [selectedCollectionId, setSelectedCollectionId] = useState(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 = () => {

Card Scanner

-

Use AI-powered OCR to quickly scan and identify your trading cards @@ -621,10 +613,7 @@ const Scanner: React.FC = () => {

- {/* OCR Settings Modal */} - {showOCRSettings && ( - setShowOCRSettings(false)} /> - )} + ); }; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx new file mode 100644 index 0000000..fb53037 --- /dev/null +++ b/src/pages/Settings.tsx @@ -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(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 ( +
+
+
+ ); + } + + if (!preferences) { + return ( +
+

Failed to load preferences

+ +
+ ); + } + + return ( +
+
+

Settings

+

Manage your account preferences and OCR configuration

+
+ + {/* Message Display */} + {message && ( +
+ {message.text} +
+ )} + + {/* Tab Navigation */} +
+ +
+ + {/* Tab Content */} +
+ {activeTab === 'general' && ( +
+

General Preferences

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+
+ )} + + {activeTab === 'ocr' && ( +
+

AI OCR Configuration

+ + {/* Service Selection */} +
+ +
+ + +
+
+ + {/* OpenAI Settings */} +
+

OpenAI Configuration

+
+
+ + 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" + /> +

+ Get your API key from{' '} + + OpenAI Platform + +

+
+
+ + {testResults.openai && ( + {testResults.openai} + )} +
+
+
+ + {/* Ollama Settings */} +
+

Ollama Configuration

+
+
+ + 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" + /> +

+ Requires LLaVA or similar vision model:{' '} + ollama pull llava +

+
+
+ + {testResults.ollama && ( + {testResults.ollama} + )} +
+
+
+ + {/* OCR Options */} +
+
+ + updateOcrSetting('confidence_threshold', parseInt(e.target.value))} + className="w-full" + /> +

+ Minimum confidence required to accept OCR results +

+
+ + +
+
+ )} + + {activeTab === 'privacy' && ( +
+

Privacy Settings

+ +
+ + + +
+
+ )} +
+ + {/* Save Button */} +
+ +
+
+ ); +}; + +export default Settings; \ No newline at end of file