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[] }) {
const { t } = useLanguage()
// Optimistic update could be implemented here, but standard is fine for admin
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

@ -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>

View File

@ -127,7 +127,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
<FeatureToggle
name={t('titleSuggestions.available').replace('💡 ', '')}
description="Suggest titles for untitled notes after 50+ words"
description={t('aiSettings.titleSuggestionsDesc')}
checked={settings.titleSuggestions}
onChange={(checked) => handleToggle('titleSuggestions', checked)}
/>
@ -141,7 +141,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
<FeatureToggle
name={t('paragraphRefactor.title')}
description="AI-powered text improvement options"
description={t('aiSettings.paragraphRefactorDesc')}
checked={settings.paragraphRefactor}
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
/>
@ -159,7 +159,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
{t('aiSettings.frequency')}
</Label>
<p className="text-xs text-gray-500 mb-3">
How often to analyze note connections
{t('aiSettings.frequencyDesc')}
</p>
<RadioGroup
value={settings.memoryEchoFrequency}
@ -192,7 +192,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
<Card className="p-4">
<Label className="text-base font-medium mb-1">{t('aiSettings.provider')}</Label>
<p className="text-sm text-gray-500 mb-4">
Choose your preferred AI provider
{t('aiSettings.providerDesc')}
</p>
<RadioGroup
@ -206,7 +206,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
{t('aiSettings.providerAuto')}
</Label>
<p className="text-sm text-gray-500">
Ollama when available, OpenAI fallback
{t('aiSettings.providerAutoDesc')}
</p>
</div>
</div>
@ -218,7 +218,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
{t('aiSettings.providerOllama')}
</Label>
<p className="text-sm text-gray-500">
100% private, runs locally on your machine
{t('aiSettings.providerOllamaDesc')}
</p>
</div>
</div>
@ -230,7 +230,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
{t('aiSettings.providerOpenAI')}
</Label>
<p className="text-sm text-gray-500">
Most accurate, requires API key
{t('aiSettings.providerOpenAIDesc')}
</p>
</div>
</div>

View File

@ -55,7 +55,10 @@ export function AutoLabelSuggestionDialog({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ notebookId }),
body: JSON.stringify({
notebookId,
language: document.documentElement.lang || 'en',
}),
})
const data = await response.json()
@ -68,7 +71,7 @@ export function AutoLabelSuggestionDialog({
} else {
// No suggestions is not an error - just close the dialog
if (data.message) {
}
}
onOpenChange(false)
}
} catch (error) {
@ -113,7 +116,7 @@ export function AutoLabelSuggestionDialog({
if (data.success) {
toast.success(
t('ai.autoLabels.created', { count: data.data.createdCount }) ||
`${data.data.createdCount} labels created successfully`
`${data.data.createdCount} labels created successfully`
)
onLabelsCreated()
onOpenChange(false)

View File

@ -38,7 +38,11 @@ export function BatchOrganizationDialog({
try {
const response = await fetch('/api/ai/batch-organize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
language: document.documentElement.lang || 'en'
}),
})
const data = await response.json()
@ -125,7 +129,7 @@ export function BatchOrganizationDialog({
if (data.success) {
toast.success(
t('ai.batchOrganization.success', { count: data.data.movedCount }) ||
`${data.data.movedCount} notes moved successfully`
`${data.data.movedCount} notes moved successfully`
)
onNotesMoved()
onOpenChange(false)
@ -306,7 +310,7 @@ export function BatchOrganizationDialog({
) : (
<>
<CheckCircle2 className="h-4 w-4 mr-2" />
{t('ai.batchOrganization.apply')}
{t('ai.batchOrganization.apply', { count: selectedNotes.size })}
</>
)}
</Button>

View File

@ -21,13 +21,13 @@ export function DemoModeToggle({ demoMode, onToggle }: DemoModeToggleProps) {
try {
await onToggle(checked)
if (checked) {
toast.success('🧪 Demo Mode activated! Memory Echo will now work instantly.')
toast.success(t('demoMode.activated'))
} else {
toast.success('Demo Mode disabled. Normal parameters restored.')
toast.success(t('demoMode.deactivated'))
}
} catch (error) {
console.error('Error toggling demo mode:', error)
toast.error('Failed to toggle demo mode')
toast.error(t('demoMode.toggleFailed'))
} finally {
setIsPending(false)
}
@ -53,14 +53,11 @@ export function DemoModeToggle({ demoMode, onToggle }: DemoModeToggleProps) {
</div>
<div>
<CardTitle className="text-base flex items-center gap-2">
🧪 Demo Mode
🧪 {t('demoMode.title')}
{demoMode && <Zap className="h-4 w-4 text-amber-500 animate-pulse" />}
</CardTitle>
<CardDescription className="text-xs mt-1">
{demoMode
? 'Test Memory Echo instantly with relaxed parameters'
: 'Enable instant testing of Memory Echo feature'
}
{t('demoMode.description')}
</CardDescription>
</div>
</div>
@ -77,31 +74,25 @@ export function DemoModeToggle({ demoMode, onToggle }: DemoModeToggleProps) {
<CardContent className="pt-0 space-y-2">
<div className="rounded-lg bg-white dark:bg-zinc-900 border border-amber-200 dark:border-amber-900/30 p-3">
<p className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
Demo parameters active:
{t('demoMode.parametersActive')}
</p>
<div className="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
<div className="flex items-start gap-2">
<Target className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
<span>
<strong>50% similarity</strong> threshold (normally 75%)
</span>
<span>{t('demoMode.similarityThreshold')}</span>
</div>
<div className="flex items-start gap-2">
<Zap className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
<span>
<strong>0-day delay</strong> between notes (normally 7 days)
</span>
<span>{t('demoMode.delayBetweenNotes')}</span>
</div>
<div className="flex items-start gap-2">
<Lightbulb className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
<span>
<strong>Unlimited insights</strong> (no frequency limits)
</span>
<span>{t('demoMode.unlimitedInsights')}</span>
</div>
</div>
</div>
<p className="text-xs text-amber-700 dark:text-amber-400 text-center">
💡 Create 2+ similar notes and see Memory Echo in action!
💡 {t('demoMode.createNotesTip')}
</p>
</CardContent>
)}

View File

@ -53,7 +53,7 @@ export function FavoritesSection({ pinnedNotes, onEdit, isLoading }: FavoritesSe
<div className="flex items-center gap-2">
<span className="text-2xl">📌</span>
<h2 className="text-lg font-semibold text-foreground">
Pinned Notes
{t('notes.pinnedNotes')}
<span className="text-sm font-medium text-muted-foreground ml-2">
({pinnedNotes.length})
</span>

View File

@ -77,7 +77,7 @@ export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, on
onSelectTag(suggestion.tag);
}}
className={cn("flex items-center px-3 py-1 text-xs font-medium", colorClasses.text)}
title={isNewLabel ? "Créer ce nouveau label et l'ajouter" : t('ai.clickToAddTag')}
title={isNewLabel ? t('ai.autoLabels.createNewLabel') : t('ai.clickToAddTag')}
>
{isNewLabel && <Plus className="w-3 h-3 mr-1" />}
{!isNewLabel && <Sparkles className="w-3 h-3 mr-1.5 opacity-50" />}

View File

@ -355,7 +355,7 @@ export function Header({
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push('/admin')} className="cursor-pointer">
<Shield className="mr-2 h-4 w-4" />
<span>Admin</span>
<span>{t('nav.adminDashboard')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => signOut()} className="cursor-pointer text-red-600 focus:text-red-600">
<LogOut className="mr-2 h-4 w-4" />

View File

@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@ -33,6 +34,7 @@ interface MemoryEchoNotificationProps {
}
export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationProps) {
const { t } = useLanguage()
const [insight, setInsight] = useState<MemoryEchoInsight | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isDismissed, setIsDismissed] = useState(false)
@ -137,9 +139,9 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h2 className="text-xl font-semibold">💡 Memory Echo Discovery</h2>
<h2 className="text-xl font-semibold">{t('memoryEcho.title')}</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
These notes are connected by {similarityPercentage}% similarity
{t('connection.similarityInfo', { similarity: similarityPercentage })}
</p>
</div>
</div>
@ -179,7 +181,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-4">
{insight.note1.content}
</p>
<p className="text-xs text-gray-500 mt-2">Click to view note </p>
<p className="text-xs text-gray-500 mt-2">{t('memoryEcho.clickToView')}</p>
</div>
{/* Note 2 */}
@ -198,37 +200,35 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-4">
{insight.note2.content}
</p>
<p className="text-xs text-gray-500 mt-2">Click to view note </p>
<p className="text-xs text-gray-500 mt-2">{t('memoryEcho.clickToView')}</p>
</div>
</div>
{/* Feedback Section */}
<div className="flex items-center justify-between px-6 py-4 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
<p className="text-sm text-gray-600 dark:text-gray-400">
Is this connection helpful?
{t('connection.isHelpful')}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => handleFeedback('thumbs_up')}
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${
insight.feedback === 'thumbs_up'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'hover:bg-green-50 text-green-600 dark:hover:bg-green-950/20'
}`}
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${insight.feedback === 'thumbs_up'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'hover:bg-green-50 text-green-600 dark:hover:bg-green-950/20'
}`}
>
<ThumbsUp className="h-4 w-4" />
Helpful
{t('memoryEcho.helpful')}
</button>
<button
onClick={() => handleFeedback('thumbs_down')}
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${
insight.feedback === 'thumbs_down'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'hover:bg-red-50 text-red-600 dark:hover:bg-red-950/20'
}`}
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${insight.feedback === 'thumbs_down'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'hover:bg-red-50 text-red-600 dark:hover:bg-red-950/20'
}`}
>
<ThumbsDown className="h-4 w-4" />
Not Helpful
{t('memoryEcho.notHelpful')}
</button>
</div>
</div>
@ -248,11 +248,11 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
</div>
<div>
<CardTitle className="text-base flex items-center gap-2">
💡 I noticed something...
{t('memoryEcho.title')}
<Sparkles className="h-4 w-4 text-amber-500" />
</CardTitle>
<CardDescription className="text-xs mt-1">
Proactive connections between your notes
{t('memoryEcho.description')}
</CardDescription>
</div>
</div>
@ -298,7 +298,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
onClick={handleView}
>
View Connection
{t('memoryEcho.viewConnection')}
</Button>
<div className="flex items-center gap-1 border-l pl-2">
@ -307,7 +307,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
size="icon"
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
onClick={() => handleFeedback('thumbs_up')}
title="Helpful"
title={t('memoryEcho.helpful')}
>
<ThumbsUp className="h-4 w-4" />
</Button>
@ -316,7 +316,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
size="icon"
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => handleFeedback('thumbs_down')}
title="Not Helpful"
title={t('memoryEcho.notHelpful')}
>
<ThumbsDown className="h-4 w-4" />
</Button>
@ -328,7 +328,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
className="w-full text-center text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 py-1"
onClick={handleDismiss}
>
Dismiss for now
{t('memoryEcho.dismiss')}
</button>
</CardContent>
</Card>

View File

@ -1122,7 +1122,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
}
}
toast.success('Notes fusionnées avec succès !')
toast.success(t('toast.notesFusionSuccess'))
triggerRefresh()
onClose()
}}

View File

@ -57,7 +57,10 @@ export function NotebookSuggestionToast({
const response = await fetch('/api/ai/suggest-notebook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteContent })
body: JSON.stringify({
noteContent,
language: document.documentElement.lang || 'en',
})
})
const data = await response.json()

View File

@ -52,7 +52,10 @@ export function NotebookSummaryDialog({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ notebookId }),
body: JSON.stringify({
notebookId,
language: document.documentElement.lang || 'en',
}),
})
const data = await response.json()
@ -82,6 +85,10 @@ export function NotebookSummaryDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader className="sr-only">
<DialogTitle>{t('notebook.generating')}</DialogTitle>
<DialogDescription>{t('notebook.generatingDescription') || 'Please wait...'}</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="mt-4 text-sm text-muted-foreground">

View File

