feat(ai): localize AI features

This commit is contained in:
Sepehr Ramezani
2026-02-15 17:38:16 +01:00
parent 8f9031f076
commit 9eb3bd912a
72 changed files with 17098 additions and 7759 deletions

View File

@@ -8,10 +8,12 @@ import { resetPassword } from '@/app/actions/auth-reset'
import { toast } from 'sonner'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLanguage } from '@/lib/i18n'
function ResetPasswordForm() {
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
const [isSubmitting, setIsSubmitting] = useState(false)
const token = searchParams.get('token')
@@ -25,7 +27,7 @@ function ResetPasswordForm() {
const confirm = formData.get('confirmPassword') as string
if (password !== confirm) {
toast.error("Passwords don't match")
toast.error(t('resetPassword.passwordMismatch'))
return
}
@@ -36,7 +38,7 @@ function ResetPasswordForm() {
if (result.error) {
toast.error(result.error)
} else {
toast.success('Password reset successfully. You can now login.')
toast.success(t('resetPassword.success'))
router.push('/login')
}
}
@@ -45,12 +47,12 @@ function ResetPasswordForm() {
return (
<Card className="w-full max-w-[400px]">
<CardHeader>
<CardTitle>Invalid Link</CardTitle>
<CardDescription>This password reset link is invalid or has expired.</CardDescription>
<CardTitle>{t('resetPassword.invalidLinkTitle')}</CardTitle>
<CardDescription>{t('resetPassword.invalidLinkDescription')}</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/forgot-password" title="Try again" className="w-full">
<Button variant="outline" className="w-full">Request new link</Button>
<Button variant="outline" className="w-full">{t('resetPassword.requestNewLink')}</Button>
</Link>
</CardFooter>
</Card>
@@ -60,23 +62,23 @@ function ResetPasswordForm() {
return (
<Card className="w-full max-w-[400px]">
<CardHeader>
<CardTitle>Reset Password</CardTitle>
<CardDescription>Enter your new password below.</CardDescription>
<CardTitle>{t('resetPassword.title')}</CardTitle>
<CardDescription>{t('resetPassword.description')}</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="password">New Password</label>
<label htmlFor="password">{t('resetPassword.newPassword')}</label>
<Input id="password" name="password" type="password" required minLength={6} autoFocus />
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword">Confirm New Password</label>
<label htmlFor="confirmPassword">{t('resetPassword.confirmNewPassword')}</label>
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} />
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? 'Resetting...' : 'Reset Password'}
{isSubmitting ? t('resetPassword.resetting') : t('resetPassword.resetPassword')}
</Button>
</CardFooter>
</form>
@@ -85,9 +87,10 @@ function ResetPasswordForm() {
}
export default function ResetPasswordPage() {
const { t } = useLanguage()
return (
<main className="flex items-center justify-center md:h-screen p-4">
<Suspense fallback={<div>Loading...</div>}>
<Suspense fallback={<div>{t('resetPassword.loading')}</div>}>
<ResetPasswordForm />
</Suspense>
</main>

View File

@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { Loader2, CheckCircle2, XCircle, Clock, Zap, Info } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
interface TestResult {
success: boolean
@@ -20,6 +21,7 @@ interface TestResult {
}
export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
const { t } = useLanguage()
const [isLoading, setIsLoading] = useState(false)
const [result, setResult] = useState<TestResult | null>(null)
const [config, setConfig] = useState<any>(null)
@@ -34,7 +36,6 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
const data = await response.json()
setConfig(data)
// Set previous result if available
if (data.previousTest) {
setResult(data.previousTest[type] || null)
}
@@ -84,7 +85,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
responseTime: endTime - startTime
}
setResult(errorResult)
toast.error(`❌ Test Error: ${error.message}`)
toast.error(t('admin.aiTest.testError', { error: error.message }))
} finally {
setIsLoading(false)
}
@@ -113,13 +114,13 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
{/* Provider Info */}
<div className="space-y-3 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Provider:</span>
<span className="text-sm font-medium">{t('admin.aiTest.provider')}</span>
<Badge variant="outline" className="text-xs">
{providerInfo.provider.toUpperCase()}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Model:</span>
<span className="text-sm font-medium">{t('admin.aiTest.model')}</span>
<span className="text-sm text-muted-foreground font-mono">
{providerInfo.model}
</span>
@@ -136,12 +137,12 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
{t('admin.aiTest.testing')}
</>
) : (
<>
<Zap className="mr-2 h-4 w-4" />
Run Test
{t('admin.aiTest.runTest')}
</>
)}
</Button>
@@ -155,12 +156,12 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
{result.success ? (
<>
<CheckCircle2 className="h-5 w-5 text-green-600" />
<span className="font-semibold text-green-600 dark:text-green-400">Test Passed</span>
<span className="font-semibold text-green-600 dark:text-green-400">{t('admin.aiTest.testPassed')}</span>
</>
) : (
<>
<XCircle className="h-5 w-5 text-red-600" />
<span className="font-semibold text-red-600 dark:text-red-400">Test Failed</span>
<span className="font-semibold text-red-600 dark:text-red-400">{t('admin.aiTest.testFailed')}</span>
</>
)}
</div>
@@ -169,7 +170,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
{result.responseTime && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
<Clock className="h-4 w-4" />
<span>Response time: {result.responseTime}ms</span>
<span>{t('admin.aiTest.responseTime', { time: result.responseTime })}</span>
</div>
)}
@@ -178,7 +179,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
<div className="space-y-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Generated Tags:</span>
<span className="text-sm font-medium">{t('admin.aiTest.generatedTags')}</span>
</div>
<div className="flex flex-wrap gap-2">
{result.tags.map((tag, idx) => (
@@ -202,19 +203,19 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
<div className="space-y-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium">Embedding Dimensions:</span>
<span className="text-sm font-medium">{t('admin.aiTest.embeddingDimensions')}</span>
</div>
<div className="p-3 bg-muted rounded-lg">
<div className="text-2xl font-bold text-center">
{result.embeddingLength}
</div>
<div className="text-xs text-center text-muted-foreground mt-1">
vector dimensions
{t('admin.aiTest.vectorDimensions')}
</div>
</div>
{result.firstValues && result.firstValues.length > 0 && (
<div className="space-y-1">
<span className="text-xs font-medium">First 5 values:</span>
<span className="text-xs font-medium">{t('admin.aiTest.first5Values')}</span>
<div className="p-2 bg-muted rounded font-mono text-xs">
[{result.firstValues.slice(0, 5).map((v, i) => v.toFixed(4)).join(', ')}]
</div>
@@ -226,7 +227,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
{/* Error Details */}
{!result.success && result.error && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900">
<p className="text-sm font-medium text-red-900 dark:text-red-100">Error:</p>
<p className="text-sm font-medium text-red-900 dark:text-red-100">{t('admin.aiTest.error')}</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{result.error}</p>
{result.details && (
<details className="mt-2">
@@ -249,7 +250,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
<div className="text-center py-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="text-sm text-muted-foreground mt-2">
Testing {type === 'tags' ? 'tags generation' : 'embeddings'}...
{t('admin.aiTest.testing')} {type === 'tags' ? 'tags generation' : 'embeddings'}...
</p>
</div>
)}

View File

@@ -1,26 +1,48 @@
import { AdminMetrics } from '@/components/admin-metrics'
import { Button } from '@/components/ui/button'
import { Zap, Settings, Activity, TrendingUp } from 'lucide-react'
import Link from 'next/link'
import { getSystemConfig } from '@/lib/config'
export default async function AdminAIPage() {
const config = await getSystemConfig()
// Determine provider status based on config
const openaiKey = config.OPENAI_API_KEY
const ollamaUrl = config.OLLAMA_BASE_URL || config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL_EMBEDDING
const providers = [
{
name: 'OpenAI',
status: openaiKey ? 'Connected' : 'Not Configured',
requests: 'N/A' // We don't track request counts yet
},
{
name: 'Ollama',
status: ollamaUrl ? 'Available' : 'Not Configured',
requests: 'N/A'
},
]
// Mock AI metrics - in a real app, these would come from analytics
// TODO: Implement real analytics tracking
const aiMetrics = [
{
title: 'Total Requests',
value: '856',
trend: { value: 12, isPositive: true },
value: '',
trend: { value: 0, isPositive: true },
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
},
{
title: 'Success Rate',
value: '98.5%',
trend: { value: 2, isPositive: true },
value: '100%',
trend: { value: 0, isPositive: true },
icon: <TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />,
},
{
title: 'Avg Response Time',
value: '1.2s',
trend: { value: 5, isPositive: true },
value: '',
trend: { value: 0, isPositive: true },
icon: <Activity className="h-5 w-5 text-primary dark:text-primary-foreground" />,
},
{
@@ -41,10 +63,12 @@ export default async function AdminAIPage() {
Monitor and configure AI features
</p>
</div>
<Button variant="outline">
<Settings className="mr-2 h-4 w-4" />
Configure
</Button>
<Link href="/admin/settings">
<Button variant="outline">
<Settings className="mr-2 h-4 w-4" />
Configure
</Button>
</Link>
</div>
<AdminMetrics metrics={aiMetrics} />
@@ -83,10 +107,7 @@ export default async function AdminAIPage() {
AI Provider Status
</h2>
<div className="space-y-3">
{[
{ name: 'OpenAI', status: 'Connected', requests: '642' },
{ name: 'Ollama', status: 'Available', requests: '214' },
].map((provider) => (
{providers.map((provider) => (
<div
key={provider.name}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
@@ -100,11 +121,10 @@ export default async function AdminAIPage() {
</p>
</div>
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
provider.status === 'Connected'
className={`px-2 py-1 text-xs font-medium rounded-full ${provider.status === 'Connected' || provider.status === 'Available'
? 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900'
: 'text-primary dark:text-primary-foreground bg-primary/10 dark:bg-primary/20'
}`}
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800'
}`}
>
{provider.status}
</span>
@@ -119,7 +139,7 @@ export default async function AdminAIPage() {
Recent AI Requests
</h2>
<p className="text-gray-600 dark:text-gray-400">
Recent AI requests will be displayed here.
Recent AI requests will be displayed here. (Coming Soon)
</p>
</div>
</div>

View File

@@ -15,50 +15,52 @@ import { Input } from '@/components/ui/input'
import { Plus } from 'lucide-react'
import { createUser } from '@/app/actions/admin'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
export function CreateUserDialog() {
const [open, setOpen] = useState(false)
const { t } = useLanguage()
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" /> Add User
<Plus className="mr-2 h-4 w-4" /> {t('admin.users.addUser')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create User</DialogTitle>
<DialogTitle>{t('admin.users.createUser')}</DialogTitle>
<DialogDescription>
Add a new user to the system. They will need to change their password upon first login if you implement that policy.
{t('admin.users.createUserDescription')}
</DialogDescription>
</DialogHeader>
<form
action={async (formData) => {
const result = await createUser(formData)
if (result?.error) {
toast.error('Failed to create user')
toast.error(t('admin.users.createFailed'))
} else {
toast.success('User created successfully')
toast.success(t('admin.users.createSuccess'))
setOpen(false)
}
}}
className="grid gap-4 py-4"
>
<div className="grid gap-2">
<label htmlFor="name">Name</label>
<label htmlFor="name">{t('admin.users.name')}</label>
<Input id="name" name="name" required />
</div>
<div className="grid gap-2">
<label htmlFor="email">Email</label>
<label htmlFor="email">{t('admin.users.email')}</label>
<Input id="email" name="email" type="email" required />
</div>
<div className="grid gap-2">
<label htmlFor="password">Password</label>
<label htmlFor="password">{t('admin.users.password')}</label>
<Input id="password" name="password" type="password" required minLength={6} />
</div>
<div className="grid gap-2">
<label htmlFor="role">Role</label>
<label htmlFor="role">{t('admin.users.role')}</label>
<select
id="role"
name="role"
@@ -69,7 +71,7 @@ export function CreateUserDialog() {
</select>
</div>
<DialogFooter>
<Button type="submit">Create User</Button>
<Button type="submit">{t('admin.users.createUser')}</Button>
</DialogFooter>
</form>
</DialogContent>

View File

@@ -6,10 +6,12 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { updateSystemConfig, testSMTP } from '@/app/actions/admin-settings'
import { getOllamaModels } from '@/app/actions/ollama'
import { toast } from 'sonner'
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { TestTube, ExternalLink } from 'lucide-react'
import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
type AIProvider = 'ollama' | 'openai' | 'custom'
@@ -19,10 +21,7 @@ interface AvailableModels {
}
const MODELS_2026 = {
ollama: {
tags: ['llama3:latest', 'llama3.2:latest', 'granite4:latest', 'mistral:latest', 'mixtral:latest', 'phi3:latest', 'gemma2:latest', 'qwen2:latest'],
embeddings: ['embeddinggemma:latest', 'mxbai-embed-large:latest', 'nomic-embed-text:latest']
},
// Removed hardcoded Ollama models in favor of dynamic fetching
openai: {
tags: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
@@ -34,6 +33,7 @@ const MODELS_2026 = {
}
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
const { t } = useLanguage()
const [isSaving, setIsSaving] = useState(false)
const [isTesting, setIsTesting] = useState(false)
@@ -46,15 +46,68 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
// Sync state with config when server revalidates
// Selected Models State (Controlled Inputs)
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
// Dynamic Models State
const [ollamaTagsModels, setOllamaTagsModels] = useState<string[]>([])
const [ollamaEmbeddingsModels, setOllamaEmbeddingsModels] = useState<string[]>([])
const [isLoadingTagsModels, setIsLoadingTagsModels] = useState(false)
const [isLoadingEmbeddingsModels, setIsLoadingEmbeddingsModels] = useState(false)
// Sync state with config
useEffect(() => {
setAllowRegister(config.ALLOW_REGISTRATION !== 'false')
setSmtpSecure(config.SMTP_SECURE === 'true')
setSmtpIgnoreCert(config.SMTP_IGNORE_CERT === 'true')
setTagsProvider((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
setEmbeddingsProvider((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
setSelectedTagsModel(config.AI_MODEL_TAGS || '')
setSelectedEmbeddingModel(config.AI_MODEL_EMBEDDING || '')
}, [config])
// Fetch Ollama models
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings', url: string) => {
if (!url) return
if (type === 'tags') setIsLoadingTagsModels(true)
else setIsLoadingEmbeddingsModels(true)
try {
const result = await getOllamaModels(url)
if (result.success) {
if (type === 'tags') setOllamaTagsModels(result.models)
else setOllamaEmbeddingsModels(result.models)
} else {
toast.error(`Failed to fetch Ollama models: ${result.error}`)
}
} catch (error) {
console.error(error)
toast.error('Failed to fetch Ollama models')
} finally {
if (type === 'tags') setIsLoadingTagsModels(false)
else setIsLoadingEmbeddingsModels(false)
}
}, [])
// Initial fetch for Ollama models if provider is selected
useEffect(() => {
if (tagsProvider === 'ollama') {
const url = config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('tags', url)
}
}, [tagsProvider, config.OLLAMA_BASE_URL_TAGS, config.OLLAMA_BASE_URL, fetchOllamaModels])
useEffect(() => {
if (embeddingsProvider === 'ollama') {
const url = config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('embeddings', url)
}
}, [embeddingsProvider, config.OLLAMA_BASE_URL_EMBEDDING, config.OLLAMA_BASE_URL, fetchOllamaModels])
const handleSaveSecurity = async (formData: FormData) => {
setIsSaving(true)
const data = {
@@ -65,9 +118,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
setIsSaving(false)
if (result.error) {
toast.error('Failed to update security settings')
toast.error(t('admin.security.updateFailed'))
} else {
toast.success('Security Settings updated')
toast.success(t('admin.security.updateSuccess'))
}
}
@@ -76,9 +129,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const data: Record<string, string> = {}
try {
// Tags provider configuration
const tagsProv = formData.get('AI_PROVIDER_TAGS') as AIProvider
if (!tagsProv) throw new Error('AI_PROVIDER_TAGS is required')
if (!tagsProv) throw new Error(t('admin.ai.providerTagsRequired'))
data.AI_PROVIDER_TAGS = tagsProv
const tagsModel = formData.get('AI_MODEL_TAGS') as string
@@ -86,7 +138,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
if (tagsProv === 'ollama') {
const ollamaUrl = formData.get('OLLAMA_BASE_URL_TAGS') as string
if (ollamaUrl) data.OLLAMA_BASE_URL = ollamaUrl
if (ollamaUrl) data.OLLAMA_BASE_URL_TAGS = ollamaUrl
} else if (tagsProv === 'openai') {
const openaiKey = formData.get('OPENAI_API_KEY') as string
if (openaiKey) data.OPENAI_API_KEY = openaiKey
@@ -97,9 +149,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
}
// Embeddings provider configuration
const embedProv = formData.get('AI_PROVIDER_EMBEDDING') as AIProvider
if (!embedProv) throw new Error('AI_PROVIDER_EMBEDDING is required')
if (!embedProv) throw new Error(t('admin.ai.providerEmbeddingRequired'))
data.AI_PROVIDER_EMBEDDING = embedProv
const embedModel = formData.get('AI_MODEL_EMBEDDING') as string
@@ -107,7 +158,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
if (embedProv === 'ollama') {
const ollamaUrl = formData.get('OLLAMA_BASE_URL_EMBEDDING') as string
if (ollamaUrl) data.OLLAMA_BASE_URL = ollamaUrl
if (ollamaUrl) data.OLLAMA_BASE_URL_EMBEDDING = ollamaUrl
} else if (embedProv === 'openai') {
const openaiKey = formData.get('OPENAI_API_KEY') as string
if (openaiKey) data.OPENAI_API_KEY = openaiKey
@@ -118,20 +169,30 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
}
console.log('Saving AI config:', data)
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error('Failed to update AI settings: ' + result.error)
toast.error(t('admin.ai.updateFailed') + ': ' + result.error)
} else {
toast.success('AI Settings updated successfully')
toast.success(t('admin.ai.updateSuccess'))
setTagsProvider(tagsProv)
setEmbeddingsProvider(embedProv)
// Refresh models after save if Ollama is selected
if (tagsProv === 'ollama') {
const url = data.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('tags', url)
}
if (embedProv === 'ollama') {
const url = data.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('embeddings', url)
}
}
} catch (error: any) {
setIsSaving(false)
toast.error('Error: ' + error.message)
toast.error(t('general.error') + ': ' + error.message)
}
}
@@ -151,9 +212,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
setIsSaving(false)
if (result.error) {
toast.error('Failed to update SMTP settings')
toast.error(t('admin.smtp.updateFailed'))
} else {
toast.success('SMTP Settings updated')
toast.success(t('admin.smtp.updateSuccess'))
}
}
@@ -162,12 +223,12 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
try {
const result: any = await testSMTP()
if (result.success) {
toast.success('Test email sent successfully!')
toast.success(t('admin.smtp.testSuccess'))
} else {
toast.error(`Failed: ${result.error}`)
toast.error(t('admin.smtp.testFailed', { error: result.error }))
}
} catch (e: any) {
toast.error(`Error: ${e.message}`)
toast.error(t('general.error') + ': ' + e.message)
} finally {
setIsTesting(false)
}
@@ -177,8 +238,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Security Settings</CardTitle>
<CardDescription>Manage access control and registration policies.</CardDescription>
<CardTitle>{t('admin.security.title')}</CardTitle>
<CardDescription>{t('admin.security.description')}</CardDescription>
</CardHeader>
<form action={handleSaveSecurity}>
<CardContent className="space-y-4">
@@ -192,35 +253,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
htmlFor="ALLOW_REGISTRATION"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Allow Public Registration
{t('admin.security.allowPublicRegistration')}
</label>
</div>
<p className="text-xs text-muted-foreground">
If disabled, new users can only be added by an Administrator via the User Management page.
{t('admin.security.allowPublicRegistrationDescription')}
</p>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isSaving}>Save Security Settings</Button>
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>AI Configuration</CardTitle>
<CardDescription>Configure AI providers for auto-tagging and semantic search. Use different providers for optimal performance.</CardDescription>
<CardTitle>{t('admin.ai.title')}</CardTitle>
<CardDescription>{t('admin.ai.description')}</CardDescription>
</CardHeader>
<form action={handleSaveAI}>
<CardContent className="space-y-6">
{/* Tags Generation Section */}
<div className="space-y-4 p-4 border rounded-lg bg-primary/5 dark:bg-primary/10">
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-primary">🏷</span> Tags Generation Provider
<span className="text-primary">🏷</span> {t('admin.ai.tagsGenerationProvider')}
</h3>
<p className="text-xs text-muted-foreground">AI provider for automatic tag suggestions. Recommended: Ollama (free, local).</p>
<p className="text-xs text-muted-foreground">{t('admin.ai.tagsGenerationDescription')}</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_TAGS">Provider</Label>
<Label htmlFor="AI_PROVIDER_TAGS">{t('admin.ai.provider')}</Label>
<select
id="AI_PROVIDER_TAGS"
name="AI_PROVIDER_TAGS"
@@ -228,100 +288,125 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
onChange={(e) => setTagsProvider(e.target.value as AIProvider)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="ollama">🦙 Ollama (Local & Free)</option>
<option value="openai">🤖 OpenAI (GPT-5, GPT-4)</option>
<option value="custom">🔧 Custom OpenAI-Compatible</option>
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
</select>
</div>
{/* Ollama Tags Config */}
{tagsProvider === 'ollama' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OLLAMA_BASE_URL_TAGS">Base URL</Label>
<Input id="OLLAMA_BASE_URL_TAGS" name="OLLAMA_BASE_URL_TAGS" defaultValue={config.OLLAMA_BASE_URL || 'http://localhost:11434'} placeholder="http://localhost:11434" />
<Label htmlFor="OLLAMA_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
id="OLLAMA_BASE_URL_TAGS"
name="OLLAMA_BASE_URL_TAGS"
defaultValue={config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
placeholder="http://localhost:11434"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const input = document.getElementById('OLLAMA_BASE_URL_TAGS') as HTMLInputElement
fetchOllamaModels('tags', input.value)
}}
disabled={isLoadingTagsModels}
title="Refresh Models"
>
<RefreshCw className={`h-4 w-4 ${isLoadingTagsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">Model</Label>
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_OLLAMA"
name="AI_MODEL_TAGS"
defaultValue={config.AI_MODEL_TAGS || 'granite4:latest'}
name="AI_MODEL_TAGS_OLLAMA"
value={selectedTagsModel}
onChange={(e) => setSelectedTagsModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.ollama.tags.map((model) => (
<option key={model} value={model}>{model}</option>
))}
{ollamaTagsModels.length > 0 ? (
ollamaTagsModels.map((model) => (
<option key={model} value={model}>{model}</option>
))
) : (
<option value={selectedTagsModel || 'granite4:latest'}>{selectedTagsModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
)}
</select>
<p className="text-xs text-muted-foreground">Select an Ollama model installed on your system</p>
<p className="text-xs text-muted-foreground">
{isLoadingTagsModels ? 'Fetching models...' : t('admin.ai.selectOllamaModel')}
</p>
</div>
</div>
)}
{/* OpenAI Tags Config */}
{tagsProvider === 'openai' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OPENAI_API_KEY">API Key</Label>
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
<p className="text-xs text-muted-foreground">Your OpenAI API key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">platform.openai.com</a></p>
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_OPENAI">Model</Label>
<Label htmlFor="AI_MODEL_TAGS_OPENAI">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_OPENAI"
name="AI_MODEL_TAGS"
defaultValue={config.AI_MODEL_TAGS || 'gpt-4o-mini'}
name="AI_MODEL_TAGS_OPENAI"
value={selectedTagsModel}
onChange={(e) => setSelectedTagsModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.openai.tags.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = Best value <strong className="text-primary">gpt-4o</strong> = Best quality</p>
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = {t('admin.ai.bestValue')} <strong className="text-primary">gpt-4o</strong> = {t('admin.ai.bestQuality')}</p>
</div>
</div>
)}
{/* Custom OpenAI Tags Config */}
{tagsProvider === 'custom' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_TAGS">Base URL</Label>
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
<Input id="CUSTOM_OPENAI_BASE_URL_TAGS" name="CUSTOM_OPENAI_BASE_URL_TAGS" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
</div>
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_API_KEY_TAGS">API Key</Label>
<Label htmlFor="CUSTOM_OPENAI_API_KEY_TAGS">{t('admin.ai.apiKey')}</Label>
<Input id="CUSTOM_OPENAI_API_KEY_TAGS" name="CUSTOM_OPENAI_API_KEY_TAGS" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_CUSTOM">Model</Label>
<Label htmlFor="AI_MODEL_TAGS_CUSTOM">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_CUSTOM"
name="AI_MODEL_TAGS"
defaultValue={config.AI_MODEL_TAGS || 'gpt-4o-mini'}
name="AI_MODEL_TAGS_CUSTOM"
value={selectedTagsModel}
onChange={(e) => setSelectedTagsModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.custom.tags.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground">Common models for OpenAI-compatible APIs</p>
<p className="text-xs text-muted-foreground">{t('admin.ai.commonModelsDescription')}</p>
</div>
</div>
)}
</div>
{/* Embeddings Section */}
<div className="space-y-4 p-4 border rounded-lg bg-green-50/50 dark:bg-green-950/20">
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-green-600">🔍</span> Embeddings Provider
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
</h3>
<p className="text-xs text-muted-foreground">AI provider for semantic search embeddings. Recommended: OpenAI (best quality).</p>
<p className="text-xs text-muted-foreground">{t('admin.ai.embeddingsDescription')}</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_EMBEDDING">
Provider
{t('admin.ai.provider')}
<span className="ml-2 text-xs text-muted-foreground">
(Current: {embeddingsProvider})
</span>
@@ -333,99 +418,125 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
onChange={(e) => setEmbeddingsProvider(e.target.value as AIProvider)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="ollama">🦙 Ollama (Local & Free)</option>
<option value="openai">🤖 OpenAI (text-embedding-4)</option>
<option value="custom">🔧 Custom OpenAI-Compatible</option>
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
</select>
<p className="text-xs text-muted-foreground">
Config value: {config.AI_PROVIDER_EMBEDDING || 'Not set (defaults to ollama)'}
</p>
</div>
{/* Ollama Embeddings Config */}
{embeddingsProvider === 'ollama' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OLLAMA_BASE_URL_EMBEDDING">Base URL</Label>
<Input id="OLLAMA_BASE_URL_EMBEDDING" name="OLLAMA_BASE_URL_EMBEDDING" defaultValue={config.OLLAMA_BASE_URL || 'http://localhost:11434'} placeholder="http://localhost:11434" />
<Label htmlFor="OLLAMA_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
id="OLLAMA_BASE_URL_EMBEDDING"
name="OLLAMA_BASE_URL_EMBEDDING"
defaultValue={config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
placeholder="http://localhost:11434"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const input = document.getElementById('OLLAMA_BASE_URL_EMBEDDING') as HTMLInputElement
fetchOllamaModels('embeddings', input.value)
}}
disabled={isLoadingEmbeddingsModels}
title="Refresh Models"
>
<RefreshCw className={`h-4 w-4 ${isLoadingEmbeddingsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">Model</Label>
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_OLLAMA"
name="AI_MODEL_EMBEDDING"
defaultValue={config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'}
name="AI_MODEL_EMBEDDING_OLLAMA"
value={selectedEmbeddingModel}
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.ollama.embeddings.map((model) => (
<option key={model} value={model}>{model}</option>
))}
{ollamaEmbeddingsModels.length > 0 ? (
ollamaEmbeddingsModels.map((model) => (
<option key={model} value={model}>{model}</option>
))
) : (
<option value={selectedEmbeddingModel || 'embeddinggemma:latest'}>{selectedEmbeddingModel || 'embeddinggemma:latest'} {t('admin.ai.saved')}</option>
)}
</select>
<p className="text-xs text-muted-foreground">Select an embedding model installed on your system</p>
<p className="text-xs text-muted-foreground">
{isLoadingEmbeddingsModels ? 'Fetching models...' : t('admin.ai.selectEmbeddingModel')}
</p>
</div>
</div>
)}
{/* OpenAI Embeddings Config */}
{embeddingsProvider === 'openai' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OPENAI_API_KEY">API Key</Label>
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
<p className="text-xs text-muted-foreground">Your OpenAI API key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">platform.openai.com</a></p>
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">Model</Label>
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_OPENAI"
name="AI_MODEL_EMBEDDING"
defaultValue={config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'}
name="AI_MODEL_EMBEDDING_OPENAI"
value={selectedEmbeddingModel}
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.openai.embeddings.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground"><strong className="text-green-600">text-embedding-3-small</strong> = Best value <strong className="text-primary">text-embedding-3-large</strong> = Best quality</p>
<p className="text-xs text-muted-foreground"><strong className="text-green-600">text-embedding-3-small</strong> = {t('admin.ai.bestValue')} <strong className="text-primary">text-embedding-3-large</strong> = {t('admin.ai.bestQuality')}</p>
</div>
</div>
)}
{/* Custom OpenAI Embeddings Config */}
{embeddingsProvider === 'custom' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_EMBEDDING">Base URL</Label>
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
<Input id="CUSTOM_OPENAI_BASE_URL_EMBEDDING" name="CUSTOM_OPENAI_BASE_URL_EMBEDDING" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
</div>
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_API_KEY_EMBEDDING">API Key</Label>
<Label htmlFor="CUSTOM_OPENAI_API_KEY_EMBEDDING">{t('admin.ai.apiKey')}</Label>
<Input id="CUSTOM_OPENAI_API_KEY_EMBEDDING" name="CUSTOM_OPENAI_API_KEY_EMBEDDING" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_CUSTOM">Model</Label>
<Label htmlFor="AI_MODEL_EMBEDDING_CUSTOM">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_CUSTOM"
name="AI_MODEL_EMBEDDING"
defaultValue={config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'}
name="AI_MODEL_EMBEDDING_CUSTOM"
value={selectedEmbeddingModel}
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.custom.embeddings.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground">Common embedding models for OpenAI-compatible APIs</p>
<p className="text-xs text-muted-foreground">{t('admin.ai.commonEmbeddingModels')}</p>
</div>
</div>
)}
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button type="submit" disabled={isSaving}>{isSaving ? 'Saving...' : 'Save AI Settings'}</Button>
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
<Link href="/admin/ai-test">
<Button type="button" variant="outline" className="gap-2">
<TestTube className="h-4 w-4" />
Open AI Test Panel
{t('admin.ai.openTestPanel')}
<ExternalLink className="h-3 w-3" />
</Button>
</Link>
@@ -435,34 +546,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<Card>
<CardHeader>
<CardTitle>SMTP Configuration</CardTitle>
<CardDescription>Configure email server for password resets.</CardDescription>
<CardTitle>{t('admin.smtp.title')}</CardTitle>
<CardDescription>{t('admin.smtp.description')}</CardDescription>
</CardHeader>
<form action={handleSaveSMTP}>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="SMTP_HOST" className="text-sm font-medium">Host</label>
<label htmlFor="SMTP_HOST" className="text-sm font-medium">{t('admin.smtp.host')}</label>
<Input id="SMTP_HOST" name="SMTP_HOST" defaultValue={config.SMTP_HOST || ''} placeholder="smtp.example.com" />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_PORT" className="text-sm font-medium">Port</label>
<label htmlFor="SMTP_PORT" className="text-sm font-medium">{t('admin.smtp.port')}</label>
<Input id="SMTP_PORT" name="SMTP_PORT" defaultValue={config.SMTP_PORT || '587'} placeholder="587" />
</div>
</div>
<div className="space-y-2">
<label htmlFor="SMTP_USER" className="text-sm font-medium">Username</label>
<label htmlFor="SMTP_USER" className="text-sm font-medium">{t('admin.smtp.username')}</label>
<Input id="SMTP_USER" name="SMTP_USER" defaultValue={config.SMTP_USER || ''} />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_PASS" className="text-sm font-medium">Password</label>
<label htmlFor="SMTP_PASS" className="text-sm font-medium">{t('admin.smtp.password')}</label>
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_FROM" className="text-sm font-medium">From Email</label>
<label htmlFor="SMTP_FROM" className="text-sm font-medium">{t('admin.smtp.fromEmail')}</label>
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
</div>
@@ -476,7 +587,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
htmlFor="SMTP_SECURE"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Force SSL/TLS (usually for port 465)
{t('admin.smtp.forceSSL')}
</label>
</div>
@@ -490,14 +601,14 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
htmlFor="SMTP_IGNORE_CERT"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
>
Ignore Certificate Errors (Self-hosted/Dev only)
{t('admin.smtp.ignoreCertErrors')}
</label>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button type="submit" disabled={isSaving}>Save SMTP Settings</Button>
<Button type="submit" disabled={isSaving}>{t('admin.smtp.saveSettings')}</Button>
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
{isTesting ? 'Sending...' : 'Test Email'}
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
</Button>
</CardFooter>
</form>

View File

@@ -6,17 +6,18 @@ import { deleteUser, updateUserRole } from '@/app/actions/admin'
import { toast } from 'sonner'
import { Trash2, Shield, ShieldOff } from 'lucide-react'
import { format } from 'date-fns'
import { useLanguage } from '@/lib/i18n'
export function UserList({ initialUsers }: { initialUsers: any[] }) {
// Optimistic update could be implemented here, but standard is fine for admin
const { t } = useLanguage()
const handleDelete = async (id: string) => {
if (!confirm('Are you sure? This action cannot be undone.')) return
if (!confirm(t('admin.users.confirmDelete'))) return
try {
await deleteUser(id)
toast.success('User deleted')
toast.success(t('admin.users.deleteSuccess'))
} catch (e) {
toast.error('Failed to delete')
toast.error(t('admin.users.deleteFailed'))
}
}
@@ -24,9 +25,9 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
try {
await updateUserRole(user.id, newRole)
toast.success(`User role updated to ${newRole}`)
toast.success(t('admin.users.roleUpdateSuccess', { role: newRole }))
} catch (e) {
toast.error('Failed to update role')
toast.error(t('admin.users.roleUpdateFailed'))
}
}
@@ -35,21 +36,21 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
<table className="w-full caption-bottom text-sm text-left">
<thead className="[&_tr]:border-b">
<tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Name</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Email</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Role</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Created At</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground text-right">Actions</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.name')}</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.email')}</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.role')}</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.createdAt')}</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground text-right">{t('admin.users.table.actions')}</th>
</tr>
</thead>
<tbody className="[&_tr:last-child]:border-0">
{initialUsers.map((user) => (
<tr key={user.id} className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td className="p-4 align-middle font-medium">{user.name || 'N/A'}</td>
<td className="p-4 align-middle font-medium">{user.name || t('common.notAvailable')}</td>
<td className="p-4 align-middle">{user.email}</td>
<td className="p-4 align-middle">
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${user.role === 'ADMIN' ? 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80' : 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80'}`}>
{user.role}
{user.role === 'ADMIN' ? t('admin.users.roles.admin') : t('admin.users.roles.user')}
</span>
</td>
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
@@ -59,7 +60,7 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
variant="ghost"
size="sm"
onClick={() => handleRoleToggle(user)}
title={user.role === 'ADMIN' ? "Demote to User" : "Promote to Admin"}
title={user.role === 'ADMIN' ? t('admin.users.demote') : t('admin.users.promote')}
>
{user.role === 'ADMIN' ? <ShieldOff className="h-4 w-4" /> : <Shield className="h-4 w-4" />}
</Button>

View File

@@ -24,11 +24,13 @@ import { useNotebooks } from '@/context/notebooks-context'
import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Plane, ChevronRight, Plus } from 'lucide-react'
import { cn } from '@/lib/utils'
import { LabelFilter } from '@/components/label-filter'
import { useLanguage } from '@/lib/i18n'
export default function HomePage() {
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
// Force re-render when search params change (for filtering)
const [notes, setNotes] = useState<Note[]>([])
const [pinnedNotes, setPinnedNotes] = useState<Note[]>([])
@@ -260,7 +262,7 @@ export default function HomePage() {
// Helper for Breadcrumbs
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<span>Notebooks</span>
<span>{t('nav.notebooks')}</span>
<ChevronRight className="w-4 h-4" />
<span className="font-medium text-primary">{notebookName}</span>
</div>
@@ -325,7 +327,7 @@ export default function HomePage() {
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
<FileText className="w-8 h-8 text-primary" />
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">Notes</h1>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
</div>
{/* Actions Section */}
@@ -342,15 +344,15 @@ export default function HomePage() {
/>
{/* AI Organization Button - Moved to Header */}
{isInbox && !isLoading && notes.length >= 5 && (
{isInbox && !isLoading && notes.length >= 2 && (
<Button
onClick={() => setBatchOrganizationOpen(true)}
variant="outline"
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
title="Organiser avec l'IA"
title={t('batch.organizeWithAI')}
>
<Wand2 className="h-4 w-4 text-purple-600" />
<span className="hidden sm:inline">Organiser</span>
<span className="hidden sm:inline">{t('batch.organize')}</span>
</Button>
)}
@@ -359,7 +361,7 @@ export default function HomePage() {
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
>
<Plus className="w-5 h-5" />
Add Note
{t('notes.newNote')}
</Button>
</div>
</div>
@@ -378,7 +380,7 @@ export default function HomePage() {
)}
{isLoading ? (
<div className="text-center py-8 text-gray-500">Loading...</div>
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
) : (
<>
{/* Favorites Section - Pinned Notes */}
@@ -408,7 +410,7 @@ export default function HomePage() {
{/* Empty state when no notes */}
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
<div className="text-center py-8 text-gray-500">
No notes yet. Create your first note!
{t('notes.emptyState')}
</div>
)}
</>

View File

@@ -1,127 +1,129 @@
'use client'
import { SettingsNav, SettingsSection } from '@/components/settings'
import { SettingsSection } from '@/components/settings'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { useLanguage } from '@/lib/i18n'
export default function AboutSettingsPage() {
const { t } = useLanguage()
const version = '1.0.0'
const buildDate = '2026-01-17'
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">About</h1>
<h1 className="text-3xl font-bold mb-2">{t('about.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
Information about the application
{t('about.description')}
</p>
</div>
<SettingsSection
title="Keep Notes"
title={t('about.appName')}
icon={<span className="text-2xl">📝</span>}
description="A powerful note-taking application with AI-powered features"
description={t('about.appDescription')}
>
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex justify-between items-center">
<span className="font-medium">Version</span>
<span className="font-medium">{t('about.version')}</span>
<Badge variant="secondary">{version}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="font-medium">Build Date</span>
<span className="font-medium">{t('about.buildDate')}</span>
<Badge variant="outline">{buildDate}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="font-medium">Platform</span>
<Badge variant="outline">Web</Badge>
<span className="font-medium">{t('about.platform')}</span>
<Badge variant="outline">{t('about.platformWeb')}</Badge>
</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title="Features"
title={t('about.features.title')}
icon={<span className="text-2xl"></span>}
description="AI-powered capabilities"
description={t('about.features.description')}
>
<Card>
<CardContent className="pt-6 space-y-2">
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>AI-powered title suggestions</span>
<span>{t('about.features.titleSuggestions')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Semantic search with embeddings</span>
<span>{t('about.features.semanticSearch')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Paragraph reformulation</span>
<span>{t('about.features.paragraphReformulation')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Memory Echo daily insights</span>
<span>{t('about.features.memoryEcho')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Notebook organization</span>
<span>{t('about.features.notebookOrganization')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Drag & drop note management</span>
<span>{t('about.features.dragDrop')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Label system</span>
<span>{t('about.features.labelSystem')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Multiple AI providers (OpenAI, Ollama)</span>
<span>{t('about.features.multipleProviders')}</span>
</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title="Technology Stack"
title={t('about.technology.title')}
icon={<span className="text-2xl"></span>}
description="Built with modern technologies"
description={t('about.technology.description')}
>
<Card>
<CardContent className="pt-6 space-y-2 text-sm">
<div><strong>Frontend:</strong> Next.js 16, React 19, TypeScript</div>
<div><strong>Backend:</strong> Next.js API Routes, Server Actions</div>
<div><strong>Database:</strong> SQLite (Prisma ORM)</div>
<div><strong>Authentication:</strong> NextAuth 5</div>
<div><strong>AI:</strong> Vercel AI SDK, OpenAI, Ollama</div>
<div><strong>UI:</strong> Radix UI, Tailwind CSS, Lucide Icons</div>
<div><strong>Testing:</strong> Playwright (E2E)</div>
<div><strong>{t('about.technology.frontend')}:</strong> Next.js 16, React 19, TypeScript</div>
<div><strong>{t('about.technology.backend')}:</strong> Next.js API Routes, Server Actions</div>
<div><strong>{t('about.technology.database')}:</strong> SQLite (Prisma ORM)</div>
<div><strong>{t('about.technology.authentication')}:</strong> NextAuth 5</div>
<div><strong>{t('about.technology.ai')}:</strong> Vercel AI SDK, OpenAI, Ollama</div>
<div><strong>{t('about.technology.ui')}:</strong> Radix UI, Tailwind CSS, Lucide Icons</div>
<div><strong>{t('about.technology.testing')}:</strong> Playwright (E2E)</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title="Support"
title={t('about.support.title')}
icon={<span className="text-2xl">💬</span>}
description="Get help and feedback"
description={t('about.support.description')}
>
<Card>
<CardContent className="pt-6 space-y-4">
<div>
<p className="font-medium mb-2">Documentation</p>
<p className="font-medium mb-2">{t('about.support.documentation')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Check the documentation for detailed guides and tutorials.
</p>
</div>
<div>
<p className="font-medium mb-2">Report Issues</p>
<p className="font-medium mb-2">{t('about.support.reportIssues')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Found a bug? Report it in the issue tracker.
</p>
</div>
<div>
<p className="font-medium mb-2">Feedback</p>
<p className="font-medium mb-2">{t('about.support.feedback')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
We value your feedback! Share your thoughts and suggestions.
</p>

View File

@@ -27,36 +27,33 @@ export default function AISettingsPage() {
try {
await updateAISettings({ [feature]: value })
} catch (error) {
console.error('Error updating setting:', error)
toast.error('Failed to save setting')
toast.error(t('aiSettings.error'))
setSettings(settings) // Revert on error
}
}
const handleFrequencyChange = async (value: 'daily' | 'weekly' | 'custom') => {
setSettings(prev => ({ ...prev, memoryEchoFrequency: value }))
const handleFrequencyChange = async (value: string) => {
setSettings(prev => ({ ...prev, memoryEchoFrequency: value as any }))
try {
await updateAISettings({ memoryEchoFrequency: value })
await updateAISettings({ memoryEchoFrequency: value as any })
} catch (error) {
console.error('Error updating frequency:', error)
toast.error('Failed to save setting')
toast.error(t('aiSettings.error'))
}
}
const handleProviderChange = async (value: 'auto' | 'openai' | 'ollama') => {
setSettings(prev => ({ ...prev, aiProvider: value }))
const handleProviderChange = async (value: string) => {
setSettings(prev => ({ ...prev, aiProvider: value as any }))
try {
await updateAISettings({ aiProvider: value })
await updateAISettings({ aiProvider: value as any })
} catch (error) {
console.error('Error updating provider:', error)
toast.error('Failed to save setting')
toast.error(t('aiSettings.error'))
}
}
const handleApiKeyChange = async (value: string) => {
setApiKey(value)
// TODO: Implement API key persistence
console.log('API Key:', value)
}
return (
@@ -70,37 +67,37 @@ export default function AISettingsPage() {
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">AI Settings</h1>
<h1 className="text-3xl font-bold mb-2">{t('aiSettings.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
Configure AI-powered features and preferences
{t('aiSettings.description')}
</p>
</div>
{/* AI Provider */}
<SettingsSection
title="AI Provider"
title={t('aiSettings.provider')}
icon={<span className="text-2xl">🤖</span>}
description="Choose your preferred AI service provider"
description={t('aiSettings.providerDesc')}
>
<SettingSelect
label="Provider"
description="Select which AI service to use"
label={t('aiSettings.provider')}
description={t('aiSettings.providerDesc')}
value={settings.aiProvider}
options={[
{
value: 'auto',
label: 'Auto-detect',
description: 'Ollama when available, OpenAI fallback'
label: t('aiSettings.providerAuto'),
description: t('aiSettings.providerAutoDesc')
},
{
value: 'ollama',
label: 'Ollama (Local)',
description: '100% private, runs locally on your machine'
label: t('aiSettings.providerOllama'),
description: t('aiSettings.providerOllamaDesc')
},
{
value: 'openai',
label: 'OpenAI',
description: 'Most accurate, requires API key'
label: t('aiSettings.providerOpenAI'),
description: t('aiSettings.providerOpenAIDesc')
},
]}
onChange={handleProviderChange}
@@ -108,8 +105,8 @@ export default function AISettingsPage() {
{settings.aiProvider === 'openai' && (
<SettingInput
label="API Key"
description="Your OpenAI API key (stored securely)"
label={t('admin.ai.apiKey')}
description={t('admin.ai.openAIKeyDescription')}
value={apiKey}
type="password"
placeholder="sk-..."
@@ -120,46 +117,46 @@ export default function AISettingsPage() {
{/* Feature Toggles */}
<SettingsSection
title="AI Features"
title={t('aiSettings.features')}
icon={<span className="text-2xl"></span>}
description="Enable or disable AI-powered features"
description={t('aiSettings.description')}
>
<SettingToggle
label="Title Suggestions"
description="Suggest titles for untitled notes after 50+ words"
label={t('titleSuggestions.available').replace('💡 ', '')}
description={t('aiSettings.titleSuggestionsDesc')}
checked={settings.titleSuggestions}
onChange={(checked) => handleToggle('titleSuggestions', checked)}
/>
<SettingToggle
label="Semantic Search"
description="Search by meaning, not just keywords"
label={t('semanticSearch.exactMatch')}
description={t('semanticSearch.searching')}
checked={settings.semanticSearch}
onChange={(checked) => handleToggle('semanticSearch', checked)}
/>
<SettingToggle
label="Paragraph Reformulation"
description="AI-powered text improvement options"
label={t('paragraphRefactor.title')}
description={t('aiSettings.paragraphRefactorDesc')}
checked={settings.paragraphRefactor}
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
/>
<SettingToggle
label="Memory Echo"
description="Daily proactive note connections and insights"
label={t('memoryEcho.title')}
description={t('memoryEcho.dailyInsight')}
checked={settings.memoryEcho}
onChange={(checked) => handleToggle('memoryEcho', checked)}
/>
{settings.memoryEcho && (
<SettingSelect
label="Memory Echo Frequency"
description="How often to analyze note connections"
label={t('aiSettings.frequency')}
description={t('aiSettings.frequencyDesc')}
value={settings.memoryEchoFrequency}
options={[
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'daily', label: t('aiSettings.frequencyDaily') },
{ value: 'weekly', label: t('aiSettings.frequencyWeekly') },
{ value: 'custom', label: 'Custom' },
]}
onChange={handleFrequencyChange}
@@ -169,13 +166,13 @@ export default function AISettingsPage() {
{/* Demo Mode */}
<SettingsSection
title="Demo Mode"
title={t('demoMode.title')}
icon={<span className="text-2xl">🎭</span>}
description="Test AI features without using real AI calls"
description={t('demoMode.description')}
>
<SettingToggle
label="Enable Demo Mode"
description="Use mock AI responses for testing and demonstrations"
label={t('demoMode.title')}
description={t('demoMode.description')}
checked={settings.demoMode}
onChange={(checked) => handleToggle('demoMode', checked)}
/>

View File

@@ -3,9 +3,9 @@
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { SettingsSection, SettingSelect } from '@/components/settings'
// Import actions directly
import { updateAISettings as updateAI } from '@/app/actions/ai-settings'
import { updateUserSettings as updateUser } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
interface AppearanceSettingsFormProps {
initialTheme: string
@@ -16,6 +16,7 @@ export function AppearanceSettingsForm({ initialTheme, initialFontSize }: Appear
const router = useRouter()
const [theme, setTheme] = useState(initialTheme)
const [fontSize, setFontSize] = useState(initialFontSize)
const { t } = useLanguage()
const handleThemeChange = async (value: string) => {
setTheme(value)
@@ -57,46 +58,46 @@ export function AppearanceSettingsForm({ initialTheme, initialFontSize }: Appear
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Appearance</h1>
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
Customize look and feel of application
{t('appearance.description')}
</p>
</div>
<SettingsSection
title="Theme"
title={t('settings.theme')}
icon={<span className="text-2xl">🎨</span>}
description="Choose your preferred color scheme"
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
>
<SettingSelect
label="Color Scheme"
description="Select app's visual theme"
label={t('settings.theme')}
description={t('settings.selectLanguage')}
value={theme}
options={[
{ value: 'slate', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'slate', label: t('settings.themeLight') },
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'sepia', label: 'Sepia' },
{ value: 'midnight', label: 'Midnight' },
{ value: 'blue', label: 'Blue' },
{ value: 'auto', label: 'Auto (system)' },
{ value: 'auto', label: t('settings.themeSystem') },
]}
onChange={handleThemeChange}
/>
</SettingsSection>
<SettingsSection
title="Typography"
title={t('profile.fontSize')}
icon={<span className="text-2xl">📝</span>}
description="Adjust text size for better readability"
description={t('profile.fontSizeDescription')}
>
<SettingSelect
label="Font Size"
description="Adjust size of text throughout app"
label={t('profile.fontSize')}
description={t('profile.selectFontSize')}
value={fontSize}
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
{ value: 'small', label: t('profile.fontSizeSmall') },
{ value: 'medium', label: t('profile.fontSizeMedium') },
{ value: 'large', label: t('profile.fontSizeLarge') },
]}
onChange={handleFontSizeChange}
/>

View File

@@ -4,8 +4,10 @@ import { useState, useEffect } from 'react'
import { SettingsNav, SettingsSection, SettingSelect } from '@/components/settings'
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
import { updateUserSettings, getUserSettings } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
export default function AppearanceSettingsPage() {
const { t } = useLanguage()
const [theme, setTheme] = useState('auto')
const [fontSize, setFontSize] = useState('medium')
@@ -63,45 +65,45 @@ export default function AppearanceSettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Appearance</h1>
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
Customize look and feel of application
{t('appearance.description')}
</p>
</div>
<SettingsSection
title="Theme"
title={t('settings.theme')}
icon={<span className="text-2xl">🎨</span>}
description="Choose your preferred color scheme"
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
>
<SettingSelect
label="Color Scheme"
description="Select app's visual theme"
label={t('settings.theme')}
description={t('settings.selectLanguage')}
value={theme}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: t('settings.themeLight') },
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'sepia', label: 'Sepia' },
{ value: 'midnight', label: 'Midnight' },
{ value: 'auto', label: 'Auto (system)' },
{ value: 'auto', label: t('settings.themeSystem') },
]}
onChange={handleThemeChange}
/>
</SettingsSection>
<SettingsSection
title="Typography"
title={t('profile.fontSize')}
icon={<span className="text-2xl">📝</span>}
description="Adjust text size for better readability"
description={t('profile.fontSizeDescription')}
>
<SettingSelect
label="Font Size"
description="Adjust size of text throughout app"
label={t('profile.fontSize')}
description={t('profile.selectFontSize')}
value={fontSize}
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
{ value: 'small', label: t('profile.fontSizeSmall') },
{ value: 'medium', label: t('profile.fontSizeMedium') },
{ value: 'large', label: t('profile.fontSizeLarge') },
]}
onChange={handleFontSizeChange}
/>

View File

@@ -1,16 +1,17 @@
'use client'
import { useState } from 'react'
import { SettingsNav, SettingsSection, SettingToggle, SettingInput } from '@/components/settings'
import { SettingsSection } from '@/components/settings'
import { Button } from '@/components/ui/button'
import { Download, Upload, Trash2, Loader2, Check } from 'lucide-react'
import { Download, Upload, Trash2, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
export default function DataSettingsPage() {
const { t } = useLanguage()
const [isExporting, setIsExporting] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [exportUrl, setExportUrl] = useState('')
const handleExport = async () => {
setIsExporting(true)
@@ -26,11 +27,11 @@ export default function DataSettingsPage() {
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
toast.success('Notes exported successfully')
toast.success(t('dataManagement.export.success'))
}
} catch (error) {
console.error('Export error:', error)
toast.error('Failed to export notes')
toast.error(t('dataManagement.export.failed'))
} finally {
setIsExporting(false)
}
@@ -52,24 +53,22 @@ export default function DataSettingsPage() {
if (response.ok) {
const result = await response.json()
toast.success(`Imported ${result.count} notes`)
// Refresh the page to show imported notes
toast.success(t('dataManagement.import.success', { count: result.count }))
window.location.reload()
} else {
throw new Error('Import failed')
}
} catch (error) {
console.error('Import error:', error)
toast.error('Failed to import notes')
toast.error(t('dataManagement.import.failed'))
} finally {
setIsImporting(false)
// Reset input
event.target.value = ''
}
}
const handleDeleteAll = async () => {
if (!confirm('Are you sure you want to delete all notes? This action cannot be undone.')) {
if (!confirm(t('dataManagement.delete.confirm'))) {
return
}
@@ -77,12 +76,12 @@ export default function DataSettingsPage() {
try {
const response = await fetch('/api/notes/delete-all', { method: 'POST' })
if (response.ok) {
toast.success('All notes deleted')
toast.success(t('dataManagement.delete.success'))
window.location.reload()
}
} catch (error) {
console.error('Delete error:', error)
toast.error('Failed to delete notes')
toast.error(t('dataManagement.delete.failed'))
} finally {
setIsDeleting(false)
}
@@ -91,22 +90,22 @@ export default function DataSettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Data Management</h1>
<h1 className="text-3xl font-bold mb-2">{t('dataManagement.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
Export, import, or manage your data
{t('dataManagement.toolsDescription')}
</p>
</div>
<SettingsSection
title="Export Data"
title={`💾 ${t('dataManagement.export.title')}`}
icon={<span className="text-2xl">💾</span>}
description="Download your notes as a JSON file"
description={t('dataManagement.export.description')}
>
<div className="flex items-center justify-between py-4">
<div>
<p className="font-medium">Export All Notes</p>
<p className="font-medium">{t('dataManagement.export.title')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Download all your notes in JSON format
{t('dataManagement.export.description')}
</p>
</div>
<Button
@@ -118,21 +117,21 @@ export default function DataSettingsPage() {
) : (
<Download className="h-4 w-4 mr-2" />
)}
{isExporting ? 'Exporting...' : 'Export'}
{isExporting ? t('dataManagement.exporting') : t('dataManagement.export.button')}
</Button>
</div>
</SettingsSection>
<SettingsSection
title="Import Data"
title={`📥 ${t('dataManagement.import.title')}`}
icon={<span className="text-2xl">📥</span>}
description="Import notes from a JSON file"
description={t('dataManagement.import.description')}
>
<div className="flex items-center justify-between py-4">
<div>
<p className="font-medium">Import Notes</p>
<p className="font-medium">{t('dataManagement.import.title')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Upload a JSON file to import notes
{t('dataManagement.import.description')}
</p>
</div>
<div>
@@ -153,22 +152,22 @@ export default function DataSettingsPage() {
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{isImporting ? 'Importing...' : 'Import'}
{isImporting ? t('dataManagement.importing') : t('dataManagement.import.button')}
</Button>
</div>
</div>
</SettingsSection>
<SettingsSection
title="Danger Zone"
title={`⚠️ ${t('dataManagement.dangerZone')}`}
icon={<span className="text-2xl"></span>}
description="Permanently delete your data"
description={t('dataManagement.dangerZoneDescription')}
>
<div className="flex items-center justify-between py-4 border-t border-red-200 dark:border-red-900">
<div>
<p className="font-medium text-red-600 dark:text-red-400">Delete All Notes</p>
<p className="font-medium text-red-600 dark:text-red-400">{t('dataManagement.delete.title')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
This action cannot be undone
{t('dataManagement.delete.description')}
</p>
</div>
<Button
@@ -181,7 +180,7 @@ export default function DataSettingsPage() {
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{isDeleting ? 'Deleting...' : 'Delete All'}
{isDeleting ? t('dataManagement.deleting') : t('dataManagement.delete.button')}
</Button>
</div>
</SettingsSection>

View File

@@ -4,9 +4,10 @@ import { useState, useEffect } from 'react'
import { SettingsNav, SettingsSection, SettingToggle, SettingSelect } from '@/components/settings'
import { useLanguage } from '@/lib/i18n'
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
import { toast } from 'sonner'
export default function GeneralSettingsPage() {
const { t } = useLanguage()
const { t, setLanguage: setContextLanguage } = useLanguage()
const [language, setLanguage] = useState('auto')
const [emailNotifications, setEmailNotifications] = useState(false)
const [desktopNotifications, setDesktopNotifications] = useState(false)
@@ -30,7 +31,22 @@ export default function GeneralSettingsPage() {
const handleLanguageChange = async (value: string) => {
setLanguage(value)
// 1. Update database settings
await updateAISettings({ preferredLanguage: value as any })
// 2. Update local storage and application state
if (value === 'auto') {
localStorage.removeItem('user-language')
toast.success("Language set to Auto")
} else {
localStorage.setItem('user-language', value)
setContextLanguage(value as any)
toast.success(t('profile.languageUpdateSuccess') || "Language updated")
}
// 3. Force reload to ensure all components update (server components, metadata, etc.)
setTimeout(() => window.location.reload(), 500)
}
const handleEmailNotificationsChange = async (enabled: boolean) => {
@@ -51,23 +67,23 @@ export default function GeneralSettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">General Settings</h1>
<h1 className="text-3xl font-bold mb-2">{t('generalSettings.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
Configure basic application preferences
{t('generalSettings.description')}
</p>
</div>
<SettingsSection
title="Language & Region"
title={t('settings.language')}
icon={<span className="text-2xl">🌍</span>}
description="Choose your preferred language and regional settings"
description={t('profile.languagePreferencesDescription')}
>
<SettingSelect
label="Language"
description="Select interface language"
label={t('settings.language')}
description={t('settings.selectLanguage')}
value={language}
options={[
{ value: 'auto', label: 'Auto-detect' },
{ value: 'auto', label: t('profile.autoDetect') },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
@@ -89,32 +105,32 @@ export default function GeneralSettingsPage() {
</SettingsSection>
<SettingsSection
title="Notifications"
title={t('settings.notifications')}
icon={<span className="text-2xl">🔔</span>}
description="Manage how and when you receive notifications"
description={t('settings.notifications')}
>
<SettingToggle
label="Email Notifications"
description="Receive email updates about your notes"
label={t('settings.notifications')}
description={t('settings.notifications')}
checked={emailNotifications}
onChange={handleEmailNotificationsChange}
/>
<SettingToggle
label="Desktop Notifications"
description="Show notifications in your browser"
label={t('settings.notifications')}
description={t('settings.notifications')}
checked={desktopNotifications}
onChange={handleDesktopNotificationsChange}
/>
</SettingsSection>
<SettingsSection
title="Privacy"
title={t('settings.privacy')}
icon={<span className="text-2xl">🔒</span>}
description="Control your privacy settings"
description={t('settings.privacy')}
>
<SettingToggle
label="Anonymous Analytics"
description="Help improve app with anonymous usage data"
label={t('settings.privacy')}
description={t('settings.privacy')}
checked={anonymousAnalytics}
onChange={handleAnonymousAnalyticsChange}
/>

View File

@@ -81,9 +81,9 @@ export default function SettingsPage() {
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Settings</h1>
<h1 className="text-3xl font-bold mb-2">{t('settings.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
Configure your application settings
{t('settings.description')}
</p>
</div>
@@ -92,18 +92,18 @@ export default function SettingsPage() {
<Link href="/settings/ai">
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
<BrainCircuit className="h-6 w-6 text-purple-500 mb-2" />
<h3 className="font-semibold">AI Settings</h3>
<h3 className="font-semibold">{t('aiSettings.title')}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Configure AI features and provider
{t('aiSettings.description')}
</p>
</div>
</Link>
<Link href="/settings/profile">
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
<RefreshCw className="h-6 w-6 text-primary mb-2" />
<h3 className="font-semibold">Profile Settings</h3>
<h3 className="font-semibold">{t('profile.title')}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Manage your account and preferences
{t('profile.description')}
</p>
</div>
</Link>
@@ -111,17 +111,17 @@ export default function SettingsPage() {
{/* AI Diagnostics */}
<SettingsSection
title="AI Diagnostics"
title={t('diagnostics.title')}
icon={<span className="text-2xl">🔍</span>}
description="Check your AI provider connection status"
description={t('diagnostics.description')}
>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">Configured Provider</p>
<p className="text-sm font-medium text-muted-foreground mb-1">{t('diagnostics.configuredProvider')}</p>
<p className="text-lg font-mono">{config?.provider || '...'}</p>
</div>
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">API Status</p>
<p className="text-sm font-medium text-muted-foreground mb-1">{t('diagnostics.apiStatus')}</p>
<div className="flex items-center gap-2">
{status === 'success' && <CheckCircle className="text-green-500 w-5 h-5" />}
{status === 'error' && <XCircle className="text-red-500 w-5 h-5" />}
@@ -129,9 +129,9 @@ export default function SettingsPage() {
status === 'error' ? 'text-red-600 dark:text-red-400' :
'text-gray-600'
}`}>
{status === 'success' ? 'Operational' :
status === 'error' ? 'Error' :
'Checking...'}
{status === 'success' ? t('diagnostics.operational') :
status === 'error' ? t('diagnostics.errorStatus') :
t('diagnostics.checking')}
</span>
</div>
</div>
@@ -139,7 +139,7 @@ export default function SettingsPage() {
{result && (
<div className="space-y-2 mt-4">
<h3 className="text-sm font-medium">Test Details:</h3>
<h3 className="text-sm font-medium">{t('diagnostics.testDetails')}</h3>
<div className={`p-4 rounded-md font-mono text-xs overflow-x-auto ${status === 'error'
? 'bg-red-50 text-red-900 border border-red-200 dark:bg-red-950 dark:text-red-100 dark:border-red-900'
: 'bg-slate-950 text-slate-50'
@@ -149,12 +149,12 @@ export default function SettingsPage() {
{status === 'error' && (
<div className="text-sm text-red-600 dark:text-red-400 mt-2">
<p className="font-bold">Troubleshooting Tips:</p>
<p className="font-bold">{t('diagnostics.troubleshootingTitle')}</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Check that Ollama is running (<code className="bg-red-100 dark:bg-red-900 px-1 rounded">ollama list</code>)</li>
<li>Check URL (http://localhost:11434)</li>
<li>Verify model (e.g., granite4:latest) is downloaded</li>
<li>Check Next.js server terminal for more logs</li>
<li>{t('diagnostics.tip1')}</li>
<li>{t('diagnostics.tip2')}</li>
<li>{t('diagnostics.tip3')}</li>
<li>{t('diagnostics.tip4')}</li>
</ul>
</div>
)}
@@ -164,45 +164,45 @@ export default function SettingsPage() {
<div className="mt-4 flex justify-end">
<Button variant="outline" size="sm" onClick={checkConnection} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Test Connection
{t('general.testConnection')}
</Button>
</div>
</SettingsSection>
{/* Maintenance */}
<SettingsSection
title="Maintenance"
title={t('settings.maintenance')}
icon={<span className="text-2xl">🔧</span>}
description="Tools to maintain your database health"
description={t('settings.maintenanceDescription')}
>
<div className="space-y-4 py-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium flex items-center gap-2">
Clean Orphan Tags
{t('settings.cleanTags')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Remove tags that are no longer used by any notes
{t('settings.cleanTagsDescription')}
</p>
</div>
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading}>
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Database className="w-4 h-4 mr-2" />}
Clean
{t('general.clean')}
</Button>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium flex items-center gap-2">
Semantic Indexing
{t('settings.semanticIndexing')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Generate vectors for all notes to enable intent-based search
{t('settings.semanticIndexingDescription')}
</p>
</div>
<Button variant="secondary" onClick={handleSync} disabled={syncLoading}>
{syncLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <BrainCircuit className="w-4 h-4 mr-2" />}
Index All
{t('general.indexAll')}
</Button>
</div>
</div>

View File

@@ -21,13 +21,13 @@ export default function ProfileSettingsPage() {
const handleNameChange = async (value: string) => {
setUser(prev => ({ ...prev, name: value }))
// TODO: Implement profile update
console.log('Name:', value)
}
const handleEmailChange = async (value: string) => {
setUser(prev => ({ ...prev, email: value }))
// TODO: Implement email update
console.log('Email:', value)
}
const handleLanguageChange = async (value: string) => {
@@ -36,7 +36,7 @@ export default function ProfileSettingsPage() {
await updateAISettings({ preferredLanguage: value as any })
} catch (error) {
console.error('Error updating language:', error)
toast.error('Failed to save language')
toast.error(t('aiSettings.error'))
}
}
@@ -46,7 +46,7 @@ export default function ProfileSettingsPage() {
await updateAISettings({ showRecentNotes: enabled })
} catch (error) {
console.error('Error updating recent notes setting:', error)
toast.error('Failed to save setting')
toast.error(t('aiSettings.error'))
}
}
@@ -61,48 +61,48 @@ export default function ProfileSettingsPage() {
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Profile</h1>
<h1 className="text-3xl font-bold mb-2">{t('profile.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
Manage your account and personal information
{t('profile.description')}
</p>
</div>
{/* Profile Information */}
<SettingsSection
title="Profile Information"
title={t('profile.accountSettings')}
icon={<span className="text-2xl">👤</span>}
description="Update your personal details"
description={t('profile.description')}
>
<SettingInput
label="Name"
description="Your display name"
label={t('profile.displayName')}
description={t('profile.displayName')}
value={user.name}
onChange={handleNameChange}
placeholder="Enter your name"
placeholder={t('auth.namePlaceholder')}
/>
<SettingInput
label="Email"
description="Your email address"
label={t('profile.email')}
description={t('profile.email')}
value={user.email}
type="email"
onChange={handleEmailChange}
placeholder="Enter your email"
placeholder={t('auth.emailPlaceholder')}
/>
</SettingsSection>
{/* Preferences */}
<SettingsSection
title="Preferences"
title={t('settings.language')}
icon={<span className="text-2xl"></span>}
description="Customize your experience"
description={t('profile.languagePreferencesDescription')}
>
<SettingSelect
label="Language"
description="Choose your preferred language"
label={t('profile.preferredLanguage')}
description={t('profile.languageDescription')}
value={language}
options={[
{ value: 'auto', label: 'Auto-detect' },
{ value: 'auto', label: t('profile.autoDetect') },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
@@ -123,8 +123,8 @@ export default function ProfileSettingsPage() {
/>
<SettingToggle
label="Show Recent Notes"
description="Display recently viewed notes in sidebar"
label={t('profile.showRecentNotes')}
description={t('profile.showRecentNotesDescription')}
checked={showRecentNotes}
onChange={handleRecentNotesChange}
/>
@@ -135,16 +135,16 @@ export default function ProfileSettingsPage() {
<div className="flex items-center gap-4">
<div className="text-4xl"></div>
<div className="flex-1">
<h3 className="font-semibold text-lg mb-1">AI Settings</h3>
<h3 className="font-semibold text-lg mb-1">{t('aiSettings.title')}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Configure AI-powered features, provider selection, and preferences
{t('aiSettings.description')}
</p>
</div>
<button
onClick={() => window.location.href = '/settings/ai'}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Configure
{t('nav.configureAI')}
</button>
</div>
</div>

View File

@@ -7,40 +7,16 @@ import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { updateProfile, changePassword, updateLanguage, updateFontSize, updateShowRecentNotes } from '@/app/actions/profile'
import { updateProfile, changePassword, updateFontSize, updateShowRecentNotes } from '@/app/actions/profile'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
const LANGUAGES = [
{ value: 'auto', label: 'Auto-detect', flag: '🌐' },
{ value: 'en', label: 'English', flag: '🇬🇧' },
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
{ value: 'es', label: 'Español', flag: '🇪🇸' },
{ value: 'de', label: 'Deutsch', flag: '🇩🇪' },
{ value: 'it', label: 'Italiano', flag: '🇮🇹' },
{ value: 'pt', label: 'Português', flag: '🇵🇹' },
{ value: 'ru', label: 'Русский', flag: '🇷🇺' },
{ value: 'zh', label: '中文', flag: '🇨🇳' },
{ value: 'ja', label: '日本語', flag: '🇯🇵' },
{ value: 'ko', label: '한국어', flag: '🇰🇷' },
{ value: 'ar', label: 'العربية', flag: '🇸🇦' },
{ value: 'hi', label: 'हिन्दी', flag: '🇮🇳' },
{ value: 'nl', label: 'Nederlands', flag: '🇳🇱' },
{ value: 'pl', label: 'Polski', flag: '🇵🇱' },
{ value: 'fa', label: 'فارسی (Persian)', flag: '🇮🇷' },
]
export function ProfileForm({ user, userAISettings }: { user: any; userAISettings?: any }) {
const router = useRouter()
const [selectedLanguage, setSelectedLanguage] = useState(userAISettings?.preferredLanguage || 'auto')
const [isUpdatingLanguage, setIsUpdatingLanguage] = useState(false)
const [fontSize, setFontSize] = useState(userAISettings?.fontSize || 'medium')
const [isUpdatingFontSize, setIsUpdatingFontSize] = useState(false)
const [showRecentNotes, setShowRecentNotes] = useState(userAISettings?.showRecentNotes ?? false)
@@ -101,26 +77,7 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
applyFontSize(savedFontSize as string)
}, [])
const handleLanguageChange = async (language: string) => {
setIsUpdatingLanguage(true)
try {
const result = await updateLanguage(language)
if (result?.error) {
toast.error(t('profile.languageUpdateFailed'))
} else {
setSelectedLanguage(language)
// Update localStorage and reload to apply new language
localStorage.setItem('user-language', language)
toast.success(t('profile.languageUpdateSuccess'))
// Reload page to apply new language
setTimeout(() => window.location.reload(), 500)
}
} catch (error) {
toast.error(t('profile.languageUpdateFailed'))
} finally {
setIsUpdatingLanguage(false)
}
}
const handleShowRecentNotesChange = async (enabled: boolean) => {
setIsUpdatingRecentNotes(true)
@@ -175,39 +132,7 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('profile.languagePreferences')}</CardTitle>
<CardDescription>{t('profile.languagePreferencesDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="language">{t('profile.preferredLanguage')}</Label>
<Select
value={selectedLanguage}
onValueChange={handleLanguageChange}
disabled={isUpdatingLanguage}
>
<SelectTrigger id="language">
<SelectValue placeholder={t('profile.selectLanguage')} />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
<span className="flex items-center gap-2">
<span>{lang.flag}</span>
<span>{lang.label}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{t('profile.languageDescription')}
</p>
</div>
</CardContent>
</Card>

View File

@@ -1,75 +1,77 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useLanguage } from '@/lib/i18n';
export default function SupportPage() {
const { t } = useLanguage();
return (
<div className="container mx-auto py-10 max-w-4xl">
<div className="text-center mb-10">
<h1 className="text-4xl font-bold mb-4">
Support Memento Development
{t('support.title')}
</h1>
<p className="text-muted-foreground text-lg">
Memento is 100% free and open-source. Your support helps keep it that way.
{t('support.description')}
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 mb-10">
{/* Ko-fi Card */}
<Card className="border-2 border-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl"></span>
Buy me a coffee
{t('support.buyMeACoffee')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">
Make a one-time donation or become a monthly supporter.
{t('support.donationDescription')}
</p>
<Button asChild className="w-full">
<a href="https://ko-fi.com/yourusername" target="_blank" rel="noopener noreferrer">
Donate on Ko-fi
{t('support.donateOnKofi')}
</a>
</Button>
<p className="text-xs text-muted-foreground mt-2">
No platform fees Instant payouts Secure
{t('support.kofiDescription')}
</p>
</CardContent>
</Card>
{/* GitHub Sponsors Card */}
<Card className="border-2 border-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">💚</span>
Sponsor on GitHub
{t('support.sponsorOnGithub')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">
Become a monthly sponsor and get recognition.
{t('support.sponsorDescription')}
</p>
<Button asChild variant="outline" className="w-full">
<a href="https://github.com/sponsors/yourusername" target="_blank" rel="noopener noreferrer">
Sponsor on GitHub
{t('support.sponsorOnGithub')}
</a>
</Button>
<p className="text-xs text-muted-foreground mt-2">
Recurring support Public recognition Developer-focused
{t('support.githubDescription')}
</p>
</CardContent>
</Card>
</div>
{/* How Donations Are Used */}
<Card className="mb-10">
<CardHeader>
<CardTitle>How Your Support Helps</CardTitle>
<CardTitle>{t('support.howSupportHelps')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-4">
<div>
<h3 className="font-semibold mb-2">💰 Direct Impact</h3>
<h3 className="font-semibold mb-2">💰 {t('support.directImpact')}</h3>
<ul className="space-y-2 text-sm">
<li> Keeps me fueled with coffee</li>
<li>🐛 Covers hosting and server costs</li>
@@ -79,7 +81,7 @@ export default function SupportPage() {
</ul>
</div>
<div>
<h3 className="font-semibold mb-2">🎁 Sponsor Perks</h3>
<h3 className="font-semibold mb-2">🎁 {t('support.sponsorPerks')}</h3>
<ul className="space-y-2 text-sm">
<li>🥉 $5/month - Bronze: Name in supporters list</li>
<li>🥈 $15/month - Silver: Priority feature requests</li>
@@ -91,30 +93,29 @@ export default function SupportPage() {
</CardContent>
</Card>
{/* Transparency */}
<Card>
<CardHeader>
<CardTitle>💡 Transparency</CardTitle>
<CardTitle>💡 {t('support.transparency')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm mb-4">
I believe in complete transparency. Here's how donations are used:
{t('support.transparencyDescription')}
</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Hosting & servers:</span>
<span>{t('support.hostingServers')}</span>
<span className="font-mono">~$20/month</span>
</div>
<div className="flex justify-between">
<span>Domain & SSL:</span>
<span>{t('support.domainSSL')}</span>
<span className="font-mono">~$15/year</span>
</div>
<div className="flex justify-between">
<span>AI API costs:</span>
<span>{t('support.aiApiCosts')}</span>
<span className="font-mono">~$30/month</span>
</div>
<div className="flex justify-between border-t pt-2">
<span className="font-semibold">Total expenses:</span>
<span className="font-semibold">{t('support.totalExpenses')}</span>
<span className="font-mono font-semibold">~$50/month</span>
</div>
</div>
@@ -125,28 +126,27 @@ export default function SupportPage() {
</CardContent>
</Card>
{/* Alternative Ways to Support */}
<div className="mt-10 text-center">
<h2 className="text-2xl font-bold mb-4">Other Ways to Support</h2>
<h2 className="text-2xl font-bold mb-4">{t('support.otherWaysTitle')}</h2>
<div className="flex flex-wrap justify-center gap-4">
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
Star on GitHub
{t('support.starGithub')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento/issues" target="_blank" rel="noopener noreferrer">
🐛 Report a bug
🐛 {t('support.reportBug')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
📝 Contribute code
📝 {t('support.contributeCode')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://twitter.com/intent/tweet?text=Check%20out%20Memento%20-%20a%20great%20open-source%20note-taking%20app!%20https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
🐦 Share on Twitter
🐦 {t('support.shareTwitter')}
</a>
</Button>
</div>

View File

@@ -1,23 +1,28 @@
import { ArchiveHeader } from '@/components/archive-header'
import { Trash2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
export const dynamic = 'force-dynamic'
export default function TrashPage() {
// Currently, we don't have soft-delete implemented, so trash is always empty.
// This page exists to fix the 404 error and provide a placeholder.
return (
<main className="container mx-auto px-4 py-8 max-w-7xl">
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center text-gray-500">
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full mb-4">
<Trash2 className="w-12 h-12 text-gray-400" />
</div>
<h2 className="text-xl font-medium mb-2">La corbeille est vide</h2>
<p className="max-w-md text-sm opacity-80">
Les notes supprimées sont actuellement effacées définitivement.
</p>
</div>
<TrashContent />
</main>
)
}
function TrashContent() {
const { t } = useLanguage()
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center text-gray-500">
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full mb-4">
<Trash2 className="w-12 h-12 text-gray-400" />
</div>
<h2 className="text-xl font-medium mb-2">{t('trash.empty')}</h2>
<p className="max-w-md text-sm opacity-80">
{t('trash.restore')}
</p>
</div>
)
}

View File

@@ -16,7 +16,7 @@ async function checkAdmin() {
export async function testSMTP() {
const session = await checkAdmin()
const email = session.user?.email
if (!email) throw new Error("No admin email found")
const result = await sendEmail({
@@ -46,7 +46,7 @@ export async function updateSystemConfig(data: Record<string, string>) {
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
)
console.log('Updating system config:', filteredData)
const operations = Object.entries(filteredData).map(([key, value]) =>
prisma.systemConfig.upsert({

View File

@@ -2,7 +2,7 @@
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { revalidatePath, revalidateTag } from 'next/cache'
export type UserAISettingsData = {
titleSuggestions?: boolean
@@ -25,9 +25,9 @@ export type UserAISettingsData = {
* Update AI settings for the current user
*/
export async function updateAISettings(settings: UserAISettingsData) {
console.log('[updateAISettings] Started with:', JSON.stringify(settings, null, 2))
const session = await auth()
console.log('[updateAISettings] Session User ID:', session?.user?.id)
if (!session?.user?.id) {
console.error('[updateAISettings] Unauthorized: No session or user ID')
@@ -44,10 +44,11 @@ export async function updateAISettings(settings: UserAISettingsData) {
},
update: settings
})
console.log('[updateAISettings] Database upsert successful:', result)
revalidatePath('/settings/ai')
revalidatePath('/')
revalidatePath('/settings/ai', 'page')
revalidatePath('/', 'layout')
revalidateTag('ai-settings')
return { success: true }
} catch (error) {
@@ -57,8 +58,78 @@ export async function updateAISettings(settings: UserAISettingsData) {
}
/**
* Get AI settings for the current user
* Get AI settings for the current user (Cached)
*/
import { unstable_cache } from 'next/cache'
// Internal cached function to fetch settings from DB
const getCachedAISettings = unstable_cache(
async (userId: string) => {
try {
const settings = await prisma.userAISettings.findUnique({
where: { userId }
})
if (!settings) {
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
return {
titleSuggestions: settings.titleSuggestions,
semanticSearch: settings.semanticSearch,
paragraphRefactor: settings.paragraphRefactor,
memoryEcho: settings.memoryEcho,
memoryEchoFrequency: (settings.memoryEchoFrequency || 'daily') as 'daily' | 'weekly' | 'custom',
aiProvider: (settings.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama',
preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
demoMode: settings.demoMode,
showRecentNotes: settings.showRecentNotes,
emailNotifications: settings.emailNotifications,
desktopNotifications: settings.desktopNotifications,
anonymousAnalytics: settings.anonymousAnalytics,
// theme: 'light' as const, // REMOVED: Should not be handled here or hardcoded
fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large'
}
} catch (error) {
console.error('Error getting AI settings:', error)
// Return defaults on error
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
},
['user-ai-settings'],
{ tags: ['ai-settings'] }
)
export async function getAISettings(userId?: string) {
let id = userId
@@ -87,66 +158,7 @@ export async function getAISettings(userId?: string) {
}
}
try {
const settings = await prisma.userAISettings.findUnique({
where: { userId: id }
})
if (!settings) {
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
return {
titleSuggestions: settings.titleSuggestions,
semanticSearch: settings.semanticSearch,
paragraphRefactor: settings.paragraphRefactor,
memoryEcho: settings.memoryEcho,
memoryEchoFrequency: (settings.memoryEchoFrequency || 'daily') as 'daily' | 'weekly' | 'custom',
aiProvider: (settings.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama',
preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
demoMode: settings.demoMode,
showRecentNotes: settings.showRecentNotes,
emailNotifications: settings.emailNotifications,
desktopNotifications: settings.desktopNotifications,
anonymousAnalytics: settings.anonymousAnalytics,
// theme: 'light' as const, // REMOVED: Should not be handled here or hardcoded
fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large'
}
} catch (error) {
console.error('Error getting AI settings:', error)
// Return defaults on error
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
return getCachedAISettings(id)
}
/**

View File

@@ -346,7 +346,7 @@ export async function createNote(data: {
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70);
if (autoLabelingEnabled) {
console.log('[AUTO-LABELING] Generating suggestions for new note in notebook:', data.notebookId);
const suggestions = await contextualAutoTagService.suggestLabels(
data.content,
data.notebookId,
@@ -360,12 +360,12 @@ export async function createNote(data: {
if (appliedLabels.length > 0) {
labelsToUse = appliedLabels;
console.log(`[AUTO-LABELING] Applied ${appliedLabels.length} labels:`, appliedLabels);
} else {
console.log('[AUTO-LABELING] No suggestions met confidence threshold');
}
} else {
console.log('[AUTO-LABELING] Disabled in config');
}
} catch (error) {
console.error('[AUTO-LABELING] Failed to suggest labels:', error);

View File

@@ -0,0 +1,57 @@
'use server'
interface OllamaModel {
name: string
modified_at: string
size: number
digest: string
details: {
format: string
family: string
families: string[]
parameter_size: string
quantization_level: string
}
}
interface OllamaTagsResponse {
models: OllamaModel[]
}
export async function getOllamaModels(baseUrl: string): Promise<{ success: boolean; models: string[]; error?: string }> {
if (!baseUrl) {
return { success: false, models: [], error: 'Base URL is required' }
}
// Ensure URL doesn't end with slash
const cleanUrl = baseUrl.replace(/\/$/, '')
try {
const response = await fetch(`${cleanUrl}/api/tags`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
// Set a reasonable timeout
signal: AbortSignal.timeout(5000)
})
if (!response.ok) {
throw new Error(`Ollama API returned ${response.status}: ${response.statusText}`)
}
const data = await response.json() as OllamaTagsResponse
// Extract model names
const modelNames = data.models?.map(m => m.name) || []
return { success: true, models: modelNames }
} catch (error: any) {
console.error('Failed to fetch Ollama models:', error)
return {
success: false,
models: [],
error: error.message || 'Failed to connect to Ollama'
}
}
}

View File

@@ -2,7 +2,7 @@
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { revalidatePath, revalidateTag } from 'next/cache'
export type UserSettingsData = {
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
@@ -12,7 +12,7 @@ export type UserSettingsData = {
* Update user settings (theme, etc.)
*/
export async function updateUserSettings(settings: UserSettingsData) {
console.log('[updateUserSettings] Started with:', settings)
const session = await auth()
if (!session?.user?.id) {
@@ -25,9 +25,10 @@ export async function updateUserSettings(settings: UserSettingsData) {
where: { id: session.user.id },
data: settings
})
console.log('[updateUserSettings] Success:', result)
revalidatePath('/', 'layout')
revalidateTag('user-settings')
return { success: true }
} catch (error) {
@@ -37,8 +38,33 @@ export async function updateUserSettings(settings: UserSettingsData) {
}
/**
* Get user settings for current user
* Get user settings for current user (Cached)
*/
import { unstable_cache } from 'next/cache'
// Internal cached function
const getCachedUserSettings = unstable_cache(
async (userId: string) => {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { theme: true }
})
return {
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
}
} catch (error) {
console.error('Error getting user settings:', error)
return {
theme: 'light' as const
}
}
},
['user-settings'],
{ tags: ['user-settings'] }
)
export async function getUserSettings(userId?: string) {
let id = userId
@@ -53,19 +79,5 @@ export async function getUserSettings(userId?: string) {
}
}
try {
const user = await prisma.user.findUnique({
where: { id },
select: { theme: true }
})
return {
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
}
} catch (error) {
console.error('Error getting user settings:', error)
return {
theme: 'light' as const
}
}
return getCachedUserSettings(id)
}

View File

@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
}
const body = await request.json()
const { notebookId } = body
const { notebookId, language = 'en' } = body
if (!notebookId || typeof notebookId !== 'string') {
return NextResponse.json(
@@ -45,7 +45,8 @@ export async function POST(request: NextRequest) {
// Get label suggestions
const suggestions = await autoLabelCreationService.suggestLabels(
notebookId,
session.user.id
session.user.id,
language
)
if (!suggestions) {

View File

@@ -16,9 +16,25 @@ export async function POST(request: NextRequest) {
)
}
// Get language from request headers or body
let language = 'en'
try {
const body = await request.json()
if (body.language) {
language = body.language
}
} catch (e) {
// If no body or invalid json, check headers
const acceptLanguage = request.headers.get('accept-language')
if (acceptLanguage) {
language = acceptLanguage.split(',')[0].split('-')[0]
}
}
// Create organization plan
const plan = await batchOrganizationService.createOrganizationPlan(
session.user.id
session.user.id,
language
)
return NextResponse.json({

View File

@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
}
const body = await request.json()
const { notebookId } = body
const { notebookId, language = 'en' } = body
if (!notebookId || typeof notebookId !== 'string') {
return NextResponse.json(
@@ -45,7 +45,8 @@ export async function POST(request: NextRequest) {
// Generate summary
const summary = await notebookSummaryService.generateSummary(
notebookId,
session.user.id
session.user.id,
language
)
if (!summary) {

View File

@@ -10,7 +10,7 @@ export async function POST(req: NextRequest) {
}
const body = await req.json()
const { noteContent } = body
const { noteContent, language = 'en' } = body
if (!noteContent || typeof noteContent !== 'string') {
return NextResponse.json({ error: 'noteContent is required' }, { status: 400 })
@@ -29,7 +29,8 @@ export async function POST(req: NextRequest) {
// Get suggestion from AI service
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
noteContent,
session.user.id
session.user.id,
language
)
return NextResponse.json({

View File

@@ -8,6 +8,7 @@ import { z } from 'zod';
const requestSchema = z.object({
content: z.string().min(1, "Le contenu ne peut pas être vide"),
notebookId: z.string().optional(),
language: z.string().default('en'),
});
export async function POST(req: NextRequest) {
@@ -18,14 +19,15 @@ export async function POST(req: NextRequest) {
}
const body = await req.json();
const { content, notebookId } = requestSchema.parse(body);
const { content, notebookId, language } = requestSchema.parse(body);
// If notebookId is provided, use contextual suggestions (IA2)
if (notebookId) {
const suggestions = await contextualAutoTagService.suggestLabels(
content,
notebookId,
session.user.id
session.user.id,
language
);
// Convert label → tag to match TagSuggestion interface
@@ -37,7 +39,7 @@ export async function POST(req: NextRequest) {
...(s.isNewLabel !== undefined && { isNewLabel: s.isNewLabel })
}));
return NextResponse.json({ tags: convertedTags });
return NextResponse.json({ tags: convertedTags });
}
// Otherwise, use legacy auto-tagging (generates new tags)

View File

@@ -27,7 +27,7 @@ export const viewport: Viewport = {
themeColor: "#f59e0b",
};
export const dynamic = "force-dynamic";
import { getAISettings } from "@/app/actions/ai-settings";
import { getUserSettings } from "@/app/actions/user-settings";
@@ -59,8 +59,7 @@ export default async function RootLayout({
getUserSettings(userId)
])
console.log('[RootLayout] Auth user:', userId)
console.log('[RootLayout] Server fetched user settings:', userSettings)
return (
<html suppressHydrationWarning>