feat: rename keep-notes to memento-note, migrate to PostgreSQL, fix MCP bugs

- Rename directory keep-notes -> memento-note with all code references
- Prisma: SQLite -> PostgreSQL (both app and MCP server schemas)
- Sync MCP schema with main app (add missing fields, relations, indexes)
- Delete 17 SQLite migrations (clean slate for PostgreSQL)
- Remove SQLite dependencies (@libsql/client, better-sqlite3, etc.)
- Fix MCP server: hardcoded Windows DB paths -> DATABASE_URL env var
- Fix MCP server: .dockerignore excluded index-sse.js (SSE mode broken)
- MCP Dockerfile: node:20 -> node:22
- Docker Compose: add postgres service, remove SQLite volume
- Generate favicon.ico, icon-192.png, icon-512.png, apple-icon.png
- Update layout.tsx icons and manifest.json for PNG icons
- Update all .env files for PostgreSQL
- Rewrite README.md with updated sections
- Remove mcp-server/node_modules and prisma/client-generated from git tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-04-20 20:58:04 +02:00
parent e2ddfa53d4
commit aa6a214f37
3548 changed files with 440 additions and 516800 deletions

View File

@@ -0,0 +1,618 @@
'use client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
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, useCallback } from 'react'
import Link from 'next/link'
import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
type AIProvider = 'ollama' | 'openai' | 'custom'
interface AvailableModels {
tags: string[]
embeddings: string[]
}
const MODELS_2026 = {
// 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']
},
custom: {
tags: ['gpt-4o-mini', 'gpt-4o', 'claude-3-haiku', 'claude-3-sonnet', 'llama-3.1-8b'],
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
}
}
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
const { t } = useLanguage()
const [isSaving, setIsSaving] = useState(false)
const [isTesting, setIsTesting] = useState(false)
// Local state for Checkbox
const [allowRegister, setAllowRegister] = useState(config.ALLOW_REGISTRATION !== 'false')
const [smtpSecure, setSmtpSecure] = useState(config.SMTP_SECURE === 'true')
const [smtpIgnoreCert, setSmtpIgnoreCert] = useState(config.SMTP_IGNORE_CERT === 'true')
// AI Provider state - separated for tags and embeddings
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
// 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 = {
ALLOW_REGISTRATION: allowRegister ? 'true' : 'false',
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error(t('admin.security.updateFailed'))
} else {
toast.success(t('admin.security.updateSuccess'))
}
}
const handleSaveAI = async (formData: FormData) => {
setIsSaving(true)
const data: Record<string, string> = {}
try {
const tagsProv = formData.get('AI_PROVIDER_TAGS') as AIProvider
if (!tagsProv) throw new Error(t('admin.ai.providerTagsRequired'))
data.AI_PROVIDER_TAGS = tagsProv
const tagsModel = formData.get('AI_MODEL_TAGS') as string
if (tagsModel) data.AI_MODEL_TAGS = tagsModel
if (tagsProv === 'ollama') {
const ollamaUrl = formData.get('OLLAMA_BASE_URL_TAGS') as string
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
} else if (tagsProv === 'custom') {
const customKey = formData.get('CUSTOM_OPENAI_API_KEY_TAGS') as string
const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_TAGS') as string
if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
}
const embedProv = formData.get('AI_PROVIDER_EMBEDDING') as AIProvider
if (!embedProv) throw new Error(t('admin.ai.providerEmbeddingRequired'))
data.AI_PROVIDER_EMBEDDING = embedProv
const embedModel = formData.get('AI_MODEL_EMBEDDING') as string
if (embedModel) data.AI_MODEL_EMBEDDING = embedModel
if (embedProv === 'ollama') {
const ollamaUrl = formData.get('OLLAMA_BASE_URL_EMBEDDING') as string
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
} else if (embedProv === 'custom') {
const customKey = formData.get('CUSTOM_OPENAI_API_KEY_EMBEDDING') as string
const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_EMBEDDING') as string
if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error(t('admin.ai.updateFailed') + ': ' + result.error)
} else {
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(t('general.error') + ': ' + error.message)
}
}
const handleSaveSMTP = async (formData: FormData) => {
setIsSaving(true)
const data = {
SMTP_HOST: formData.get('SMTP_HOST') as string,
SMTP_PORT: formData.get('SMTP_PORT') as string,
SMTP_USER: formData.get('SMTP_USER') as string,
SMTP_PASS: formData.get('SMTP_PASS') as string,
SMTP_FROM: formData.get('SMTP_FROM') as string,
SMTP_IGNORE_CERT: smtpIgnoreCert ? 'true' : 'false',
SMTP_SECURE: smtpSecure ? 'true' : 'false',
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error(t('admin.smtp.updateFailed'))
} else {
toast.success(t('admin.smtp.updateSuccess'))
}
}
const handleTestEmail = async () => {
setIsTesting(true)
try {
const result: any = await testSMTP()
if (result.success) {
toast.success(t('admin.smtp.testSuccess'))
} else {
toast.error(t('admin.smtp.testFailed', { error: result.error }))
}
} catch (e: any) {
toast.error(t('general.error') + ': ' + e.message)
} finally {
setIsTesting(false)
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('admin.security.title')}</CardTitle>
<CardDescription>{t('admin.security.description')}</CardDescription>
</CardHeader>
<form action={handleSaveSecurity}>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="ALLOW_REGISTRATION"
checked={allowRegister}
onCheckedChange={(c) => setAllowRegister(!!c)}
/>
<label
htmlFor="ALLOW_REGISTRATION"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('admin.security.allowPublicRegistration')}
</label>
</div>
<p className="text-xs text-muted-foreground">
{t('admin.security.allowPublicRegistrationDescription')}
</p>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.ai.title')}</CardTitle>
<CardDescription>{t('admin.ai.description')}</CardDescription>
</CardHeader>
<form action={handleSaveAI}>
<CardContent className="space-y-6">
<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> {t('admin.ai.tagsGenerationProvider')}
</h3>
<p className="text-xs text-muted-foreground">{t('admin.ai.tagsGenerationDescription')}</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_TAGS">{t('admin.ai.provider')}</Label>
<select
id="AI_PROVIDER_TAGS"
name="AI_PROVIDER_TAGS"
value={tagsProvider}
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">{t('admin.ai.providerOllamaOption')}</option>
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
</select>
</div>
{tagsProvider === 'ollama' && (
<div className="space-y-3">
<div className="space-y-2">
<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">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_OLLAMA"
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"
>
{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">
{isLoadingTagsModels ? 'Fetching models...' : t('admin.ai.selectOllamaModel')}
</p>
</div>
</div>
)}
{tagsProvider === 'openai' && (
<div className="space-y-3">
<div className="space-y-2">
<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">{t('admin.ai.openAIKeyDescription')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_OPENAI">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_OPENAI"
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> = {t('admin.ai.bestValue')} <strong className="text-primary">gpt-4o</strong> = {t('admin.ai.bestQuality')}</p>
</div>
</div>
)}
{tagsProvider === 'custom' && (
<div className="space-y-3">
<div className="space-y-2">
<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">{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">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_CUSTOM"
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">{t('admin.ai.commonModelsDescription')}</p>
</div>
</div>
)}
</div>
<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> {t('admin.ai.embeddingsProvider')}
</h3>
<p className="text-xs text-muted-foreground">{t('admin.ai.embeddingsDescription')}</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_EMBEDDING">
{t('admin.ai.provider')}
<span className="ml-2 text-xs text-muted-foreground">
(Current: {embeddingsProvider})
</span>
</Label>
<select
id="AI_PROVIDER_EMBEDDING"
name="AI_PROVIDER_EMBEDDING"
value={embeddingsProvider}
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">{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>
{embeddingsProvider === 'ollama' && (
<div className="space-y-3">
<div className="space-y-2">
<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">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_OLLAMA"
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"
>
{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">
{isLoadingEmbeddingsModels ? 'Fetching models...' : t('admin.ai.selectEmbeddingModel')}
</p>
</div>
</div>
)}
{embeddingsProvider === 'openai' && (
<div className="space-y-3">
<div className="space-y-2">
<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">{t('admin.ai.openAIKeyDescription')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_OPENAI"
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> = {t('admin.ai.bestValue')} <strong className="text-primary">text-embedding-3-large</strong> = {t('admin.ai.bestQuality')}</p>
</div>
</div>
)}
{embeddingsProvider === 'custom' && (
<div className="space-y-3">
<div className="space-y-2">
<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">{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">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_CUSTOM"
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">{t('admin.ai.commonEmbeddingModels')}</p>
</div>
</div>
)}
</div>
</CardContent>
<CardFooter className="flex justify-between">
<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" />
{t('admin.ai.openTestPanel')}
<ExternalLink className="h-3 w-3" />
</Button>
</Link>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<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">{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">{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">{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">{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">{t('admin.smtp.fromEmail')}</label>
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="SMTP_SECURE"
checked={smtpSecure}
onCheckedChange={(c) => setSmtpSecure(!!c)}
/>
<label
htmlFor="SMTP_SECURE"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('admin.smtp.forceSSL')}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="SMTP_IGNORE_CERT"
checked={smtpIgnoreCert}
onCheckedChange={(c) => setSmtpIgnoreCert(!!c)}
/>
<label
htmlFor="SMTP_IGNORE_CERT"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
>
{t('admin.smtp.ignoreCertErrors')}
</label>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button type="submit" disabled={isSaving}>{t('admin.smtp.saveSettings')}</Button>
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
</Button>
</CardFooter>
</form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { getSystemConfig } from '@/app/actions/admin-settings'
import { AdminSettingsForm } from './admin-settings-form'
export default async function AdminSettingsPage() {
const config = await getSystemConfig()
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Settings
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Configure application-wide settings
</p>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
<AdminSettingsForm config={config} />
</div>
</div>
)
}