@ -1,7 +1,7 @@
'use client'
import { Note } from '@/lib/types'
import { Clock, FileText, Tag } from 'lucide-react'
import { Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
@ -11,27 +11,24 @@ interface RecentNotesSectionProps {
}
export function RecentNotesSection({ recentNotes, onEdit }: RecentNotesSectionProps) {
const { language } = useLanguage()
const { t } = useLanguage()
// Show only the 3 most recent notes
const topThree = recentNotes.slice(0, 3)
if (topThree.length === 0) return null
return (
<section data-testid="recent-notes-section" className="mb-6">
{/* Minimalist header - matching your app style */}
<div className="flex items-center gap-2 mb-3 px-1">
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{language === 'fr' ? 'Récent' : 'Recent'}
{t('notes.recent')}
</span>
<span className="text-xs text-muted-foreground">
· {topThree.length}
</span>
</div>
{/* Compact 3-card row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{topThree.map((note, index) => (
<CompactCard
@ -46,7 +43,6 @@ export function RecentNotesSection({ recentNotes, onEdit }: RecentNotesSectionPr
)
}
// Compact card - matching your app's clean design
function CompactCard({
note,
index,
@ -56,9 +52,8 @@ function CompactCard({
index: number
onEdit?: (note: Note, readOnly?: boolean) => void
}) {
const { language } = useLanguage()
// Use contentUpdatedAt - only reflects actual content changes, not property changes (size, color, etc.)
const timeAgo = getCompactTime(note.contentUpdatedAt || note.updatedAt, language)
const { t } = useLanguage()
const timeAgo = getCompactTime(note.contentUpdatedAt || note.updatedAt, t)
const isFirstNote = index === 0
return (
@ -69,7 +64,6 @@ function CompactCard({
isFirstNote && "ring-2 ring-primary/20"
)}
>
{/* Subtle left accent - colored based on recency */}
<div className={cn(
"absolute left-0 top-0 bottom-0 w-1 rounded-l-xl",
isFirstNote
@ -79,74 +73,54 @@ function CompactCard({
: "bg-muted dark:bg-muted/60"
)} />
{/* Content with left padding for accent line */}
<div className="pl-2">
{/* Title */}
<h3 className="text-sm font-semibold text-foreground line-clamp-1 mb-2">
{note.title || (language === 'fr' ? 'Sans titre' : 'Untitled')}
{note.title || t('notes.untitled')}
</h3>
{/* Preview - 2 lines max */}
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2.5rem]">
{note.content?.substring(0, 80) || ''}
{note.content && note.content.length > 80 && '...'}
</p>
{/* Footer with time and indicators */}
<div className="flex items-center justify-between pt-2 border-t border-border">
{/* Time - left */}
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
<span className="font-medium">{timeAgo}</span>
</span>
{/* Indicators - right */}
<div className="flex items-center gap-1.5">
{/* Notebook indicator */}
{note.notebookId && (
<div className="w-1.5 h-1.5 rounded-full bg-primary dark:bg-primary/70" title="In notebook" />
<div className="w-1.5 h-1.5 rounded-full bg-primary dark:bg-primary/70" title={t('notes.inNotebook')} />
)}
{/* Labels indicator */}
{note.labels && note.labels.length > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" title={`${note.labels.length} ${language === 'fr' ? 'étiquettes' : 'labels'}`} />
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" title={t('labels.count', { count: note.labels.length })} />
)}
</div>
</div>
</div>
{/* Hover indicator - top right */}
<div className="absolute top-3 right-3 w-2 h-2 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
</button>
)
}
// Compact time display - matching your app's style
// NOTE: Ensure dates are properly parsed from database (may come as strings)
function getCompactTime(date: Date | string, language: string): string {
function getCompactTime(date: Date | string, t: (key: string, params?: Record<string, any>) => string): string {
const now = new Date()
const then = date instanceof Date ? date : new Date(date)
// Validate date
if (isNaN(then.getTime())) {
console.warn('Invalid date provided to getCompactTime:', date)
return language === 'fr' ? 'date invalide' : 'invalid date'
return t('common.error')
}
const seconds = Math.floor((now.getTime() - then.getTime()) / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (language === 'fr') {
if (seconds < 60) return 'à l\'instant'
if (minutes < 60) return `il y a ${minutes}m`
if (hours < 24) return `il y a ${hours}h`
const days = Math.floor(hours / 24)
return `il y a ${days}j`
} else {
if (seconds < 60) return 'just now'
if (minutes < 60) return `${minutes}m ago`
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
if (seconds < 60) return t('time.justNow')
if (minutes < 60) return t('time.minutesAgo', { count: minutes })
if (hours < 24) return t('time.hoursAgo', { count: hours })
const days = Math.floor(hours / 24)
return t('time.daysAgo', { count: days })
}

View File

@ -1,7 +1,10 @@
'use client'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useState, useEffect } from "react"
import { useLanguage } from '@/lib/i18n'
interface ReminderDialogProps {
open: boolean
@ -18,6 +21,7 @@ export function ReminderDialog({
onSave,
onRemove
}: ReminderDialogProps) {
const { t } = useLanguage()
const [reminderDate, setReminderDate] = useState('')
const [reminderTime, setReminderTime] = useState('')
@ -51,7 +55,6 @@ export function ReminderDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement;
const isSonnerElement =
@ -75,12 +78,12 @@ export function ReminderDialog({
}}
>
<DialogHeader>
<DialogTitle>Set Reminder</DialogTitle>
<DialogTitle>{t('reminder.setReminder')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="reminder-date" className="text-sm font-medium">
Date
{t('reminder.reminderDate')}
</label>
<Input
id="reminder-date"
@ -92,7 +95,7 @@ export function ReminderDialog({
</div>
<div className="space-y-2">
<label htmlFor="reminder-time" className="text-sm font-medium">
Time
{t('reminder.reminderTime')}
</label>
<Input
id="reminder-time"
@ -107,16 +110,16 @@ export function ReminderDialog({
<div>
{currentReminder && (
<Button variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
Remove Reminder
{t('reminder.removeReminder')}
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
{t('reminder.cancel')}
</Button>
<Button onClick={handleSave}>
Set Reminder
{t('reminder.save')}
</Button>
</div>
</div>

View File

@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label'
import { Loader2, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
interface SettingInputProps {
label: string
@ -25,6 +26,7 @@ export function SettingInput({
placeholder,
disabled
}: SettingInputProps) {
const { t } = useLanguage()
const [isLoading, setIsLoading] = useState(false)
const [isSaved, setIsSaved] = useState(false)
@ -35,15 +37,12 @@ export function SettingInput({
try {
await onChange(newValue)
setIsSaved(true)
toast.success('Setting saved')
toast.success(t('toast.saved'))
// Clear saved indicator after 2 seconds
setTimeout(() => setIsSaved(false), 2000)
} catch (err) {
console.error('Error updating setting:', err)
toast.error('Failed to save setting', {
description: 'Please try again'
})
toast.error(t('toast.saveFailed'))
} finally {
setIsLoading(false)
}

View File

@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
interface SelectOption {
value: string
@ -29,6 +30,7 @@ export function SettingSelect({
onChange,
disabled
}: SettingSelectProps) {
const { t } = useLanguage()
const [isLoading, setIsLoading] = useState(false)
const handleChange = async (newValue: string) => {
@ -36,14 +38,10 @@ export function SettingSelect({
try {
await onChange(newValue)
toast.success('Setting saved', {
description: `${label} has been updated`
})
toast.success(t('toast.saved'))
} catch (err) {
console.error('Error updating setting:', err)
toast.error('Failed to save setting', {
description: 'Please try again'
})
toast.error(t('toast.saveFailed'))
} finally {
setIsLoading(false)
}

View File

@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label'
import { Loader2, Check, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
interface SettingToggleProps {
label: string
@ -22,6 +23,7 @@ export function SettingToggle({
onChange,
disabled
}: SettingToggleProps) {
const { t } = useLanguage()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(false)
@ -31,15 +33,11 @@ export function SettingToggle({
try {
await onChange(newChecked)
toast.success('Setting saved', {
description: `${label} has been ${newChecked ? 'enabled' : 'disabled'}`
})
toast.success(t('toast.saved'))
} catch (err) {
console.error('Error updating setting:', err)
setError(true)
toast.error('Failed to save setting', {
description: 'Please try again'
})
toast.error(t('toast.saveFailed'))
} finally {
setIsLoading(false)
}

View File

@ -4,6 +4,7 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Settings, Sparkles, Palette, User, Database, Info, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
interface SettingsSection {
id: string
@ -18,41 +19,42 @@ interface SettingsNavProps {
export function SettingsNav({ className }: SettingsNavProps) {
const pathname = usePathname()
const { t } = useLanguage()
const sections: SettingsSection[] = [
{
id: 'general',
label: 'General',
label: t('generalSettings.title'),
icon: <Settings className="h-5 w-5" />,
href: '/settings/general'
},
{
id: 'ai',
label: 'AI',
label: t('aiSettings.title'),
icon: <Sparkles className="h-5 w-5" />,
href: '/settings/ai'
},
{
id: 'appearance',
label: 'Appearance',
label: t('appearance.title'),
icon: <Palette className="h-5 w-5" />,
href: '/settings/appearance'
},
{
id: 'profile',
label: 'Profile',
label: t('profile.title'),
icon: <User className="h-5 w-5" />,
href: '/settings/profile'
},
{
id: 'data',
label: 'Data',
label: t('dataManagement.title'),
icon: <Database className="h-5 w-5" />,
href: '/settings/data'
},
{
id: 'about',
label: 'About',
label: t('about.title'),
icon: <Info className="h-5 w-5" />,
href: '/settings/about'
}

View File

@ -109,11 +109,11 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
{/* Footer / Copyright / Terms */}
<div className="mt-auto px-6 py-4 text-[10px] text-gray-400">
<div className="flex gap-2 mb-1">
<Link href="#" className="hover:underline">Confidentialité</Link>
<Link href="#" className="hover:underline">{t('footer.privacy')}</Link>
<span></span>
<Link href="#" className="hover:underline">Conditions</Link>
<Link href="#" className="hover:underline">{t('footer.terms')}</Link>
</div>
<p>Open Source Clone</p>
<p>{t('footer.openSource')}</p>
</div>
</aside>
)

View File

@ -14,10 +14,12 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useSession, signOut } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { LogOut, Settings, User, Shield } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
export function UserNav({ user }: { user?: any }) {
const { data: session } = useSession()
const router = useRouter()
const { t } = useLanguage()
const currentUser = user || session?.user
@ -51,23 +53,23 @@ export function UserNav({ user }: { user?: any }) {
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push('/settings/profile')}>
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
<span>{t('nav.profile')}</span>
</DropdownMenuItem>
{userRole === 'ADMIN' && (
<DropdownMenuItem onClick={() => router.push('/admin')}>
<Shield className="mr-2 h-4 w-4" />
<span>Admin Dashboard</span>
<span>{t('nav.adminDashboard')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => router.push('/settings')}>
<Settings className="mr-2 h-4 w-4" />
<span>Diagnostics</span>
<span>{t('nav.diagnostics')}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut({ callbackUrl: '/login' })}>
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
<span>{t('nav.logout')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -41,7 +41,8 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: contentToAnalyze,
notebookId: notebookId || undefined, // Pass notebookId for contextual suggestions (IA2)
notebookId: notebookId || undefined,
language: document.documentElement.lang || 'en',
}),
});

View File

@ -28,7 +28,7 @@ export class AutoLabelCreationService {
* @param userId - User ID (for authorization)
* @returns Suggested labels or null if not enough notes/no patterns found
*/
async suggestLabels(notebookId: string, userId: string): Promise<AutoLabelSuggestion | null> {
async suggestLabels(notebookId: string, userId: string, language: string = 'en'): Promise<AutoLabelSuggestion | null> {
// 1. Get notebook with existing labels
const notebook = await prisma.notebook.findFirst({
where: {
@ -84,7 +84,7 @@ export class AutoLabelCreationService {
}
// 2. Use AI to detect recurring themes
const suggestions = await this.detectRecurringThemes(notes, notebook)
const suggestions = await this.detectRecurringThemes(notes, notebook, language)
return suggestions
}
@ -94,13 +94,14 @@ export class AutoLabelCreationService {
*/
private async detectRecurringThemes(
notes: any[],
notebook: any
notebook: any,
language: string
): Promise<AutoLabelSuggestion | null> {
const existingLabelNames = new Set<string>(
notebook.labels.map((l: any) => l.name.toLowerCase())
)
const prompt = this.buildPrompt(notes, existingLabelNames)
const prompt = this.buildPrompt(notes, existingLabelNames, language)
try {
const config = await getSystemConfig()
@ -128,9 +129,9 @@ export class AutoLabelCreationService {
}
/**
* Build prompt for AI (always in French - interface language)
* Build prompt for AI (localized)
*/
private buildPrompt(notes: any[], existingLabelNames: Set<string>): string {
private buildPrompt(notes: any[], existingLabelNames: Set<string>, language: string = 'en'): string {
const notesSummary = notes
.map((note, index) => {
const title = note.title || 'Sans titre'
@ -141,7 +142,8 @@ export class AutoLabelCreationService {
const existingLabels = Array.from(existingLabelNames).join(', ')
return `
const instructions: Record<string, string> = {
fr: `
Tu es un assistant qui détecte les thèmes récurrents dans des notes pour suggérer de nouvelles étiquettes.
CARNET ANALYSÉ :
@ -182,7 +184,178 @@ Exemples de bonnes étiquettes :
- "marie", "jean", "équipe" (personnes)
Ta réponse (JSON seulement) :
`.trim(),
en: `
You are an assistant that detects recurring themes in notes to suggest new labels.
ANALYZED NOTEBOOK:
${notes.length} notes
EXISTING LABELS (do not suggest these):
${existingLabels || 'None'}
NOTEBOOK NOTES:
${notesSummary}
TASK:
Analyze the notes and detect recurring themes (keywords, subjects, places, people).
A theme must appear in at least 5 different notes to be suggested.
RESPONSE FORMAT (JSON):
{
"labels": [
{
"nom": "label_name",
"note_indices": [0, 5, 12, 23, 45],
"confiance": 0.85
}
]
}
RULES:
- Label name must be short (max 1-2 words)
- A theme must appear in 5+ notes to be suggested
- Confidence must be > 0.60
- Do not suggest labels that already exist
- Prioritize places, people, clear categories
- Maximum 5 suggestions
Examples of good labels:
- "tokyo", "kyoto", "osaka" (places)
- "hotels", "restaurants", "flights" (categories)
- "mary", "john", "team" (people)
Your response (JSON only):
`.trim(),
fa: `
شما یک دستیار هستید که تمهای تکرارشونده در یادداشتها را برای پیشنهاد برچسبهای جدید شناسایی میکنید.
دفترچه تحلیل شده:
${notes.length} یادداشت
برچسبهای موجود (اینها را پیشنهاد ندهید):
${existingLabels || 'هیچ'}
یادداشتهای دفترچه:
${notesSummary}
وظیفه:
یادداشتها را تحلیل کنید و تمهای تکرارشونده (کلمات کلیدی، موضوعات، مکانها، افراد) را شناسایی کنید.
یک تم باید حداقل در ۵ یادداشت مختلف ظاهر شود تا پیشنهاد داده شود.
فرمت پاسخ (JSON):
{
"labels": [
{
"nom": ام_برچسب",
"note_indices": [0, 5, 12, 23, 45],
"confiance": 0.85
}
]
}
قوانین:
- نام برچسب باید کوتاه باشد (حداکثر ۱-۲ کلمه)
- یک تم باید در ۵+ یادداشت ظاهر شود تا پیشنهاد داده شود
- اطمینان باید > 0.60 باشد
- برچسبهایی که قبلاً وجود دارند را پیشنهاد ندهید
- اولویت با مکانها، افراد، دستهبندیهای واضح است
- حداکثر ۵ پیشنهاد
مثالهای برچسب خوب:
- "توکیو"، "کیوتو"، "اوزاکا" (مکانها)
- "هتل‌ها"، "رستوران‌ها"، "پروازها" (دستهبندیها)
- "مریم"، "علی"، "تیم" (افراد)
پاسخ شما (فقط JSON):
`.trim(),
es: `
Eres un asistente que detecta temas recurrentes en notas para sugerir nuevas etiquetas.
CUADERNO ANALIZADO:
${notes.length} notas
ETIQUETAS EXISTENTES (no sugerir estas):
${existingLabels || 'Ninguna'}
NOTAS DEL CUADERNO:
${notesSummary}
TAREA:
Analiza las notas y detecta temas recurrentes (palabras clave, temas, lugares, personas).
Un tema debe aparecer en al menos 5 notas diferentes para ser sugerido.
FORMATO DE RESPUESTA (JSON):
{
"labels": [
{
"nom": "nombre_etiqueta",
"note_indices": [0, 5, 12, 23, 45],
"confiance": 0.85
}
]
}
REGLAS:
- El nombre de la etiqueta debe ser corto (máx 1-2 palabras)
- Un tema debe aparecer en 5+ notas para ser sugerido
- La confianza debe ser > 0.60
- No sugieras etiquetas que ya existen
- Prioriza lugares, personas, categorías claras
- Máximo 5 sugerencias
Ejemplos de buenas etiquetas:
- "tokio", "kyoto", "osaka" (lugares)
- "hoteles", "restaurantes", "vuelos" (categorías)
- "maría", "juan", "equipo" (personas)
Tu respuesta (solo JSON):
`.trim(),
de: `
Du bist ein Assistent, der wiederkehrende Themen in Notizen erkennt, um neue Labels vorzuschlagen.
ANALYSIERTES NOTIZBUCH:
${notes.length} Notizen
VORHANDENE LABELS (schlage diese nicht vor):
${existingLabels || 'Keine'}
NOTIZBUCH-NOTIZEN:
${notesSummary}
AUFGABE:
Analysiere die Notizen und erkenne wiederkehrende Themen (Schlüsselwörter, Themen, Orte, Personen).
Ein Thema muss in mindestens 5 verschiedenen Notizen erscheinen, um vorgeschlagen zu werden.
ANTWORTFORMAT (JSON):
{
"labels": [
{
"nom": "label_name",
"note_indices": [0, 5, 12, 23, 45],
"confiance": 0.85
}
]
}
REGELN:
- Der Labelname muss kurz sein (max 1-2 Wörter)
- Ein Thema muss in 5+ Notizen erscheinen, um vorgeschlagen zu werden
- Konfidenz muss > 0.60 sein
- Schlage keine Labels vor, die bereits existieren
- Priorisiere Orte, Personen, klare Kategorien
- Maximal 5 Vorschläge
Beispiele für gute Labels:
- "tokio", "kyoto", "osaka" (Orte)
- "hotels", "restaurants", "flüge" (Kategorien)
- "maria", "johannes", "team" (Personen)
Deine Antwort (nur JSON):
`.trim()
}
return instructions[language] || instructions['en'] || instructions['fr']
}
/**

View File

@ -36,9 +36,10 @@ export class BatchOrganizationService {
/**
* Analyze all notes in "Notes générales" and create an organization plan
* @param userId - User ID
* @param language - User's preferred language (default: 'en')
* @returns Organization plan with notebook assignments
*/
async createOrganizationPlan(userId: string): Promise<OrganizationPlan> {
async createOrganizationPlan(userId: string, language: string = 'en'): Promise<OrganizationPlan> {
// 1. Get all notes without notebook (Inbox/Notes générales)
const notesWithoutNotebook = await prisma.note.findMany({
where: {
@ -86,7 +87,7 @@ export class BatchOrganizationService {
}
// 3. Call AI to create organization plan
const plan = await this.aiOrganizeNotes(notesWithoutNotebook, notebooks)
const plan = await this.aiOrganizeNotes(notesWithoutNotebook, notebooks, language)
return plan
}
@ -96,9 +97,10 @@ export class BatchOrganizationService {
*/
private async aiOrganizeNotes(
notes: NoteForOrganization[],
notebooks: any[]
notebooks: any[],
language: string
): Promise<OrganizationPlan> {
const prompt = this.buildPrompt(notes, notebooks)
const prompt = this.buildPrompt(notes, notebooks, language)
try {
const config = await getSystemConfig()
@ -121,9 +123,9 @@ export class BatchOrganizationService {
}
/**
* Build prompt for AI (always in French - interface language)
* Build prompt for AI (localized)
*/
private buildPrompt(notes: NoteForOrganization[], notebooks: any[]): string {
private buildPrompt(notes: NoteForOrganization[], notebooks: any[], language: string = 'en'): string {
const notebookList = notebooks
.map(nb => {
const labels = nb.labels.map((l: any) => l.name).join(', ')
@ -140,7 +142,9 @@ export class BatchOrganizationService {
})
.join('\n')
return `
// System instructions based on language
const instructions: Record<string, string> = {
fr: `
Tu es un assistant qui organise des notes en les regroupant par thématique dans des carnets.
CARNETS DISPONIBLES :
@ -175,7 +179,7 @@ Pour chaque carnet, liste les notes qui lui appartiennent :
{
"index": 0,
"confiance": 0.95,
"raison": "Courte explication"
"raison": "Courte explication en français"
}
]
}
@ -186,9 +190,684 @@ RÈGLES :
- Seules les notes avec confiance > 0.60 doivent être assignées
- Si une note est trop générique, ne l'assigne pas
- Sois précis dans tes regroupements thématiques
- Ta réponse doit être uniquement un JSON valide
`.trim(),
en: `
You are an assistant that organizes notes by grouping them into notebooks based on their theme.
Ta réponse (JSON seulement) :
AVAILABLE NOTEBOOKS:
${notebookList}
NOTES TO ORGANIZE (Inbox):
${notesList}
TASK:
Analyze each note and suggest the MOST appropriate notebook.
Consider:
1. The subject/theme of the note (MOST IMPORTANT)
2. Existing labels in each notebook
3. Thematic consistency between notes in the same notebook
CLASSIFICATION GUIDE:
- SPORT/EXERCISE/SHOPPING/GROCERIES Personal Notebook
- HOBBIES/PASSIONS/OUTINGS Personal Notebook
- HEALTH/FITNESS/DOCTOR Personal Notebook or Health
- FAMILY/FRIENDS Personal Notebook
- WORK/MEETINGS/PROJECTS/CLIENTS Work Notebook
- CODING/TECH/DEVELOPMENT Work Notebook or Code
- FINANCE/BILLS/BANKING Personal Notebook or Finance
RESPONSE FORMAT (JSON):
For each notebook, list the notes that belong to it:
{
"carnets": [
{
"nom": "Notebook Name",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Short explanation in English"
}
]
}
]
}
RULES:
- Only assign notes with confidence > 0.60
- If a note is too generic, do not assign it
- Be precise in your thematic groupings
- Your response must be valid JSON only
`.trim(),
es: `
Eres un asistente que organiza notas agrupándolas por temática en cuadernos.
CUADERNOS DISPONIBLES:
${notebookList}
NOTAS A ORGANIZAR (Bandeja de entrada):
${notesList}
TAREA:
Analiza cada nota y sugiere el cuaderno MÁS apropiado.
Considera:
1. El tema/asunto de la nota (LO MÁS IMPORTANTE)
2. Etiquetas existentes en cada cuaderno
3. Coherencia temática entre notas del mismo cuaderno
GUÍA DE CLASIFICACIÓN:
- DEPORTE/EJERCICIO/COMPRAS Cuaderno Personal
- HOBBIES/PASIONES/SALIDAS Cuaderno Personal
- SALUD/FITNESS/DOCTOR Cuaderno Personal o Salud
- FAMILIA/AMIGOS Cuaderno Personal
- TRABAJO/REUNIONES/PROYECTOS Cuaderno Trabajo
- CODING/TECH/DESARROLLO Cuaderno Trabajo o Código
- FINANZAS/FACTURAS/BANCO Cuaderno Personal o Finanzas
FORMATO DE RESPUESTA (JSON):
Para cada cuaderno, lista las notas que le pertenecen:
{
"carnets": [
{
"nom": "Nombre del cuaderno",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Breve explicación en español"
}
]
}
]
}
REGLAS:
- Solo asigna notas con confianza > 0.60
- Si una nota es demasiado genérica, no la asignes
- preciso en tus agrupaciones temáticas
- Tu respuesta debe ser únicamente un JSON válido
`.trim(),
de: `
Du bist ein Assistent, der Notizen organisiert, indem er sie thematisch in Notizbücher gruppiert.
VERFÜGBARE NOTIZBÜCHER:
${notebookList}
ZU ORGANISIERENDE NOTIZEN (Eingang):
${notesList}
AUFGABE:
Analysiere jede Notiz und schlage das AM BESTEN geeignete Notizbuch vor.
Berücksichtige:
1. Das Thema/den Inhalt der Notiz (AM WICHTIGSTEN)
2. Vorhandene Labels in jedem Notizbuch
3. Thematische Konsistenz zwischen Notizen im selben Notizbuch
KLASSIFIZIERUNGSLEITFADEN:
- SPORT/ÜBUNG/EINKAUFEN Persönliches Notizbuch
- HOBBYS/LEIDENSCHAFTEN Persönliches Notizbuch
- GESUNDHEIT/FITNESS/ARZT Persönliches Notizbuch oder Gesundheit
- FAMILIE/FREUNDE Persönliches Notizbuch
- ARBEIT/MEETINGS/PROJEKTE Arbeitsnotizbuch
- CODING/TECH/ENTWICKLUNG Arbeitsnotizbuch oder Code
- FINANZEN/RECHNUNGEN/BANK Persönliches Notizbuch oder Finanzen
ANTWORTFORMAT (JSON):
Für jedes Notizbuch, liste die zugehörigen Notizen auf:
{
"carnets": [
{
"nom": "Name des Notizbuchs",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Kurze Erklärung auf Deutsch"
}
]
}
]
}
REGELN:
- Ordne nur Notizen mit Konfidenz > 0.60
- Wenn eine Notiz zu allgemein ist, ordne sie nicht zu
- Sei präzise in deinen thematischen Gruppierungen
- Deine Antwort muss ein gültiges JSON sein
`.trim(),
it: `
Sei un assistente che organizza le note raggruppandole per tema nei taccuini.
TACCUINI DISPONIBILI:
${notebookList}
NOTE DA ORGANIZZARE (In arrivo):
${notesList}
COMPITO:
Analizza ogni nota e suggerisci il taccuino PIÙ appropriato.
Considera:
1. L'argomento/tema della nota (PIÙ IMPORTANTE)
2. Etichette esistenti in ogni taccuino
3. Coerenza tematica tra le note dello stesso taccuino
GUIDA ALLA CLASSIFICAZIONE:
- SPORT/ESERCIZIO/SHOPPING Taccuino Personale
- HOBBY/PASSIONI/USCITE Taccuino Personale
- SALUTE/FITNESS/DOTTORE Taccuino Personale o Salute
- FAMIGLIA/AMIGOS Taccuino Personale
- LAVORO/RIUNIONI/PROGETTI Taccuino Lavoro
- CODING/TECH/SVILUPPO Taccuino Lavoro o Codice
- FINANZA/BILLS/BANCA Taccuino Personale o Finanza
FORMATO RISPOSTA (JSON):
Per ogni taccuino, elenca le note che appartengono ad esso:
{
"carnets": [
{
"nom": "Nome del taccuino",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Breve spiegazione in italiano"
}
]
}
]
}
REGOLE:
- Assegna solo note con confidenza > 0.60
- Se una nota è troppo generica, non assegnarla
- Sii preciso nei tuoi raggruppamenti tematici
- La tua risposta deve essere solo un JSON valido
`.trim(),
pt: `
Você é um assistente que organiza notas agrupando-as por tema em cadernos.
CADERNOS DISPONÍVEIS:
${notebookList}
NOTAS A ORGANIZAR (Caixa de entrada):
${notesList}
TAREFA:
Analise cada nota e sugira o caderno MAIS apropriado.
Considere:
1. O assunto/tema da nota (MAIS IMPORTANTE)
2. Etiquetas existentes em cada caderno
3. Coerência temática entre notas do mesmo caderno
GUIA DE CLASSIFICAÇÃO:
- ESPORTE/EXERCÍCIO/COMPRAS Caderno Pessoal
- HOBBIES/PAIXÕES/SAÍDAS Caderno Pessoal
- SAÚDE/FITNESS/MÉDICO Caderno Pessoal ou Saúde
- FAMÍLIA/AMIGOS Caderno Pessoal
- TRABALHO/REUNIÕES/PROJETOS Caderno Trabalho
- CODING/TECH/DESENVOLVIMENTO Caderno Trabalho ou Código
- FINANÇAS/CONTAS/BANCO Caderno Pessoal ou Finanças
FORMATO DE RESPOSTA (JSON):
Para cada caderno, liste as notas que pertencem a ele:
{
"carnets": [
{
"nom": "Nome do caderno",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Breve explicação em português"
}
]
}
]
}
REGRAS:
- Apenas atribua notas com confiança > 0.60
- Se uma nota for muito genérica, não a atribua
- Seja preciso em seus agrupamentos temáticos
- Sua resposta deve ser apenas um JSON válido
`.trim(),
nl: `
Je bent een assistent die notities organiseert door ze thematisch in notitieboekjes te groeperen.
BESCHIKBARE NOTITIEBOEKJES:
${notebookList}
TE ORGANISEREN NOTITIES (Inbox):
${notesList}
TAAK:
Analyseer elke notitie en stel het MEEST geschikte notitieboek voor.
Overweeg:
1. Het onderwerp/thema van de notitie (BELANGRIJKST)
2. Bestaande labels in elk notitieboekje
3. Thematische consistentie tussen notities in hetzelfde notitieboekje
CLASSIFICATIEGIDS:
- SPORT/OEFENING/WINKELEN Persoonlijk Notitieboek
- HOBBIES/PASSIES/UITJES Persoonlijk Notitieboek
- GEZONDHEID/FITNESS/DOKTER Persoonlijk Notitieboek of Gezondheid
- FAMILIE/VRIENDEN Persoonlijk Notitieboek
- WERK/VERGADERINGEN/PROJECTEN Werk Notitieboek
- CODING/TECH/ONTWIKKELING Werk Notitieboek of Code
- FINANCIËN/REKENINGEN/BANK Persoonlijk Notitieboek of Financiën
ANTWOORDFORMAAT (JSON):
Voor elk notitieboekje, lijst de notities op die erbij horen:
{
"carnets": [
{
"nom": "Naam van het notitieboekje",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Korte uitleg in het Nederlands"
}
]
}
]
}
REGELS:
- Wijs alleen notities toe met vertrouwen > 0.60
- Als een notitie te generiek is, wijs deze dan niet toe
- Wees nauwkeurig in je thematische groeperingen
- Je antwoord moet alleen een geldige JSON zijn
`.trim(),
pl: `
Jesteś asystentem, który organizuje notatki, grupując je tematycznie w notatnikach.
DOSTĘPNE NOTATNIKI:
${notebookList}
NOTATKI DO ZORGANIZOWANIA (Skrzynka odbiorcza):
${notesList}
ZADANIE:
Przeanalizuj każdą notatkę i zasugeruj NAJBARDZIEJ odpowiedni notatnik.
Rozważ:
1. Temat/treść notatki (NAJWAŻNIEJSZE)
2. Istniejące etykiety w każdym notatniku
3. Spójność tematyczna między notatkami w tym samym notatniku
PRZEWODNIK KLASYFIKACJI:
- SPORT/ĆWICZENIA/ZAKUPY Notatnik Osobisty
- HOBBY/PASJE/WYJŚCIA Notatnik Osobisty
- ZDROWIE/FITNESS/LEKARZ Notatnik Osobisty lub Zdrowie
- RODZINA/PRZYJACIELE Notatnik Osobisty
- PRACA/SPOTKANIA/PROJEKTY Notatnik Praca
- KODOWANIE/TECH/ROZWÓJ Notatnik Praca lub Kod
- FINANSE/RACHUNKI/BANK Notatnik Osobisty lub Finanse
FORMAT ODPOWIEDZI (JSON):
Dla każdego notatnika wymień należące do niego notatki:
{
"carnets": [
{
"nom": "Nazwa notatnika",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Krótkie wyjaśnienie po polsku"
}
]
}
]
}
ZASADY:
- Przypisuj tylko notatki z pewnością > 0.60
- Jeśli notatka jest zbyt ogólna, nie przypisuj jej
- Bądź precyzyjny w swoich grupach tematycznych
- Twoja odpowiedź musi być tylko prawidłowym JSON
`.trim(),
ru: `
Вы помощник, который организует заметки, группируя их по темам в блокноты.
ДОСТУПНЫЕ БЛОКНОТЫ:
${notebookList}
ЗАМЕТКИ ДЛЯ ОРГАНИЗАЦИИ (Входящие):
${notesList}
ЗАДАЧА:
Проанализируйте каждую заметку и предложите САМЫЙ подходящий блокнот.
Учитывайте:
1. Тему/предмет заметки (САМОЕ ВАЖНОЕ)
2. Существующие метки в каждом блокноте
3. Тематическую согласованность между заметками в одном блокноте
РУКОВОДСТВО ПО КЛАССИФИКАЦИИ:
- СПОРТ/УПРАЖНЕНИЯ/ПОКУПКИ Личный блокнот
- ХОББИ/УВЛЕЧЕНИЯ/ВЫХОДЫ Личный блокнот
- ЗДОРОВЬЕ/ФИТНЕС/ВРАЧ Личный блокнот или Здоровье
- СЕМЬЯ/ДРУЗЬЯ Личный блокнот
- РАБОТА/СОВЕЩАНИЯ/ПРОЕКТЫ Рабочий блокнот
- КОДИНГ/ТЕХНОЛОГИИ/РАЗРАБОТКА Рабочий блокнот или Код
- ФИНАНСЫ/СЧЕТА/БАНК Личный блокнот или Финансы
ФОРМАТ ОТВЕТА (JSON):
Для каждого блокнота перечислите заметки, которые к нему относятся:
{
"carnets": [
{
"nom": "Название блокнота",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Краткое объяснение на русском"
}
]
}
]
}
ПРАВИЛА:
- Назначайте только заметки с уверенностью > 0.60
- Если заметка слишком общая, не назначайте ее
- Будьте точны в своих тематических группировках
- Ваш ответ должен быть только валидным JSON
`.trim(),
ja: `
:
${notebookList}
():
${notesList}
:
:
1. / ()
2.
3.
:
- //
- //
- //
- /
- //
- //
- //
(JSON):
:
{
"carnets": [
{
"nom": "ノートブック名",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "日本語での短い説明"
}
]
}
]
}
:
- 0.60
-
-
- JSON
`.trim(),
ko: `
.
:
${notebookList}
( ):
${notesList}
:
.
:
1. / ( )
2.
3.
:
- //
- //
- //
- /
- //
- //
- //
(JSON):
:
{
"carnets": [
{
"nom": "노트북 이름",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "한국어로 된 짧은 설명"
}
]
}
]
}
:
- 0.60
-
-
- JSON이어야
`.trim(),
zh: `
${notebookList}
${notesList}
1. /
2.
3.
- //
- //
- //
- /
- //
- //
- //
(JSON):
{
"carnets": [
{
"nom": "笔记本名称",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "中文简短说明"
}
]
}
]
}
- > 0.60
-
-
- JSON
`.trim(),
ar: `
أنت مساعد يقوم بتنظيم الملاحظات عن طريق تجميعها حسب الموضوع في دفاتر ملاحظات.
دفاتر الملاحظات المتاحة:
${notebookList}
ملاحظات للتنظيم (صندوق الوارد):
${notesList}
المهمة:
حلل كل ملاحظة واقترح دفتر الملاحظات الأكثر ملاءمة.
اعتبار:
1. موضوع/مادة الملاحظة (الأهم)
2. التسميات الموجودة في كل دفتر ملاحظات
3. الاتساق الموضوعي بين الملاحظات في نفس دفتر الملاحظات
دليل التصنيف:
- الرياضة/التمرين/التسوق دفتر ملاحظات شخصي
- الهوايات/الشغف/النزهات دفتر ملاحظات شخصي
- الصحة/اللياقة البدنية/الطبيب دفتر ملاحظات شخصي أو صحة
- العائلة/الأصدقاء دفتر ملاحظات شخصي
- العمل/الاجتماعات/المشاريع دفتر ملاحظات العمل
- البرمجة/التقنية/التطوير دفتر ملاحظات العمل أو الكود
- المالية/الفواتير/البنك دفتر ملاحظات شخصي أو مالية
تنسيق الاستجابة (JSON):
لكل دفتر ملاحظات، ضع قائمة بالملاحظات التي تنتمي إليه:
{
"carnets": [
{
"nom": "اسم دفتر الملاحظات",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "شرح قصير باللغة العربية"
}
]
}
]
}
القواعد:
- عيّن فقط الملاحظات ذات الثقة > 0.60
- إذا كانت الملاحظة عامة جدًا، فلا تقم بتعيينها
- كن دقيقًا في مجموعاتك الموضوعية
- يجب أن تكون إجابتك بتنسيق JSON صالح فقط
`.trim(),
hi: `
ि ि ि
:
${notebookList}
ि ि ():
${notesList}
:
ि
ि :
1. ि/ ( )
2.
3. ि ि
:
- // ि
- // ि
- /ि/ ि
- ि/ि ि
- //ि
- ि//ि
- ि/ि/ ि ि
िि (JSON):
ि, ि :
{
"carnets": [
{
"nom": "नोटबुक का नाम",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "हिंदी में संक्षिप्त स्पष्टीकरण"
}
]
}
]
}
ि:
- > 0.60 ि
- ि ,
- ि
- िि JSON ि
`.trim(),
fa: `
شما دستیاری هستید که یادداشتها را با گروهبندی موضوعی در دفترچهها سازماندهی میکنید.
دفترچههای موجود:
${notebookList}
یادداشتهای برای سازماندهی (صندوق ورودی):
${notesList}
وظیفه:
هر یادداشت را تحلیل کنید و مناسبترین دفترچه را پیشنهاد دهید.
در نظر بگیرید:
1. موضوع/تم یادداشت (مهمترین)
2. برچسبهای موجود در هر دفترچه
3. سازگاری موضوعی بین یادداشتها در همان دفترچه
راهنمای طبقهبندی:
- ورزش/تمرین/خرید دفترچه شخصی
- سرگرمیها/علایق/گردش دفترچه شخصی
- سلامت/تناسب اندام/پزشک دفترچه شخصی یا سلامت
- خانواده/دوستان دفترچه شخصی
- کار/جلسات/پروژهها دفترچه کار
- کدنویسی/تکنولوژی/توسعه دفترچه کار یا کد
- مالی/قبضها/بانک دفترچه شخصی یا مالی
فرمت پاسخ (JSON):
برای هر دفترچه، یادداشتهایی که به آن تعلق دارند را لیست کنید:
{
"carnets": [
{
"nom": "نام دفترچه",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "توضیح کوتاه به فارسی"
}
]
}
]
}
قوانین:
- فقط یادداشتهای با اطمینان > 0.60 را اختصاص دهید
- اگر یادداشتی خیلی کلی است، آن را اختصاص ندهید
- در گروهبندیهای موضوعی خود دقیق باشید
- پاسخ شما باید فقط یک JSON معتبر باشد
`.trim()
}
// Return instruction for requested language, fallback to English
return instructions[language] || instructions['en'] || instructions['fr']
}
/**

View File

@ -26,7 +26,8 @@ export class ContextualAutoTagService {
async suggestLabels(
noteContent: string,
notebookId: string | null,
userId: string
userId: string,
language: string = 'en'
): Promise<LabelSuggestion[]> {
// If no notebook, return empty (no context)
if (!notebookId) {
@ -54,11 +55,11 @@ export class ContextualAutoTagService {
// CASE 1: Notebook has existing labels → suggest from them (IA2)
if (notebook.labels.length > 0) {
return await this.suggestFromExistingLabels(noteContent, notebook)
return await this.suggestFromExistingLabels(noteContent, notebook, language)
}
// CASE 2: Notebook has NO labels → suggest NEW labels to create
return await this.suggestNewLabels(noteContent, notebook)
return await this.suggestNewLabels(noteContent, notebook, language)
}
/**
@ -66,12 +67,13 @@ export class ContextualAutoTagService {
*/
private async suggestFromExistingLabels(
noteContent: string,
notebook: any
notebook: any,
language: string
): Promise<LabelSuggestion[]> {
const availableLabels = notebook.labels.map((l: any) => l.name)
// Build prompt with available labels
const prompt = this.buildPrompt(noteContent, notebook.name, availableLabels)
const prompt = this.buildPrompt(noteContent, notebook.name, availableLabels, language)
try {
const config = await getSystemConfig()
@ -151,10 +153,11 @@ export class ContextualAutoTagService {
*/
private async suggestNewLabels(
noteContent: string,
notebook: any
notebook: any,
language: string
): Promise<LabelSuggestion[]> {
// Build prompt to suggest NEW labels based on content
const prompt = this.buildNewLabelsPrompt(noteContent, notebook.name)
const prompt = this.buildNewLabelsPrompt(noteContent, notebook.name, language)
try {
const config = await getSystemConfig()
@ -229,12 +232,13 @@ export class ContextualAutoTagService {
}
/**
* Build the AI prompt for contextual label suggestion
* Build the AI prompt for contextual label suggestion (localized)
*/
private buildPrompt(noteContent: string, notebookName: string, availableLabels: string[]): string {
private buildPrompt(noteContent: string, notebookName: string, availableLabels: string[], language: string = 'en'): string {
const labelList = availableLabels.map(l => `- ${l}`).join('\n')
return `
const instructions: Record<string, string> = {
fr: `
Tu es un assistant qui suggère les labels les plus appropriés pour une note.
CONTENU DE LA NOTE :
@ -267,14 +271,154 @@ FORMAT DE RÉPONSE (JSON uniquement) :
}
Ta réponse :
`.trim(),
en: `
You are an assistant that suggests the most appropriate labels for a note.
NOTE CONTENT:
${noteContent.substring(0, 1000)}
CURRENT NOTEBOOK:
${notebookName}
AVAILABLE LABELS IN THIS NOTEBOOK:
${labelList}
TASK:
Analyze the note content and suggest the MOST appropriate labels from the available labels above.
Consider:
1. Label relevance to content
2. Number of labels (maximum 3 suggestions)
3. Confidence (minimum threshold: 0.6)
RULES:
- Suggest ONLY labels that are in the available labels list
- Return maximum 3 suggestions
- Each suggestion must have confidence > 0.6
- If no label is relevant, return an empty array
RESPONSE FORMAT (JSON only):
{
"suggestions": [
{ "label": "label_name", "confidence": 0.85, "reasoning": "Why this label is relevant" }
]
}
Your response:
`.trim(),
fa: `
شما یک دستیار هستید که مناسبترین برچسبها را برای یک یادداشت پیشنهاد میدهید.
محتوای یادداشت:
${noteContent.substring(0, 1000)}
دفترچه فعلی:
${notebookName}
برچسبهای موجود در این دفترچه:
${labelList}
وظیفه:
محتوای یادداشت را تحلیل کنید و مناسبترین برچسبها را از لیست برچسبهای موجود در بالا پیشنهاد دهید.
در نظر بگیرید:
1. ارتباط برچسب با محتوا
2. تعداد برچسبها (حداکثر ۳ پیشنهاد)
3. اطمینان (حداقل آستانه: 0.6)
قوانین:
- فقط برچسبهایی را پیشنهاد دهید که در لیست برچسبهای موجود هستند
- حداکثر ۳ پیشنهاد برگردانید
- هر پیشنهاد باید دارای اطمینان > 0.6 باشد
- اگر هیچ برچسبی مرتبط نیست، یک اینرایه خالی برگردانید
فرمت پاسخ (فقط JSON):
{
"suggestions": [
{ "label": ام_برچسب", "confidence": 0.85, "reasoning": "چرا این برچسب مرتبط است" }
]
}
پاسخ شما:
`.trim(),
es: `
Eres un asistente que sugiere las etiquetas más apropiadas para una nota.
CONTENIDO DE LA NOTA:
${noteContent.substring(0, 1000)}
CUADERNO ACTUAL:
${notebookName}
ETIQUETAS DISPONIBLES EN ESTE CUADERNO:
${labelList}
TAREA:
Analiza el contenido de la nota y sugiere las etiquetas MÁS apropiadas de las etiquetas disponibles arriba.
Considera:
1. Relevancia de la etiqueta para el contenido
2. Número de etiquetas (máximo 3 sugerencias)
3. Confianza (umbral mínimo: 0.6)
REGLAS:
- Sugiere SOLO etiquetas que estén en la lista de etiquetas disponibles
- Devuelve máximo 3 sugerencias
- Cada sugerencia debe tener confianza > 0.6
- Si ninguna etiqueta es relevante, devuelve un array vacío
FORMATO DE RESPUESTA (solo JSON):
{
"suggestions": [
{ "label": "nombre_etiqueta", "confidence": 0.85, "reasoning": "Por qué esta etiqueta es relevante" }
]
}
Tu respuesta:
`.trim(),
de: `
Du bist ein Assistent, der die passendsten Labels für eine Notiz vorschlägt.
NOTIZINHALT:
${noteContent.substring(0, 1000)}
AKTUELLES NOTIZBUCH:
${notebookName}
VERFÜGBARE LABELS IN DIESEM NOTIZBUCH:
${labelList}
AUFGABE:
Analysiere den Notizinhalt und schlage die AM BESTEN geeigneten Labels aus den oben verfügbaren Labels vor.
Berücksichtige:
1. Relevanz des Labels für den Inhalt
2. Anzahl der Labels (maximal 3 Vorschläge)
3. Konfidenz (Mindestschwellenwert: 0.6)
REGELN:
- Schlage NUR Labels vor, die in der Liste der verfügbaren Labels sind
- Gib maximal 3 Vorschläge zurück
- Jeder Vorschlag muss eine Konfidenz > 0.6 haben
- Wenn kein Label relevant ist, gib ein leeres Array zurück
ANTWORTFORMAT (nur JSON):
{
"suggestions": [
{ "label": "label_name", "confidence": 0.85, "reasoning": "Warum dieses Label relevant ist" }
]
}
Deine Antwort:
`.trim()
}
return instructions[language] || instructions['en'] || instructions['fr']
}
/**
* Build the AI prompt for NEW label suggestions (when notebook is empty)
* Build the AI prompt for NEW label suggestions (when notebook is empty) (localized)
*/
private buildNewLabelsPrompt(noteContent: string, notebookName: string): string {
return `
private buildNewLabelsPrompt(noteContent: string, notebookName: string, language: string = 'en'): string {
const instructions: Record<string, string> = {
fr: `
Tu es un assistant qui suggère de nouveaux labels pour organiser une note.
CONTENU DE LA NOTE :
@ -306,7 +450,141 @@ FORMAT DE RÉPONSE (JSON brut, sans markdown) :
{"suggestions":[{"label":"nom_du_label","confidence":0.85,"reasoning":"Pourquoi ce label est pertinent"}]}
Ta réponse (JSON brut uniquement) :
`.trim(),
en: `
You are an assistant that suggests new labels to organize a note.
NOTE CONTENT:
${noteContent.substring(0, 1000)}
CURRENT NOTEBOOK:
${notebookName}
CONTEXT:
This notebook has no labels yet. You must suggest the FIRST appropriate labels for this note.
TASK:
Analyze the note content and suggest 1-3 labels that would be relevant to organize this note.
Consider:
1. Topics or themes covered
2. Content type (idea, task, reference, etc.)
3. Context of the notebook "${notebookName}"
RULES:
- Labels must be SHORT (max 1-2 words)
- Labels must be lowercase
- Avoid accents if possible
- Return maximum 3 suggestions
- Each suggestion must have confidence > 0.6
IMPORTANT: Respond ONLY with valid JSON, without text before or after. No markdown, no code blocks.
RESPONSE FORMAT (raw JSON, no markdown):
{"suggestions":[{"label":"label_name","confidence":0.85,"reasoning":"Why this label is relevant"}]}
Your response (raw JSON only):
`.trim(),
fa: `
شما یک دستیار هستید که برچسبهای جدیدی برای سازماندهی یک یادداشت پیشنهاد میدهید.
محتوای یادداشت:
${noteContent.substring(0, 1000)}
دفترچه فعلی:
${notebookName}
زمینه:
این دفترچه هنوز هیچ برچسبی ندارد. شما باید اولین برچسبهای مناسب را برای این یادداشت پیشنهاد دهید.
وظیفه:
محتوای یادداشت را تحلیل کنید و ۱-۳ برچسب پیشنهاد دهید که برای سازماندهی این یادداشت مرتبط باشند.
در نظر بگیرید:
1. موضوعات یا تمهای پوشش داده شده
2. نوع محتوا (ایده، وظیفه، مرجع و غیره)
3. زمینه دفترچه "${notebookName}"
قوانین:
- برچسبها باید کوتاه باشند (حداکثر ۱-۲ کلمه)
- برچسبها باید با حروف کوچک باشند
- حداکثر ۳ پیشنهاد برگردانید
- هر پیشنهاد باید دارای اطمینان > 0.6 باشد
مهم: فقط با یک JSON معتبر پاسخ دهید، بدون متن قبل یا بعد. بدون مارکداون، بدون بلوک کد.
فرمت پاسخ (JSON خام، بدون مارکداون):
{"suggestions":[{"label":ام_برچسب","confidence":0.85,"reasoning":"چرا این برچسب مرتبط است"}]}
پاسخ شما (فقط JSON خام):
`.trim(),
es: `
Eres un asistente que sugiere nuevas etiquetas para organizar una nota.
CONTENIDO DE LA NOTA:
${noteContent.substring(0, 1000)}
CUADERNO ACTUAL:
${notebookName}
CONTEXTO:
Este cuaderno aún no tiene etiquetas. Debes sugerir las PRIMERAS etiquetas apropiadas para esta nota.
TAREA:
Analiza el contenido de la nota y sugiere 1-3 etiquetas que serían relevantes para organizar esta nota.
Considera:
1. Temas o tópicos cubiertos
2. Tipo de contenido (idea, tarea, referencia, etc.)
3. Contexto del cuaderno "${notebookName}"
REGLAS:
- Las etiquetas deben ser CORTAS (máx 1-2 palabras)
- Las etiquetas deben estar en minúsculas
- Evita acentos si es posible
- Devuelve máximo 3 sugerencias
- Cada sugerencia debe tener confianza > 0.6
IMPORTANTE: Responde SOLO con JSON válido, sin texto antes o después. Sin markdown, sin bloques de código.
FORMATO DE RESPUESTA (JSON crudo, sin markdown):
{"suggestions":[{"label":"nombre_etiqueta","confidence":0.85,"reasoning":"Por qué esta etiqueta es relevante"}]}
Tu respuesta (solo JSON crudo):
`.trim(),
de: `
Du bist ein Assistent, der neue Labels vorschlägt, um eine Notiz zu organisieren.
NOTIZINHALT:
${noteContent.substring(0, 1000)}
AKTUELLES NOTIZBUCH:
${notebookName}
KONTEXT:
Dieses Notizbuch hat noch keine Labels. Du musst die ERSTEN passenden Labels für diese Notiz vorschlagen.
AUFGABE:
Analysiere den Notizinhalt und schlage 1-3 Labels vor, die relevant wären, um diese Notiz zu organisieren.
Berücksichtige:
1. Abgedeckte Themen oder Bereiche
2. Inhaltstyp (Idee, Aufgabe, Referenz, usw.)
3. Kontext des Notizbuchs "${notebookName}"
REGELN:
- Labels müssen KURZ sein (max 1-2 Wörter)
- Labels müssen kleingeschrieben sein
- Vermeide Akzente wenn möglich
- Gib maximal 3 Vorschläge zurück
- Jeder Vorschlag muss eine Konfidenz > 0.6 haben
WICHTIG: Antworte NUR mit gültigem JSON, ohne Text davor oder danach. Kein Markdown, keine Code-Blöcke.
ANTWORTFORMAT (rohes JSON, kein Markdown):
{"suggestions":[{"label":"label_name","confidence":0.85,"reasoning":"Warum dieses Label relevant ist"}]}
Deine Antwort (nur rohes JSON):
`.trim()
}
return instructions[language] || instructions['en'] || instructions['fr']
}
}

View File

@ -10,7 +10,7 @@ export class NotebookSuggestionService {
* @param userId - User ID (for fetching user's notebooks)
* @returns Suggested notebook or null (if no good match)
*/
async suggestNotebook(noteContent: string, userId: string): Promise<Notebook | null> {
async suggestNotebook(noteContent: string, userId: string, language: string = 'en'): Promise<Notebook | null> {
// 1. Get all notebooks for this user
const notebooks = await prisma.notebook.findMany({
where: { userId },
@ -28,7 +28,7 @@ export class NotebookSuggestionService {
}
// 2. Build prompt for AI (always in French - interface language)
const prompt = this.buildPrompt(noteContent, notebooks)
const prompt = this.buildPrompt(noteContent, notebooks, language)
// 3. Call AI
try {
@ -57,9 +57,9 @@ export class NotebookSuggestionService {
}
/**
* Build the AI prompt for notebook suggestion (always in French - interface language)
* Build the AI prompt for notebook suggestion (localized)
*/
private buildPrompt(noteContent: string, notebooks: any[]): string {
private buildPrompt(noteContent: string, notebooks: any[], language: string = 'en'): string {
const notebookList = notebooks
.map(nb => {
const labels = nb.labels.map((l: any) => l.name).join(', ')
@ -68,7 +68,8 @@ export class NotebookSuggestionService {
})
.join('\n')
return `
const instructions: Record<string, string> = {
fr: `
Tu es un assistant qui suggère à quel carnet une note devrait appartenir.
CONTENU DE LA NOTE :
@ -107,7 +108,148 @@ Exemples :
- "Achat d'une chemise et d'un jean" carnet "Personnel"
Ta suggestion :
`.trim(),
en: `
You are an assistant that suggests which notebook a note should belong to.
NOTE CONTENT:
${noteContent.substring(0, 500)}
AVAILABLE NOTEBOOKS:
${notebookList}
TASK:
Analyze the note content (regardless of language) and suggest the MOST appropriate notebook for this note.
Consider:
1. The subject/theme of the note (MOST IMPORTANT)
2. Existing labels in each notebook
3. The number of notes (prefer notebooks with related content)
CLASSIFICATION GUIDE:
- SPORT/EXERCISE/SHOPPING/GROCERIES Personal Notebook
- HOBBIES/PASSIONS/OUTINGS Personal Notebook
- HEALTH/FITNESS/DOCTOR Personal Notebook or Health
- FAMILY/FRIENDS Personal Notebook
- WORK/MEETINGS/PROJECTS/CLIENTS Work Notebook
- CODING/TECH/DEVELOPMENT Work Notebook or Code
- FINANCE/BILLS/BANKING Personal Notebook or Finance
RULES:
- Return ONLY the notebook name, EXACTLY as listed above (case insensitive)
- If no good match exists, return "NONE"
- If the note is too generic/vague, return "NONE"
- Do not include explanations or extra text
Examples:
- "Meeting with John about project planning" notebook "Work"
- "Grocery list or buying clothes" notebook "Personal"
- "Python script for data analysis" notebook "Code"
- "Gym session or fitness" notebook "Personal"
Your suggestion:
`.trim(),
fa: `
شما یک دستیار هستید که پیشنهاد میدهد یک یادداشت به کدام دفترچه تعلق داشته باشد.
محتوای یادداشت:
${noteContent.substring(0, 500)}
دفترچههای موجود:
${notebookList}
وظیفه:
محتوای یادداشت را تحلیل کنید (صرف نظر از زبان) و مناسبترین دفترچه را برای این یادداشت پیشنهاد دهید.
در نظر بگیرید:
1. موضوع/تم یادداشت (مهمترین)
2. برچسبهای موجود در هر دفترچه
3. تعداد یادداشتها (دفترچههای با محتوای مرتبط را ترجیح دهید)
راهنمای طبقهبندی:
- ورزش/تمرین/خرید دفترچه شخصی
- سرگرمیها/علایق/گردش دفترچه شخصی
- سلامت/تناسب اندام/پزشک دفترچه شخصی یا سلامت
- خانواده/دوستان دفترچه شخصی
- کار/جلسات/پروژهها/مشتریان دفترچه کار
- کدنویسی/تکنولوژی/توسعه دفترچه کار یا کد
- مالی/قبضها/بانک دفترچه شخصی یا مالی
قوانین:
- فقط نام دفترچه را برگردانید، دقیقاً همانطور که در بالا ذکر شده است (بدون حساسیت به حروف بزرگ و کوچک)
- اگر تطابق خوبی وجود ندارد، "NONE" را برگردانید
- اگر یادداشت خیلی کلی/مبهم است، "NONE" را برگردانید
- توضیحات یا متن اضافی را شامل نکنید
پیشناد شما:
`.trim(),
es: `
Eres un asistente que sugiere a qué cuaderno debería pertenecer una nota.
CONTENIDO DE LA NOTA:
${noteContent.substring(0, 500)}
CUADERNOS DISPONIBLES:
${notebookList}
TAREA:
Analiza el contenido de la nota (independientemente del idioma) y sugiere el cuaderno MÁS apropiado para esta nota.
Considera:
1. El tema/asunto de la nota (LO MÁS IMPORTANTE)
2. Etiquetas existentes en cada cuaderno
3. El número de notas (prefiere cuadernos con contenido relacionado)
GUÍA DE CLASIFICACIÓN:
- DEPORTE/EJERCICIO/COMPRAS Cuaderno Personal
- HOBBIES/PASIONES/SALIDAS Cuaderno Personal
- SALUD/FITNESS/DOCTOR Cuaderno Personal o Salud
- FAMILIA/AMIGOS Cuaderno Personal
- TRABAJO/REUNIONES/PROYECTOS Cuaderno Trabajo
- CODING/TECH/DESARROLLO Cuaderno Trabajo o Código
- FINANZAS/FACTURAS/BANCO Cuaderno Personal o Finanzas
REGLAS:
- Devuelve SOLO el nombre del cuaderno, EXACTAMENTE como se lista arriba (insensible a mayúsculas/minúsculas)
- Si no existe una buena coincidencia, devuelve "NONE"
- Si la nota es demasiado genérica/vaga, devuelve "NONE"
- No incluyas explicaciones o texto extra
Tu sugerencia:
`.trim(),
de: `
Du bist ein Assistent, der vorschlägt, zu welchem Notizbuch eine Notiz gehören sollte.
NOTIZINHALT:
${noteContent.substring(0, 500)}
VERFÜGBARE NOTIZBÜCHER:
${notebookList}
AUFGABE:
Analysiere den Notizinhalt (unabhängig von der Sprache) und schlage das AM BESTEN geeignete Notizbuch für diese Notiz vor.
Berücksichtige:
1. Das Thema/den Inhalt der Notiz (AM WICHTIGSTEN)
2. Vorhandene Labels in jedem Notizbuch
3. Die Anzahl der Notizen (bevorzuge Notizbücher mit verwandtem Inhalt)
KLASSIFIZIERUNGSLEITFADEN:
- SPORT/ÜBUNG/EINKAUFEN Persönliches Notizbuch
- HOBBYS/LEIDENSCHAFTEN Persönliches Notizbuch
- GESUNDHEIT/FITNESS/ARZT Persönliches Notizbuch oder Gesundheit
- FAMILIE/FREUNDE Persönliches Notizbuch
- ARBEIT/MEETINGS/PROJEKTE Arbeitsnotizbuch
- CODING/TECH/ENTWICKLUNG Arbeitsnotizbuch oder Code
- FINANZEN/RECHNUNGEN/BANK Persönliches Notizbuch oder Finanzen
REGELN:
- Gib NUR den Namen des Notizbuchs zurück, GENAU wie oben aufgeführt (Groß-/Kleinschreibung egal)
- Wenn keine gute Übereinstimmung existiert, gib "NONE" zurück
- Wenn die Notiz zu allgemein/vage ist, gib "NONE" zurück
- Füge keine Erklärungen oder zusätzlichen Text hinzu
Dein Vorschlag:
`.trim()
}
return instructions[language] || instructions['en'] || instructions['fr']
}
/**
@ -118,14 +260,15 @@ Ta suggestion :
*/
async suggestNotebooksBatch(
noteContents: string[],
userId: string
userId: string,
language: string = 'en'
): Promise<Map<number, Notebook | null>> {
const results = new Map<number, Notebook | null>()
// For efficiency, we could batch this into a single AI call
// For now, process sequentially (could be parallelized)
for (let i = 0; i < noteContents.length; i++) {
const suggestion = await this.suggestNotebook(noteContents[i], userId)
const suggestion = await this.suggestNotebook(noteContents[i], userId, language)
results.set(i, suggestion)
}

View File

@ -26,7 +26,7 @@ export class NotebookSummaryService {
* @param userId - User ID (for authorization)
* @returns Notebook summary or null
*/
async generateSummary(notebookId: string, userId: string): Promise<NotebookSummary | null> {
async generateSummary(notebookId: string, userId: string, language: string = 'en'): Promise<NotebookSummary | null> {
// 1. Get notebook with notes and labels
const notebook = await prisma.notebook.findFirst({
where: {
@ -79,7 +79,7 @@ export class NotebookSummaryService {
}
// 2. Generate summary using AI
const summary = await this.generateAISummary(notes, notebook)
const summary = await this.generateAISummary(notes, notebook, language)
// 3. Get labels used in this notebook
const labelsUsed = Array.from(
@ -107,7 +107,7 @@ export class NotebookSummaryService {
/**
* Use AI to generate notebook summary
*/
private async generateAISummary(notes: any[], notebook: any): Promise<string> {
private async generateAISummary(notes: any[], notebook: any, language: string): Promise<string> {
// Build notes summary for AI
const notesSummary = notes
.map((note, index) => {
@ -122,7 +122,7 @@ ${content}...`
})
.join('\n\n')
const prompt = this.buildPrompt(notesSummary, notebook.name)
const prompt = this.buildPrompt(notesSummary, notebook.name, language)
try {
const config = await getSystemConfig()
@ -136,10 +136,11 @@ ${content}...`
}
/**
* Build prompt for AI (always in French - interface language)
* Build prompt for AI (localized)
*/
private buildPrompt(notesSummary: string, notebookName: string): string {
return `
private buildPrompt(notesSummary: string, notebookName: string, language: string = 'en'): string {
const instructions: Record<string, string> = {
fr: `
Tu es un assistant qui génère des synthèses structurées de carnets de notes.
CARNET: ${notebookName}
@ -181,9 +182,197 @@ RÈGLES:
- Identifie les vraies tendances, ne pas inventer d'informations
- Si une section n'est pas pertinente, utilise "N/A" ou omets-la
- Ton: professionnel mais accessible
- TA RÉPONSE DOIT ÊTRE EN FRANÇAIS
Ta réponse :
`.trim(),
en: `
You are an assistant that generates structured summaries of notebooks.
NOTEBOOK: ${notebookName}
NOTEBOOK NOTES:
${notesSummary}
TASK:
Generate a structured and organized summary of this notebook by analyzing all notes.
RESPONSE FORMAT (Markdown with emojis):
# 📊 Summary of Notebook ${notebookName}
## 🌍 Main Themes
Identify 3-5 recurring themes or topics covered
## 📝 Statistics
Total number of notes analyzed
Main content categories
## 📅 Temporal Elements
Important dates or periods mentioned
Planned vs past events
## Action Items / Attention Points
Tasks or actions identified in notes
Important reminders or deadlines
Items requiring special attention
## 💡 Key Insights
Summary of most important information
Observed trends or patterns
Connections between different notes
RULES:
- Use Markdown format with emojis as in the example
- Be concise and organize information clearly
- Identify real trends, do not invent information
- If a section is not relevant, use "N/A" or omit it
- Tone: professional but accessible
- YOUR RESPONSE MUST BE IN ENGLISH
Your response:
`.trim(),
fa: `
شما یک دستیار هستید که خلاصههای ساختاریافته از دفترچههای یادداشت تولید میکنید.
دفترچه: ${notebookName}
یادداشتهای دفترچه:
${notesSummary}
وظیفه:
یک خلاصه ساختاریافته و منظم از این دفترچه با تحلیل تمام یادداشتها تولید کنید.
فرمت پاسخ (مارکداون با ایموجی):
# 📊 خلاصه دفترچه ${notebookName}
## 🌍 موضوعات اصلی
۳-۵ موضوع تکرارشونده یا مبحث پوشش داده شده را شناسایی کنید
## 📝 آمار
تعداد کل یادداشتهای تحلیل شده
دستهبندیهای اصلی محتوا
## 📅 عناصر زمانی
تاریخها یا دورههای مهم ذکر شده
رویدادهای برنامهریزی شده در مقابل گذشته
## موارد اقدام / نقاط توجه
وظایف یا اقدامات شناسایی شده در یادداشتها
یادآوریها یا مهلتهای مهم
مواردی که نیاز به توجه ویژه دارند
## 💡 بینشهای کلیدی
خلاصه مهمترین اطلاعات
روندها یا الگوهای مشاهده شده
ارتباطات بین یادداشتهای مختلف
قوانین:
- از فرمت مارکداون با ایموجی مانند مثال استفاده کنید
- مختصر باشید و اطلاعات را به وضوح سازماندهی کنید
- روندهای واقعی را شناسایی کنید، اطلاعات اختراع نکنید
- اگر بخش مرتبط نیست، از "N/A" استفاده کنید یا آن را حذف کنید
- لحن: حرفه‌ای اما قابل دسترس
- پاسخ شما باید به زبان فارسی باشد
پاسخ شما:
`.trim(),
es: `
Eres un asistente que genera resúmenes estructurados de cuadernos de notas.
CUADERNO: ${notebookName}
NOTAS DEL CUADERNO:
${notesSummary}
TAREA:
Genera un resumen estructurado y organizado de este cuaderno analizando todas las notas.
FORMATO DE RESPUESTA (Markdown con emojis):
# 📊 Resumen del Cuaderno ${notebookName}
## 🌍 Temas Principales
Identifica 3-5 temas recurrentes o tópicos cubiertos
## 📝 Estadísticas
Número total de notas analizadas
Categorías principales de contenido
## 📅 Elementos Temporales
Fechas o periodos importantes mencionados
Eventos planificados vs pasados
## Puntos de Atención / Acciones Requeridas
Tareas o acciones identificadas en las notas
Recordatorios o plazos importantes
Elementos que requieren atención especial
## 💡 Insights Clave
Resumen de la información más importante
Tendencias o patrones observados
Conexiones entre las diferentes notas
REGLAS:
- Usa formato Markdown con emojis como en el ejemplo
- conciso y organiza la información claramente
- Identifica tendencias reales, no inventes información
- Si una sección no es relevante, usa "N/A" u omítela
- Tono: profesional pero accesible
- TU RESPUESTA DEBE SER EN ESPAÑOL
Tu respuesta:
`.trim(),
de: `
Du bist ein Assistent, der strukturierte Zusammenfassungen von Notizbüchern erstellt.
NOTIZBUCH: ${notebookName}
NOTIZBUCH-NOTIZEN:
${notesSummary}
AUFGABE:
Erstelle eine strukturierte und organisierte Zusammenfassung dieses Notizbuchs, indem du alle Notizen analysierst.
ANTWORTFORMAT (Markdown mit Emojis):
# 📊 Zusammenfassung des Notizbuchs ${notebookName}
## 🌍 Hauptthemen
Identifiziere 3-5 wiederkehrende Themen
## 📝 Statistiken
Gesamtzahl der analysierten Notizen
Hauptinhaltkategorien
## 📅 Zeitliche Elemente
Wichtige erwähnte Daten oder Zeiträume
Geplante vs. vergangene Ereignisse
## Handlungspunkte / Aufmerksamkeitspunkte
In Notizen identifizierte Aufgaben oder Aktionen
Wichtige Erinnerungen oder Fristen
Elemente, die besondere Aufmerksamkeit erfordern
## 💡 Wichtige Erkenntnisse
Zusammenfassung der wichtigsten Informationen
Beobachtete Trends oder Muster
Verbindungen zwischen verschiedenen Notizen
REGELN:
- Verwende Markdown-Format mit Emojis wie im Beispiel
- Sei prägnant und organisiere Informationen klar
- Identifiziere echte Trends, erfinde keine Informationen
- Wenn ein Abschnitt nicht relevant ist, verwende "N/A" oder lass ihn weg
- Ton: professionell aber zugänglich
- DEINE ANTWORT MUSS AUF DEUTSCH SEIN
Deine Antwort:
`.trim()
}
return instructions[language] || instructions['en'] || instructions['fr']
}
}

View File

@ -24,6 +24,15 @@ export interface Translations {
createAccount: string
rememberMe: string
orContinueWith: string
checkYourEmail: string
resetEmailSent: string
returnToLogin: string
forgotPasswordTitle: string
forgotPasswordDescription: string
sending: string
sendResetLink: string
backToLogin: string
signOut: string
}
sidebar: {
notes: string
@ -65,6 +74,8 @@ export interface Translations {
invalidDateTime: string
reminderMustBeFuture: string
reminderSet: string
reminderPastError: string
reminderRemoved: string
addImage: string
addLink: string
linkAdded: string
@ -92,6 +103,34 @@ export interface Translations {
noNotes: string
noNotesFound: string
createFirstNote: string
size: string
small: string
medium: string
large: string
shareWithCollaborators: string
view: string
edit: string
readOnly: string
preview: string
noContent: string
takeNote: string
takeNoteMarkdown: string
addItem: string
sharedReadOnly: string
makeCopy: string
saving: string
copySuccess: string
copyFailed: string
copy: string
markdownOn: string
markdownOff: string
undo: string
redo: string
}
pagination: {
previous: string
pageInfo: string
next: string
}
labels: {
title: string
@ -110,9 +149,19 @@ export interface Translations {
labelName: string
labelColor: string
manageLabels: string
manageLabelsDescription: string
selectedLabels: string
allLabels: string
clearAll: string
filterByLabel: string
tagAdded: string
showLess: string
showMore: string
editLabels: string
editLabelsDescription: string
noLabelsFound: string
loading: string
notebookRequired: string
}
search: {
placeholder: string
@ -133,6 +182,27 @@ export interface Translations {
canEdit: string
canView: string
shareNote: string
shareWithCollaborators: string
addCollaboratorDescription: string
viewerDescription: string
emailAddress: string
enterEmailAddress: string
invite: string
peopleWithAccess: string
noCollaborators: string
noCollaboratorsViewer: string
pendingInvite: string
pending: string
remove: string
unnamedUser: string
done: string
willBeAdded: string
alreadyInList: string
nowHasAccess: string
accessRevoked: string
errorLoading: string
failedToAdd: string
failedToRemove: string
}
ai: {
analyzing: string
@ -143,6 +213,64 @@ export interface Translations {
poweredByAI: string
languageDetected: string
processing: string
tagAdded: string
titleGenerating: string
titleGenerateWithAI: string
titleGenerationMinWords: string
titleGenerationError: string
titlesGenerated: string
titleGenerationFailed: string
titleApplied: string
reformulationNoText: string
reformulationSelectionTooShort: string
reformulationMinWords: string
reformulationMaxWords: string
reformulationError: string
reformulationFailed: string
reformulationApplied: string
transformMarkdown: string
transforming: string
transformSuccess: string
transformError: string
assistant: string
generating: string
generateTitles: string
reformulateText: string
reformulating: string
clarify: string
shorten: string
improveStyle: string
reformulationComparison: string
original: string
reformulated: string
}
batchOrganization: {
error: string
noNotesSelected: string
title: string
description: string
analyzing: string
notesToOrganize: string
selected: string
noNotebooks: string
noSuggestions: string
confidence: string
unorganized: string
applying: string
apply: string
}
autoLabels: {
error: string
noLabelsSelected: string
created: string
analyzing: string
title: string
description: string
note: string
notes: string
typeContent: string
createNewLabel: string
new: string
}
titleSuggestions: {
available: string
@ -169,6 +297,71 @@ export interface Translations {
description: string
dailyInsight: string
insightReady: string
viewConnection: string
helpful: string
notHelpful: string
dismiss: string
thanksFeedback: string
thanksFeedbackImproving: string
connections: string
connection: string
connectionsBadge: string
fused: string
overlay: {
title: string
searchPlaceholder: string
sortBy: string
sortSimilarity: string
sortRecent: string
sortOldest: string
viewAll: string
loading: string
noConnections: string
}
comparison: {
title: string
similarityInfo: string
highSimilarityInsight: string
untitled: string
clickToView: string
helpfulQuestion: string
helpful: string
notHelpful: string
}
editorSection: {
title: string
loading: string
view: string
compare: string
merge: string
compareAll: string
mergeAll: string
}
fusion: {
title: string
mergeNotes: string
notesToMerge: string
optionalPrompt: string
promptPlaceholder: string
generateFusion: string
generating: string
previewTitle: string
edit: string
modify: string
finishEditing: string
optionsTitle: string
archiveOriginals: string
keepAllTags: string
useLatestTitle: string
createBacklinks: string
cancel: string
confirmFusion: string
success: string
error: string
generateError: string
noContentReturned: string
unknownDate: string
}
}
nav: {
home: string
@ -181,6 +374,29 @@ export interface Translations {
aiSettings: string
logout: string
login: string
adminDashboard: string
diagnostics: string
trash: string
support: string
reminders: string
userManagement: string
accountSettings: string
manageAISettings: string
configureAI: string
supportDevelopment: string
supportDescription: string
buyMeACoffee: string
donationDescription: string
donateOnKofi: string
donationNote: string
sponsorOnGithub: string
sponsorDescription: string
workspace: string
quickAccess: string
myLibrary: string
favorites: string
recent: string
proPlan: string
}
settings: {
title: string
@ -230,6 +446,21 @@ export interface Translations {
profileError: string
accountSettings: string
manageAISettings: string
displaySettings: string
displaySettingsDescription: string
fontSize: string
selectFontSize: string
fontSizeSmall: string
fontSizeMedium: string
fontSizeLarge: string
fontSizeExtraLarge: string
fontSizeDescription: string
fontSizeUpdateSuccess: string
fontSizeUpdateFailed: string
showRecentNotes: string
showRecentNotesDescription: string
recentNotesUpdateSuccess: string
recentNotesUpdateFailed: string
}
aiSettings: {
title: string
@ -287,6 +518,25 @@ export interface Translations {
save: string
cancel: string
}
notebook: {
create: string
createNew: string
createDescription: string
name: string
selectIcon: string
selectColor: string
cancel: string
creating: string
edit: string
editDescription: string
delete: string
deleteWarning: string
deleteConfirm: string
summary: string
summaryDescription: string
generating: string
summaryError: string
}
notebookSuggestion: {
title: string
description: string
@ -296,6 +546,354 @@ export interface Translations {
moveToNotebook: string
generalNotes: string
}
admin: {
title: string
userManagement: string
aiTesting: string
settings: string
security: {
title: string
description: string
allowPublicRegistration: string
allowPublicRegistrationDescription: string
updateSuccess: string
updateFailed: string
}
ai: {
title: string
description: string
tagsGenerationProvider: string
tagsGenerationDescription: string
embeddingsProvider: string
embeddingsDescription: string
provider: string
baseUrl: string
model: string
apiKey: string
selectOllamaModel: string
openAIKeyDescription: string
modelRecommendations: string
commonModelsDescription: string
selectEmbeddingModel: string
commonEmbeddingModels: string
saving: string
saveSettings: string
openTestPanel: string
updateSuccess: string
updateFailed: string
providerTagsRequired: string
providerEmbeddingRequired: string
}
smtp: {
title: string
description: string
host: string
port: string
username: string
password: string
fromEmail: string
forceSSL: string
ignoreCertErrors: string
saveSettings: string
sending: string
testEmail: string
updateSuccess: string
updateFailed: string
testSuccess: string
testFailed: string
}
users: {
createUser: string
addUser: string
createUserDescription: string
name: string
email: string
password: string
role: string
createSuccess: string
createFailed: string
deleteSuccess: string
deleteFailed: string
roleUpdateSuccess: string
roleUpdateFailed: string
table: {
name: string
email: string
role: string
createdAt: string
actions: string
}
}
aiTest: {
title: string
description: string
tagsTestTitle: string
tagsTestDescription: string
embeddingsTestTitle: string
embeddingsTestDescription: string
howItWorksTitle: string
provider: string
model: string
testing: string
runTest: string
testPassed: string
testFailed: string
responseTime: string
generatedTags: string
embeddingDimensions: string
vectorDimensions: string
first5Values: string
error: string
testError: string
tipTitle: string
tipDescription: string
}
}
about: {
title: string
description: string
appName: string
appDescription: string
version: string
buildDate: string
platform: string
platformWeb: string
features: {
title: string
description: string
titleSuggestions: string
semanticSearch: string
paragraphReformulation: string
memoryEcho: string
notebookOrganization: string
dragDrop: string
labelSystem: string
multipleProviders: string
}
technology: {
title: string
description: string
frontend: string
backend: string
database: string
authentication: string
ai: string
ui: string
testing: string
}
support: {
title: string
description: string
documentation: string
reportIssues: string
feedback: string
}
}
support: {
title: string
description: string
buyMeACoffee: string
donationDescription: string
donateOnKofi: string
kofiDescription: string
sponsorOnGithub: string
sponsorDescription: string
githubDescription: string
howSupportHelps: string
directImpact: string
sponsorPerks: string
transparency: string
transparencyDescription: string
hostingServers: string
domainSSL: string
aiApiCosts: string
totalExpenses: string
otherWaysTitle: string
starGithub: string
reportBug: string
contributeCode: string
shareTwitter: string
}
demoMode: {
title: string
activated: string
deactivated: string
toggleFailed: string
description: string
parametersActive: string
similarityThreshold: string
delayBetweenNotes: string
unlimitedInsights: string
createNotesTip: string
}
resetPassword: {
title: string
description: string
invalidLinkTitle: string
invalidLinkDescription: string
requestNewLink: string
newPassword: string
confirmNewPassword: string
resetting: string
resetPassword: string
passwordMismatch: string
success: string
loading: string
}
dataManagement: {
title: string
toolsDescription: string
export: {
title: string
description: string
button: string
success: string
failed: string
}
import: {
title: string
description: string
button: string
success: string
failed: string
}
delete: {
title: string
description: string
button: string
confirm: string
success: string
failed: string
}
indexing: {
title: string
description: string
button: string
success: string
failed: string
}
cleanup: {
title: string
description: string
button: string
failed: string
}
}
appearance: {
title: string
description: string
}
generalSettings: {
title: string
description: string
}
toast: {
saved: string
saveFailed: string
operationSuccess: string
operationFailed: string
openingConnection: string
openConnectionFailed: string
thanksFeedback: string
thanksFeedbackImproving: string
feedbackFailed: string
notesFusionSuccess: string
}
testPages: {
titleSuggestions: {
title: string
contentLabel: string
placeholder: string
wordCount: string
status: string
analyzing: string
idle: string
error: string
suggestions: string
noSuggestions: string
}
}
trash: {
title: string
empty: string
restore: string
deletePermanently: string
}
footer: {
privacy: string
terms: string
openSource: string
}
connection: {
similarityInfo: string
clickToView: string
isHelpful: string
helpful: string
notHelpful: string
memoryEchoDiscovery: string
}
diagnostics: {
title: string
configuredProvider: string
apiStatus: string
testDetails: string
troubleshootingTitle: string
tip1: string
tip2: string
tip3: string
tip4: string
}
batch: {
organizeWithAI: string
organize: string
}
common: {
unknown: string
notAvailable: string
loading: string
error: string
success: string
confirm: string
cancel: string
close: string
save: string
delete: string
edit: string
add: string
remove: string
search: string
noResults: string
required: string
optional: string
}
time: {
justNow: string
minutesAgo: string
hoursAgo: string
daysAgo: string
yesterday: string
today: string
tomorrow: string
}
favorites: {
title: string
toggleSection: string
noFavorites: string
pinToFavorite: string
}
notebooks: {
create: string
allNotebooks: string
noNotebooks: string
createFirst: string
}
ui: {
close: string
open: string
expand: string
collapse: string
}
[key: string]: any
}
/**
@ -304,12 +902,11 @@ export interface Translations {
export async function loadTranslations(language: SupportedLanguage): Promise<Translations> {
try {
const translations = await import(`@/locales/${language}.json`)
return translations.default as Translations
return translations.default as unknown as Translations
} catch (error) {
console.error(`Failed to load translations for ${language}:`, error)
// Fallback to English
const enTranslations = await import(`@/locales/en.json`)
return enTranslations.default as Translations
return enTranslations.default as unknown as Translations
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,8 @@
"forgotPasswordDescription": "Enter your email address and we'll send you a link to reset your password.",
"sending": "Sending...",
"sendResetLink": "Send Reset Link",
"backToLogin": "Back to login"
"backToLogin": "Back to login",
"signOut": "Sign out"
},
"sidebar": {
"notes": "Notes",
@ -118,7 +119,24 @@
"markdownOn": "Markdown ON",
"markdownOff": "Markdown OFF",
"undo": "Undo (Ctrl+Z)",
"redo": "Redo (Ctrl+Y)"
"redo": "Redo (Ctrl+Y)",
"pinnedNotes": "Pinned Notes",
"recent": "Recent",
"addNote": "Add Note",
"remove": "Remove",
"dragToReorder": "Drag to reorder",
"more": "More options",
"emptyState": "No notes yet. Create your first note!",
"inNotebook": "In notebook",
"moveFailed": "Failed to move note. Please try again.",
"clarifyFailed": "Failed to clarify text",
"shortenFailed": "Failed to shorten text",
"improveFailed": "Failed to improve text",
"transformFailed": "Failed to transform text",
"markdown": "Markdown",
"unpinned": "Unpinned",
"redoShortcut": "Redo (Ctrl+Y)",
"undoShortcut": "Undo (Ctrl+Z)"
},
"pagination": {
"previous": "←",
@ -154,7 +172,9 @@
"editLabelsDescription": "Create, edit colors, or delete labels.",
"noLabelsFound": "No labels found.",
"loading": "Loading...",
"notebookRequired": "⚠️ Labels are only available in notebooks. Move this note to a notebook first."
"notebookRequired": "⚠️ Labels are only available in notebooks. Move this note to a notebook first.",
"count": "{count} labels",
"noLabels": "No labels"
},
"search": {
"placeholder": "Search",
@ -235,7 +255,41 @@
"improveStyle": "Improve style",
"reformulationComparison": "Reformulation Comparison",
"original": "Original",
"reformulated": "Reformulated"
"reformulated": "Reformulated",
"autoLabels": {
"error": "Failed to fetch label suggestions",
"noLabelsSelected": "No labels selected",
"created": "{count} labels created successfully",
"analyzing": "Analyzing your notes...",
"title": "New Label Suggestions",
"description": "I've detected recurring themes in \"{notebookName}\" ({totalNotes} notes). Create labels for them?",
"note": "note",
"notes": "notes",
"typeContent": "Type content to get label suggestions...",
"createNewLabel": "Create this new label and add it",
"new": "(new)",
"create": "Create",
"creating": "Creating labels..."
},
"batchOrganization": {
"title": "Organize with AI",
"description": "AI will analyze your notes and suggest organizing them into notebooks.",
"analyzing": "Analyzing your notes...",
"noNotebooks": "No notebooks available. Create notebooks first to organize your notes.",
"noSuggestions": "AI could not find a good way to organize these notes.",
"apply": "Apply",
"applying": "Applying...",
"success": "{count} notes moved successfully",
"error": "Failed to create organization plan",
"noNotesSelected": "No notes selected",
"applyFailed": "Failed to apply organization plan",
"selectAllIn": "Select all notes in {notebook}",
"selectNote": "Select note: {title}"
},
"notebookSummary": {
"regenerate": "Regenerate Summary",
"regenerating": "Regenerating summary..."
}
},
"batchOrganization": {
"error": "Failed to create organization plan",
@ -300,6 +354,7 @@
"connection": "connection",
"connectionsBadge": "{count} connection{plural}",
"fused": "Fused",
"clickToView": "Click to view note →",
"overlay": {
"title": "Connected Notes",
"searchPlaceholder": "Search connections...",
@ -309,7 +364,8 @@
"sortOldest": "Oldest",
"viewAll": "View all side by side",
"loading": "Loading...",
"noConnections": "No connections found"
"noConnections": "No connections found",
"error": "Failed to load connections"
},
"comparison": {
"title": "💡 Note Comparison",
@ -328,7 +384,8 @@
"compare": "Compare",
"merge": "Merge",
"compareAll": "Compare all",
"mergeAll": "Merge all"
"mergeAll": "Merge all",
"close": "Close"
},
"fusion": {
"title": "🔗 Intelligent Fusion",
@ -408,7 +465,16 @@
"about": "About",
"version": "Version",
"settingsSaved": "Settings saved",
"settingsError": "Error saving settings"
"settingsError": "Error saving settings",
"maintenance": "Maintenance",
"maintenanceDescription": "Tools to maintain your database health",
"cleanTags": "Clean Orphan Tags",
"cleanTagsDescription": "Remove tags that are no longer used by any notes",
"semanticIndexing": "Semantic Indexing",
"semanticIndexingDescription": "Generate vectors for all notes to enable intent-based search",
"profile": "Profile",
"searchNoResults": "No settings found",
"languageAuto": "Language set to Auto"
},
"profile": {
"title": "Profile",
@ -468,7 +534,14 @@
"frequencyWeekly": "Weekly",
"saving": "Saving...",
"saved": "Setting updated",
"error": "Failed to update setting"
"error": "Failed to update setting",
"titleSuggestionsDesc": "Suggest titles for untitled notes after 50+ words",
"paragraphRefactorDesc": "AI-powered text improvement options",
"frequencyDesc": "How often to analyze note connections",
"providerDesc": "Choose your preferred AI provider",
"providerAutoDesc": "Ollama when available, OpenAI fallback",
"providerOllamaDesc": "100% private, runs locally on your machine",
"providerOpenAIDesc": "Most accurate, requires API key"
},
"general": {
"loading": "Loading...",
@ -489,7 +562,11 @@
"tryAgain": "Please try again",
"error": "An error occurred",
"operationSuccess": "Operation successful",
"operationFailed": "Operation failed"
"operationFailed": "Operation failed",
"testConnection": "Test Connection",
"clean": "Clean",
"indexAll": "Index All",
"preview": "Preview"
},
"colors": {
"default": "Default",
@ -528,7 +605,9 @@
"summary": "Notebook Summary",
"summaryDescription": "Generate an AI-powered summary of all notes in this notebook.",
"generating": "Generating summary...",
"summaryError": "Error generating summary"
"summaryError": "Error generating summary",
"labels": "Labels:",
"noLabels": "No labels"
},
"notebookSuggestion": {
"title": "Move to {icon} {name}?",
@ -538,5 +617,378 @@
"dismissIn": "Dismiss (closes in {timeLeft}s)",
"moveToNotebook": "Move to notebook",
"generalNotes": "General Notes"
},
"admin": {
"title": "Admin Dashboard",
"userManagement": "User Management",
"aiTesting": "AI Testing",
"settings": "Admin Settings",
"security": {
"title": "Security Settings",
"description": "Manage access control and registration policies.",
"allowPublicRegistration": "Allow Public Registration",
"allowPublicRegistrationDescription": "If disabled, new users can only be added by an Administrator via the User Management page.",
"updateSuccess": "Security Settings updated",
"updateFailed": "Failed to update security settings"
},
"ai": {
"title": "AI Configuration",
"description": "Configure AI providers for auto-tagging and semantic search. Use different providers for optimal performance.",
"tagsGenerationProvider": "Tags Generation Provider",
"tagsGenerationDescription": "AI provider for automatic tag suggestions. Recommended: Ollama (free, local).",
"embeddingsProvider": "Embeddings Provider",
"embeddingsDescription": "AI provider for semantic search embeddings. Recommended: OpenAI (best quality).",
"provider": "Provider",
"baseUrl": "Base URL",
"model": "Model",
"apiKey": "API Key",
"selectOllamaModel": "Select an Ollama model installed on your system",
"openAIKeyDescription": "Your OpenAI API key from platform.openai.com",
"modelRecommendations": "gpt-4o-mini = Best value • gpt-4o = Best quality",
"commonModelsDescription": "Common models for OpenAI-compatible APIs",
"selectEmbeddingModel": "Select an embedding model installed on your system",
"commonEmbeddingModels": "Common embedding models for OpenAI-compatible APIs",
"saving": "Saving...",
"saveSettings": "Save AI Settings",
"openTestPanel": "Open AI Test Panel",
"updateSuccess": "AI Settings updated successfully",
"updateFailed": "Failed to update AI settings",
"providerTagsRequired": "AI_PROVIDER_TAGS is required",
"providerEmbeddingRequired": "AI_PROVIDER_EMBEDDING is required",
"providerOllamaOption": "🦙 Ollama (Local & Free)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
"bestValue": "Best value",
"bestQuality": "Best quality",
"saved": "(Saved)"
},
"smtp": {
"title": "SMTP Configuration",
"description": "Configure email server for password resets.",
"host": "Host",
"port": "Port",
"username": "Username",
"password": "Password",
"fromEmail": "From Email",
"forceSSL": "Force SSL/TLS (usually for port 465)",
"ignoreCertErrors": "Ignore Certificate Errors (Self-hosted/Dev only)",
"saveSettings": "Save SMTP Settings",
"sending": "Sending...",
"testEmail": "Test Email",
"updateSuccess": "SMTP Settings updated",
"updateFailed": "Failed to update SMTP settings",
"testSuccess": "Test email sent successfully!",
"testFailed": "Failed: {error}"
},
"users": {
"createUser": "Create User",
"addUser": "Add User",
"createUserDescription": "Add a new user to the system.",
"name": "Name",
"email": "Email",
"password": "Password",
"role": "Role",
"createSuccess": "User created successfully",
"createFailed": "Failed to create user",
"deleteSuccess": "User deleted",
"deleteFailed": "Failed to delete",
"roleUpdateSuccess": "User role updated to {role}",
"roleUpdateFailed": "Failed to update role",
"demote": "Demote to User",
"promote": "Promote to Admin",
"confirmDelete": "Are you sure? This action cannot be undone.",
"table": {
"name": "Name",
"email": "Email",
"role": "Role",
"createdAt": "Created At",
"actions": "Actions"
},
"roles": {
"user": "User",
"admin": "Admin"
}
},
"aiTest": {
"title": "AI Provider Testing",
"description": "Test your AI providers for tag generation and semantic search embeddings",
"tagsTestTitle": "Tags Generation Test",
"tagsTestDescription": "Test the AI provider responsible for automatic tag suggestions",
"embeddingsTestTitle": "Embeddings Test",
"embeddingsTestDescription": "Test the AI provider responsible for semantic search embeddings",
"howItWorksTitle": "How Testing Works",
"provider": "Provider:",
"model": "Model:",
"testing": "Testing...",
"runTest": "Run Test",
"testPassed": "Test Passed",
"testFailed": "Test Failed",
"responseTime": "Response time: {time}ms",
"generatedTags": "Generated Tags:",
"embeddingDimensions": "Embedding Dimensions:",
"vectorDimensions": "vector dimensions",
"first5Values": "First 5 values:",
"error": "Error:",
"testError": "Test Error: {error}",
"tipTitle": "Tip:",
"tipDescription": "Use the AI Test Panel to diagnose configuration issues before testing."
}
},
"about": {
"title": "About",
"description": "Information about the application",
"appName": "Keep Notes",
"appDescription": "A powerful note-taking application with AI-powered features",
"version": "Version",
"buildDate": "Build Date",
"platform": "Platform",
"platformWeb": "Web",
"features": {
"title": "Features",
"description": "AI-powered capabilities",
"titleSuggestions": "AI-powered title suggestions",
"semanticSearch": "Semantic search with embeddings",
"paragraphReformulation": "Paragraph reformulation",
"memoryEcho": "Memory Echo daily insights",
"notebookOrganization": "Notebook organization",
"dragDrop": "Drag & drop note management",
"labelSystem": "Label system",
"multipleProviders": "Multiple AI providers (OpenAI, Ollama)"
},
"technology": {
"title": "Technology Stack",
"description": "Built with modern technologies",
"frontend": "Frontend",
"backend": "Backend",
"database": "Database",
"authentication": "Authentication",
"ai": "AI",
"ui": "UI",
"testing": "Testing"
},
"support": {
"title": "Support",
"description": "Get help and feedback",
"documentation": "Documentation",
"reportIssues": "Report Issues",
"feedback": "Feedback"
}
},
"support": {
"title": "Support Memento Development",
"description": "Memento is 100% free and open-source. Your support helps keep it that way.",
"buyMeACoffee": "Buy me a coffee",
"donationDescription": "Make a one-time donation or become a monthly supporter.",
"donateOnKofi": "Donate on Ko-fi",
"kofiDescription": "No platform fees • Instant payouts • Secure",
"sponsorOnGithub": "Sponsor on GitHub",
"sponsorDescription": "Become a monthly sponsor and get recognition.",
"githubDescription": "Recurring support • Public recognition • Developer-focused",
"howSupportHelps": "How Your Support Helps",
"directImpact": "Direct Impact",
"sponsorPerks": "Sponsor Perks",
"transparency": "Transparency",
"transparencyDescription": "I believe in complete transparency. Here's how donations are used:",
"hostingServers": "Hosting & servers:",
"domainSSL": "Domain & SSL:",
"aiApiCosts": "AI API costs:",
"totalExpenses": "Total expenses:",
"otherWaysTitle": "Other Ways to Support",
"starGithub": "Star on GitHub",
"reportBug": "Report a bug",
"contributeCode": "Contribute code",
"shareTwitter": "Share on Twitter"
},
"demoMode": {
"title": "Demo Mode",
"activated": "Demo Mode activated! Memory Echo will now work instantly.",
"deactivated": "Demo Mode disabled. Normal parameters restored.",
"toggleFailed": "Failed to toggle demo mode",
"description": "Speeds up Memory Echo for testing. Connections appear instantly.",
"parametersActive": "Demo parameters active:",
"similarityThreshold": "50% similarity threshold (normally 75%)",
"delayBetweenNotes": "0-day delay between notes (normally 7 days)",
"unlimitedInsights": "Unlimited insights (no frequency limits)",
"createNotesTip": "Create 2+ similar notes and see Memory Echo in action!"
},
"resetPassword": {
"title": "Reset Password",
"description": "Enter your new password below.",
"invalidLinkTitle": "Invalid Link",
"invalidLinkDescription": "This password reset link is invalid or has expired.",
"requestNewLink": "Request new link",
"newPassword": "New Password",
"confirmNewPassword": "Confirm New Password",
"resetting": "Resetting...",
"resetPassword": "Reset Password",
"passwordMismatch": "Passwords don't match",
"success": "Password reset successfully. You can now login.",
"loading": "Loading..."
},
"dataManagement": {
"title": "Data Management",
"toolsDescription": "Tools to maintain your database health",
"exporting": "Exporting...",
"importing": "Importing...",
"deleting": "Deleting...",
"dangerZone": "Danger Zone",
"dangerZoneDescription": "Permanently delete your data",
"indexingComplete": "Indexing complete: {count} notes processed",
"indexingError": "Error during indexing",
"cleanupComplete": "Cleanup complete: {created} created, {deleted} removed",
"cleanupError": "Error during cleanup",
"export": {
"title": "Export All Notes",
"description": "Download all your notes as a JSON file. This includes all content, labels, and metadata.",
"button": "Export Notes",
"success": "Notes exported successfully",
"failed": "Failed to export notes"
},
"import": {
"title": "Import Notes",
"description": "Upload a JSON file to import notes. This will add to your existing notes, not replace them.",
"button": "Import Notes",
"success": "Imported {count} notes",
"failed": "Failed to import notes"
},
"delete": {
"title": "Delete All Notes",
"description": "Permanently delete all your notes. This action cannot be undone.",
"button": "Delete All Notes",
"confirm": "Are you sure? This will permanently delete all your notes.",
"success": "All notes deleted",
"failed": "Failed to delete notes"
},
"indexing": {
"title": "Rebuild Search Index",
"description": "Regenerate embeddings for all notes to improve semantic search.",
"button": "Rebuild Index",
"success": "Indexing complete: {count} notes processed",
"failed": "Error during indexing"
},
"cleanup": {
"title": "Cleanup Orphaned Data",
"description": "Remove labels and connections that reference deleted notes.",
"button": "Cleanup",
"failed": "Error during cleanup"
}
},
"appearance": {
"title": "Appearance",
"description": "Customize how the app looks"
},
"generalSettings": {
"title": "General Settings",
"description": "General application settings"
},
"toast": {
"saved": "Setting saved",
"saveFailed": "Failed to save setting",
"operationSuccess": "Operation successful",
"operationFailed": "Operation failed",
"openingConnection": "Opening connection...",
"openConnectionFailed": "Failed to open connection",
"thanksFeedback": "Thanks for your feedback!",
"thanksFeedbackImproving": "Thanks! We'll use this to improve.",
"feedbackFailed": "Failed to submit feedback",
"notesFusionSuccess": "Notes merged successfully!"
},
"testPages": {
"titleSuggestions": {
"title": "Test Title Suggestions",
"contentLabel": "Content (need 50+ words):",
"placeholder": "Type at least 50 words here...",
"wordCount": "Word count:",
"status": "Status:",
"analyzing": "Analyzing...",
"idle": "Idle",
"error": "Error:",
"suggestions": "Suggestions ({count}):",
"noSuggestions": "No suggestions yet. Type 50+ words and wait 2 seconds."
}
},
"trash": {
"title": "Trash",
"empty": "The trash is empty",
"restore": "Restore",
"deletePermanently": "Delete Permanently"
},
"footer": {
"privacy": "Privacy",
"terms": "Terms",
"openSource": "Open Source Clone"
},
"connection": {
"similarityInfo": "These notes are connected by {similarity}% similarity",
"clickToView": "Click to view note",
"isHelpful": "Is this connection helpful?",
"helpful": "Helpful",
"notHelpful": "Not Helpful",
"memoryEchoDiscovery": "Memory Echo Discovery"
},
"diagnostics": {
"title": "Diagnostics",
"description": "Check your AI provider connection status",
"configuredProvider": "Configured Provider",
"apiStatus": "API Status",
"operational": "Operational",
"errorStatus": "Error",
"checking": "Checking...",
"testDetails": "Test Details:",
"troubleshootingTitle": "Troubleshooting Tips:",
"tip1": "Make sure Ollama is running (ollama serve)",
"tip2": "Check that the model is installed (ollama pull llama3)",
"tip3": "Verify your API key for OpenAI",
"tip4": "Check network connectivity"
},
"batch": {
"organizeWithAI": "Organize with AI",
"organize": "Organize"
},
"common": {
"unknown": "Unknown",
"notAvailable": "N/A",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"confirm": "Confirm",
"cancel": "Cancel",
"close": "Close",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"remove": "Remove",
"search": "Search",
"noResults": "No results",
"required": "Required",
"optional": "Optional"
},
"time": {
"justNow": "just now",
"minutesAgo": "{count}m ago",
"hoursAgo": "{count}h ago",
"daysAgo": "{count}d ago",
"yesterday": "Yesterday",
"today": "Today",
"tomorrow": "Tomorrow"
},
"favorites": {
"title": "Favorites",
"toggleSection": "Toggle pinned notes section",
"noFavorites": "No pinned notes yet",
"pinToFavorite": "Pin a note to add it to favorites"
},
"notebooks": {
"create": "Create notebook",
"allNotebooks": "All Notebooks",
"noNotebooks": "No notebooks yet",
"createFirst": "Create your first notebook"
},
"ui": {
"close": "Close",
"open": "Open",
"expand": "Expand",
"collapse": "Collapse"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -0,0 +1,22 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const configs = await prisma.systemConfig.findMany()
console.log('--- System Config ---')
configs.forEach(c => {
if (c.key.startsWith('AI_') || c.key.startsWith('OLLAMA_')) {
console.log(`${c.key}: ${c.value}`)
}
})
}
main()
.catch(e => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})