refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client
This commit is contained in:
@@ -15,7 +15,7 @@ export default async function AdminLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50 dark:bg-zinc-950">
|
||||
<div className="flex h-full bg-gray-50 dark:bg-zinc-950">
|
||||
<AdminSidebar />
|
||||
<AdminContentArea>{children}</AdminContentArea>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,8 @@ 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 { Combobox } from '@/components/ui/combobox'
|
||||
import { updateSystemConfig, testEmail } from '@/app/actions/admin-settings'
|
||||
import { getOllamaModels } from '@/app/actions/ollama'
|
||||
import { getCustomModels, getCustomEmbeddingModels } from '@/app/actions/custom-provider'
|
||||
import { toast } from 'sonner'
|
||||
@@ -38,43 +39,55 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
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
|
||||
// Agent tools state
|
||||
const [webSearchProvider, setWebSearchProvider] = useState(config.WEB_SEARCH_PROVIDER || 'searxng')
|
||||
|
||||
// Email provider state
|
||||
const [emailProvider, setEmailProvider] = useState<'resend' | 'smtp'>(config.EMAIL_PROVIDER as 'resend' | 'smtp' || (config.RESEND_API_KEY ? 'resend' : 'smtp'))
|
||||
const [emailTestResult, setEmailTestResult] = useState<{ provider: 'resend' | 'smtp'; success: boolean; message?: string } | null>(null)
|
||||
|
||||
// AI Provider state - separated for tags, embeddings, and chat
|
||||
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
||||
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
||||
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT 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 || '')
|
||||
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
|
||||
|
||||
|
||||
// Dynamic Models State
|
||||
const [ollamaTagsModels, setOllamaTagsModels] = useState<string[]>([])
|
||||
const [ollamaEmbeddingsModels, setOllamaEmbeddingsModels] = useState<string[]>([])
|
||||
const [ollamaChatModels, setOllamaChatModels] = useState<string[]>([])
|
||||
const [isLoadingTagsModels, setIsLoadingTagsModels] = useState(false)
|
||||
const [isLoadingEmbeddingsModels, setIsLoadingEmbeddingsModels] = useState(false)
|
||||
const [isLoadingChatModels, setIsLoadingChatModels] = useState(false)
|
||||
|
||||
// Custom provider dynamic models
|
||||
const [customTagsModels, setCustomTagsModels] = useState<string[]>([])
|
||||
const [customEmbeddingsModels, setCustomEmbeddingsModels] = useState<string[]>([])
|
||||
const [customChatModels, setCustomChatModels] = useState<string[]>([])
|
||||
const [isLoadingCustomTagsModels, setIsLoadingCustomTagsModels] = useState(false)
|
||||
const [isLoadingCustomEmbeddingsModels, setIsLoadingCustomEmbeddingsModels] = useState(false)
|
||||
const [customTagsSearch, setCustomTagsSearch] = useState('')
|
||||
const [customEmbeddingsSearch, setCustomEmbeddingsSearch] = useState('')
|
||||
|
||||
const [isLoadingCustomChatModels, setIsLoadingCustomChatModels] = useState(false)
|
||||
|
||||
// Fetch Ollama models
|
||||
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings', url: string) => {
|
||||
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings' | 'chat', url: string) => {
|
||||
if (!url) return
|
||||
|
||||
if (type === 'tags') setIsLoadingTagsModels(true)
|
||||
else setIsLoadingEmbeddingsModels(true)
|
||||
else if (type === 'embeddings') setIsLoadingEmbeddingsModels(true)
|
||||
else setIsLoadingChatModels(true)
|
||||
|
||||
try {
|
||||
const result = await getOllamaModels(url)
|
||||
|
||||
if (result.success) {
|
||||
if (type === 'tags') setOllamaTagsModels(result.models)
|
||||
else setOllamaEmbeddingsModels(result.models)
|
||||
else if (type === 'embeddings') setOllamaEmbeddingsModels(result.models)
|
||||
else setOllamaChatModels(result.models)
|
||||
} else {
|
||||
toast.error(`Failed to fetch Ollama models: ${result.error}`)
|
||||
}
|
||||
@@ -83,16 +96,18 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
toast.error('Failed to fetch Ollama models')
|
||||
} finally {
|
||||
if (type === 'tags') setIsLoadingTagsModels(false)
|
||||
else setIsLoadingEmbeddingsModels(false)
|
||||
else if (type === 'embeddings') setIsLoadingEmbeddingsModels(false)
|
||||
else setIsLoadingChatModels(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch Custom provider models — tags use /v1/models, embeddings use /v1/embeddings/models
|
||||
const fetchCustomModels = useCallback(async (type: 'tags' | 'embeddings', url: string, apiKey?: string) => {
|
||||
const fetchCustomModels = useCallback(async (type: 'tags' | 'embeddings' | 'chat', url: string, apiKey?: string) => {
|
||||
if (!url) return
|
||||
|
||||
if (type === 'tags') setIsLoadingCustomTagsModels(true)
|
||||
else setIsLoadingCustomEmbeddingsModels(true)
|
||||
else if (type === 'embeddings') setIsLoadingCustomEmbeddingsModels(true)
|
||||
else setIsLoadingCustomChatModels(true)
|
||||
|
||||
try {
|
||||
const result = type === 'embeddings'
|
||||
@@ -101,7 +116,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
if (result.success && result.models.length > 0) {
|
||||
if (type === 'tags') setCustomTagsModels(result.models)
|
||||
else setCustomEmbeddingsModels(result.models)
|
||||
else if (type === 'embeddings') setCustomEmbeddingsModels(result.models)
|
||||
else setCustomChatModels(result.models)
|
||||
} else {
|
||||
toast.error(`Impossible de récupérer les modèles : ${result.error}`)
|
||||
}
|
||||
@@ -110,7 +126,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
toast.error('Erreur lors de la récupération des modèles')
|
||||
} finally {
|
||||
if (type === 'tags') setIsLoadingCustomTagsModels(false)
|
||||
else setIsLoadingCustomEmbeddingsModels(false)
|
||||
else if (type === 'embeddings') setIsLoadingCustomEmbeddingsModels(false)
|
||||
else setIsLoadingCustomChatModels(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -144,6 +161,18 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tagsProvider])
|
||||
|
||||
useEffect(() => {
|
||||
if (chatProvider === 'ollama') {
|
||||
const url = config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('chat', url)
|
||||
} else if (chatProvider === 'custom') {
|
||||
const url = config.CUSTOM_OPENAI_BASE_URL || ''
|
||||
const key = config.CUSTOM_OPENAI_API_KEY || ''
|
||||
if (url) fetchCustomModels('chat', url, key)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatProvider])
|
||||
|
||||
const handleSaveSecurity = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
const data = {
|
||||
@@ -205,6 +234,27 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
|
||||
}
|
||||
|
||||
// Chat provider config
|
||||
const chatProv = formData.get('AI_PROVIDER_CHAT') as AIProvider
|
||||
if (chatProv) {
|
||||
data.AI_PROVIDER_CHAT = chatProv
|
||||
|
||||
const chatModel = formData.get(`AI_MODEL_CHAT_${chatProv.toUpperCase()}`) as string
|
||||
if (chatModel) data.AI_MODEL_CHAT = chatModel
|
||||
|
||||
if (chatProv === 'ollama') {
|
||||
const ollamaUrl = formData.get('OLLAMA_BASE_URL_CHAT') as string
|
||||
if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl
|
||||
} else if (chatProv === 'openai') {
|
||||
const openaiKey = formData.get('OPENAI_API_KEY') as string
|
||||
if (openaiKey) data.OPENAI_API_KEY = openaiKey
|
||||
} else if (chatProv === 'custom') {
|
||||
const customKey = formData.get('CUSTOM_OPENAI_API_KEY_CHAT') as string
|
||||
const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_CHAT') 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)
|
||||
@@ -220,16 +270,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSMTP = async (formData: FormData) => {
|
||||
const handleSaveEmail = 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 data: Record<string, string> = { EMAIL_PROVIDER: emailProvider }
|
||||
|
||||
if (emailProvider === 'resend') {
|
||||
const key = formData.get('RESEND_API_KEY') as string
|
||||
if (key) data.RESEND_API_KEY = key
|
||||
} else {
|
||||
data.SMTP_HOST = formData.get('SMTP_HOST') as string
|
||||
data.SMTP_PORT = formData.get('SMTP_PORT') as string
|
||||
data.SMTP_USER = formData.get('SMTP_USER') as string
|
||||
data.SMTP_PASS = formData.get('SMTP_PASS') as string
|
||||
data.SMTP_FROM = formData.get('SMTP_FROM') as string
|
||||
data.SMTP_IGNORE_CERT = smtpIgnoreCert ? 'true' : 'false'
|
||||
data.SMTP_SECURE = smtpSecure ? 'true' : 'false'
|
||||
}
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
@@ -245,19 +300,41 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const handleTestEmail = async () => {
|
||||
setIsTesting(true)
|
||||
try {
|
||||
const result: any = await testSMTP()
|
||||
const result: any = await testEmail(emailProvider)
|
||||
if (result.success) {
|
||||
toast.success(t('admin.smtp.testSuccess'))
|
||||
setEmailTestResult({ provider: emailProvider, success: true })
|
||||
} else {
|
||||
toast.error(t('admin.smtp.testFailed', { error: result.error }))
|
||||
setEmailTestResult({ provider: emailProvider, success: false, message: result.error })
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(t('general.error') + ': ' + e.message)
|
||||
setEmailTestResult({ provider: emailProvider, success: false, message: e.message })
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveTools = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
const data = {
|
||||
WEB_SEARCH_PROVIDER: formData.get('WEB_SEARCH_PROVIDER') as string || 'searxng',
|
||||
SEARXNG_URL: formData.get('SEARXNG_URL') as string || '',
|
||||
BRAVE_SEARCH_API_KEY: formData.get('BRAVE_SEARCH_API_KEY') as string || '',
|
||||
JINA_API_KEY: formData.get('JINA_API_KEY') as string || '',
|
||||
}
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.error) {
|
||||
toast.error(t('admin.tools.updateFailed'))
|
||||
} else {
|
||||
toast.success(t('admin.tools.updateSuccess'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@@ -433,35 +510,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
<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>
|
||||
{customTagsModels.length > 0 && (
|
||||
<Input
|
||||
placeholder="Rechercher un modèle..."
|
||||
value={customTagsSearch}
|
||||
onChange={(e) => setCustomTagsSearch(e.target.value)}
|
||||
className="mb-1"
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
id="AI_MODEL_TAGS_CUSTOM"
|
||||
name="AI_MODEL_TAGS_CUSTOM"
|
||||
<Label>{t('admin.ai.model')}</Label>
|
||||
<input type="hidden" name="AI_MODEL_TAGS_CUSTOM" value={selectedTagsModel} />
|
||||
<Combobox
|
||||
options={customTagsModels.length > 0
|
||||
? customTagsModels.map((m) => ({ value: m, label: m }))
|
||||
: selectedTagsModel
|
||||
? [{ value: selectedTagsModel, label: selectedTagsModel }]
|
||||
: []
|
||||
}
|
||||
value={selectedTagsModel}
|
||||
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||
className={`flex 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 ${customTagsModels.length > 0 ? 'h-auto min-h-[180px]' : 'h-10'}`}
|
||||
size={customTagsModels.length > 0 ? Math.min(8, customTagsModels.filter(m => m.toLowerCase().includes(customTagsSearch.toLowerCase())).length) : 1}
|
||||
>
|
||||
{customTagsModels.length > 0 ? (
|
||||
customTagsModels
|
||||
.filter(m => m.toLowerCase().includes(customTagsSearch.toLowerCase()))
|
||||
.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
selectedTagsModel
|
||||
? <option value={selectedTagsModel}>{selectedTagsModel}</option>
|
||||
: <option value="" disabled>Cliquez sur ↺ pour récupérer les modèles</option>
|
||||
)}
|
||||
</select>
|
||||
onChange={setSelectedTagsModel}
|
||||
placeholder={selectedTagsModel || 'Cliquez sur ↺ pour charger les modèles'}
|
||||
searchPlaceholder="Rechercher un modèle..."
|
||||
emptyMessage="Aucun modèle. Cliquez sur ↺"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingCustomTagsModels
|
||||
? 'Récupération des modèles...'
|
||||
@@ -619,35 +682,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
<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>
|
||||
{customEmbeddingsModels.length > 0 && (
|
||||
<Input
|
||||
placeholder="Rechercher un modèle..."
|
||||
value={customEmbeddingsSearch}
|
||||
onChange={(e) => setCustomEmbeddingsSearch(e.target.value)}
|
||||
className="mb-1"
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
id="AI_MODEL_EMBEDDING_CUSTOM"
|
||||
name="AI_MODEL_EMBEDDING_CUSTOM"
|
||||
<Label>{t('admin.ai.model')}</Label>
|
||||
<input type="hidden" name="AI_MODEL_EMBEDDING_CUSTOM" value={selectedEmbeddingModel} />
|
||||
<Combobox
|
||||
options={customEmbeddingsModels.length > 0
|
||||
? customEmbeddingsModels.map((m) => ({ value: m, label: m }))
|
||||
: selectedEmbeddingModel
|
||||
? [{ value: selectedEmbeddingModel, label: selectedEmbeddingModel }]
|
||||
: []
|
||||
}
|
||||
value={selectedEmbeddingModel}
|
||||
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||
className={`flex 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 ${customEmbeddingsModels.length > 0 ? 'h-auto min-h-[180px]' : 'h-10'}`}
|
||||
size={customEmbeddingsModels.length > 0 ? Math.min(8, customEmbeddingsModels.filter(m => m.toLowerCase().includes(customEmbeddingsSearch.toLowerCase())).length) : 1}
|
||||
>
|
||||
{customEmbeddingsModels.length > 0 ? (
|
||||
customEmbeddingsModels
|
||||
.filter(m => m.toLowerCase().includes(customEmbeddingsSearch.toLowerCase()))
|
||||
.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
selectedEmbeddingModel
|
||||
? <option value={selectedEmbeddingModel}>{selectedEmbeddingModel}</option>
|
||||
: <option value="" disabled>Cliquez sur ↺ pour récupérer les modèles</option>
|
||||
)}
|
||||
</select>
|
||||
onChange={setSelectedEmbeddingModel}
|
||||
placeholder={selectedEmbeddingModel || 'Cliquez sur ↺ pour charger les modèles'}
|
||||
searchPlaceholder="Rechercher un modèle..."
|
||||
emptyMessage="Aucun modèle. Cliquez sur ↺"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingCustomEmbeddingsModels
|
||||
? 'Récupération des modèles...'
|
||||
@@ -659,6 +708,171 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat Provider Section */}
|
||||
<div className="space-y-4 p-4 border rounded-lg bg-blue-50/50 dark:bg-blue-950/20">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<span className="text-blue-600">💬</span> {t('admin.ai.chatProvider')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.chatDescription')}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_PROVIDER_CHAT">{t('admin.ai.provider')}</Label>
|
||||
<select
|
||||
id="AI_PROVIDER_CHAT"
|
||||
name="AI_PROVIDER_CHAT"
|
||||
value={chatProvider}
|
||||
onChange={(e) => {
|
||||
const newProvider = e.target.value as AIProvider
|
||||
setChatProvider(newProvider)
|
||||
const defaultModels: Record<string, string> = {
|
||||
ollama: '',
|
||||
openai: MODELS_2026.openai.tags[0],
|
||||
custom: '',
|
||||
}
|
||||
setSelectedChatModel(defaultModels[newProvider] || '')
|
||||
}}
|
||||
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>
|
||||
|
||||
{chatProvider === 'ollama' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OLLAMA_BASE_URL_CHAT">{t('admin.ai.baseUrl')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="OLLAMA_BASE_URL_CHAT"
|
||||
name="OLLAMA_BASE_URL_CHAT"
|
||||
defaultValue={config.OLLAMA_BASE_URL_CHAT || 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_CHAT') as HTMLInputElement
|
||||
fetchOllamaModels('chat', input.value)
|
||||
}}
|
||||
disabled={isLoadingChatModels}
|
||||
title="Refresh Models"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingChatModels ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_CHAT_OLLAMA">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_CHAT_OLLAMA"
|
||||
name="AI_MODEL_CHAT_OLLAMA"
|
||||
value={selectedChatModel}
|
||||
onChange={(e) => setSelectedChatModel(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"
|
||||
>
|
||||
{ollamaChatModels.length > 0 ? (
|
||||
ollamaChatModels.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
<option value={selectedChatModel || 'granite4:latest'}>{selectedChatModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingChatModels ? 'Fetching models...' : t('admin.ai.selectOllamaModel')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chatProvider === 'openai' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OPENAI_API_KEY_CHAT">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="OPENAI_API_KEY_CHAT" 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_CHAT_OPENAI">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_CHAT_OPENAI"
|
||||
name="AI_MODEL_CHAT_OPENAI"
|
||||
value={selectedChatModel}
|
||||
onChange={(e) => setSelectedChatModel(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>
|
||||
)}
|
||||
|
||||
{chatProvider === 'custom' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_CHAT">{t('admin.ai.baseUrl')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="CUSTOM_OPENAI_BASE_URL_CHAT"
|
||||
name="CUSTOM_OPENAI_BASE_URL_CHAT"
|
||||
defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const urlInput = document.getElementById('CUSTOM_OPENAI_BASE_URL_CHAT') as HTMLInputElement
|
||||
const keyInput = document.getElementById('CUSTOM_OPENAI_API_KEY_CHAT') as HTMLInputElement
|
||||
fetchCustomModels('chat', urlInput.value, keyInput.value)
|
||||
}}
|
||||
disabled={isLoadingCustomChatModels}
|
||||
title="Récupérer les modèles"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingCustomChatModels ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_API_KEY_CHAT">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="CUSTOM_OPENAI_API_KEY_CHAT" name="CUSTOM_OPENAI_API_KEY_CHAT" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('admin.ai.model')}</Label>
|
||||
<input type="hidden" name="AI_MODEL_CHAT_CUSTOM" value={selectedChatModel} />
|
||||
<Combobox
|
||||
options={customChatModels.length > 0
|
||||
? customChatModels.map((m) => ({ value: m, label: m }))
|
||||
: selectedChatModel
|
||||
? [{ value: selectedChatModel, label: selectedChatModel }]
|
||||
: []
|
||||
}
|
||||
value={selectedChatModel}
|
||||
onChange={setSelectedChatModel}
|
||||
placeholder={selectedChatModel || 'Cliquez sur ↺ pour charger les modèles'}
|
||||
searchPlaceholder="Rechercher un modèle..."
|
||||
emptyMessage="Aucun modèle. Cliquez sur ↺"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingCustomChatModels
|
||||
? 'Récupération des modèles...'
|
||||
: customChatModels.length > 0
|
||||
? `${customChatModels.length} modèle(s) disponible(s)`
|
||||
: 'Renseignez l\'URL et cliquez sur ↺ pour charger les modèles'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between pt-6">
|
||||
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||
@@ -675,73 +889,195 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.smtp.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.smtp.description')}</CardDescription>
|
||||
<CardTitle>{t('admin.email.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.email.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveSMTP(new FormData(e.currentTarget)) }}>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveEmail(new FormData(e.currentTarget)) }}>
|
||||
<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 className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('admin.email.provider')}</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmailProvider('resend')}
|
||||
className={`flex-1 px-4 py-2.5 rounded-lg border text-sm font-medium transition-colors ${
|
||||
emailProvider === 'resend'
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmailProvider('smtp')}
|
||||
className={`flex-1 px-4 py-2.5 rounded-lg border text-sm font-medium transition-colors ${
|
||||
emailProvider === 'smtp'
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
SMTP
|
||||
</button>
|
||||
</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 || ''} />
|
||||
{/* Email service status */}
|
||||
<div className="rounded-lg border bg-muted/30 p-3 space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t('admin.email.status')}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${config.RESEND_API_KEY ? 'bg-green-500' : 'bg-slate-300'}`} />
|
||||
<span className={config.RESEND_API_KEY ? 'text-green-700' : 'text-slate-400'}>Resend</span>
|
||||
{config.RESEND_API_KEY && <span className="text-xs text-muted-foreground">({t('admin.email.keySet')})</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${config.SMTP_HOST ? 'bg-green-500' : 'bg-slate-300'}`} />
|
||||
<span className={config.SMTP_HOST ? 'text-green-700' : 'text-slate-400'}>SMTP</span>
|
||||
{config.SMTP_HOST && <span className="text-xs text-muted-foreground">({config.SMTP_HOST}:{config.SMTP_PORT || '587'})</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-primary">
|
||||
{t('admin.email.activeProvider')}: {emailProvider === 'resend' ? 'Resend' : 'SMTP'}
|
||||
</div>
|
||||
{emailTestResult && (
|
||||
<div className={`flex items-center gap-1.5 text-xs pt-1 border-t ${emailTestResult.success ? 'text-green-600' : 'text-red-500'}`}>
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${emailTestResult.success ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span>
|
||||
{emailTestResult.provider === 'resend' ? 'Resend' : 'SMTP'} — {emailTestResult.success ? t('admin.email.testOk') : `${t('admin.email.testFail')}: ${emailTestResult.message || ''}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
{emailProvider === 'resend' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="RESEND_API_KEY" className="text-sm font-medium">{t('admin.resend.apiKey')}</label>
|
||||
<Input id="RESEND_API_KEY" name="RESEND_API_KEY" type="password" defaultValue={config.RESEND_API_KEY || ''} placeholder="re_..." />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.resend.apiKeyHint')}</p>
|
||||
</div>
|
||||
{config.RESEND_API_KEY && (
|
||||
<div className="flex items-center gap-2 text-xs text-green-600">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-green-500" />
|
||||
{t('admin.resend.configured')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<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_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="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="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="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="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>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between pt-6">
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.smtp.saveSettings')}</Button>
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.email.saveSettings')}</Button>
|
||||
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
||||
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.tools.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.tools.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveTools(new FormData(e.currentTarget)) }}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="WEB_SEARCH_PROVIDER" className="text-sm font-medium">{t('admin.tools.searchProvider')}</label>
|
||||
<select
|
||||
id="WEB_SEARCH_PROVIDER"
|
||||
name="WEB_SEARCH_PROVIDER"
|
||||
value={webSearchProvider}
|
||||
onChange={(e) => setWebSearchProvider(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"
|
||||
>
|
||||
<option value="searxng">{t('admin.tools.searxng')}</option>
|
||||
<option value="brave">{t('admin.tools.brave')}</option>
|
||||
<option value="both">{t('admin.tools.both')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(webSearchProvider === 'searxng' || webSearchProvider === 'both') && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SEARXNG_URL" className="text-sm font-medium">{t('admin.tools.searxngUrl')}</label>
|
||||
<Input id="SEARXNG_URL" name="SEARXNG_URL" defaultValue={config.SEARXNG_URL || 'http://localhost:8080'} placeholder="http://localhost:8080" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(webSearchProvider === 'brave' || webSearchProvider === 'both') && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="BRAVE_SEARCH_API_KEY" className="text-sm font-medium">{t('admin.tools.braveKey')}</label>
|
||||
<Input id="BRAVE_SEARCH_API_KEY" name="BRAVE_SEARCH_API_KEY" type="password" defaultValue={config.BRAVE_SEARCH_API_KEY || ''} placeholder="BSA-..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="JINA_API_KEY" className="text-sm font-medium">{t('admin.tools.jinaKey')}</label>
|
||||
<Input id="JINA_API_KEY" name="JINA_API_KEY" type="password" defaultValue={config.JINA_API_KEY || ''} placeholder={t('admin.tools.jinaKeyOptional')} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.tools.jinaKeyDescription')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.tools.saveSettings')}</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ interface AgentItem {
|
||||
tools?: string | null
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
_count: { actions: number }
|
||||
actions: { id: string; status: string; createdAt: string | Date }[]
|
||||
notebook?: { id: string; name: string; icon?: string | null } | null
|
||||
@@ -118,6 +119,7 @@ export function AgentsPageClient({
|
||||
tools: formData.get('tools') ? JSON.parse(formData.get('tools') as string) : undefined,
|
||||
maxSteps: formData.get('maxSteps') ? Number(formData.get('maxSteps')) : undefined,
|
||||
notifyEmail: formData.get('notifyEmail') === 'true',
|
||||
includeImages: formData.get('includeImages') === 'true',
|
||||
}
|
||||
|
||||
if (editingAgent) {
|
||||
|
||||
33
keep-notes/app/(main)/agents/page.tsx
Normal file
33
keep-notes/app/(main)/agents/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAgents } from '@/app/actions/agent-actions'
|
||||
import { AgentsPageClient } from './agents-page-client'
|
||||
|
||||
export default async function AgentsPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const [agents, notebooks] = await Promise.all([
|
||||
getAgents(),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-slate-50/50">
|
||||
<div className="flex-1 p-8 overflow-y-auto">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<AgentsPageClient
|
||||
agents={agents}
|
||||
notebooks={notebooks}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
keep-notes/app/(main)/chat/page.tsx
Normal file
36
keep-notes/app/(main)/chat/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Metadata } from 'next'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ChatContainer } from '@/components/chat/chat-container'
|
||||
import { getConversations } from '@/app/actions/chat-actions'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Chat IA | Memento',
|
||||
description: 'Discutez avec vos notes et vos agents IA',
|
||||
}
|
||||
|
||||
export default async function ChatPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Fetch initial data
|
||||
const [conversations, notebooks] = await Promise.all([
|
||||
getConversations(),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-white dark:bg-[#1a1c22]">
|
||||
<ChatContainer
|
||||
initialConversations={conversations}
|
||||
notebooks={notebooks}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
keep-notes/app/(main)/lab/page.tsx
Normal file
56
keep-notes/app/(main)/lab/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Metadata } from 'next'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getCanvases, createCanvas } from '@/app/actions/canvas-actions'
|
||||
import { LabHeader } from '@/components/lab/lab-header'
|
||||
import { CanvasWrapper } from '@/components/lab/canvas-wrapper'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Le Lab | Memento',
|
||||
description: 'Visualisez et connectez vos idées sur un canvas interactif',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
export default async function LabPage(props: {
|
||||
searchParams: Promise<{ id?: string }>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const id = searchParams.id
|
||||
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const canvases = await getCanvases()
|
||||
|
||||
// Resolve current canvas correctly
|
||||
const currentCanvasId = searchParams.id || (canvases.length > 0 ? canvases[0].id : undefined)
|
||||
const currentCanvas = currentCanvasId ? canvases.find(c => c.id === currentCanvasId) : undefined
|
||||
|
||||
// Wrapper for server action creation
|
||||
async function handleCreate() {
|
||||
'use server'
|
||||
const newCanvas = await createCanvas()
|
||||
redirect(`/lab?id=${newCanvas.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-slate-50 dark:bg-[#1a1c22] overflow-hidden">
|
||||
<LabHeader
|
||||
canvases={canvases}
|
||||
currentCanvasId={currentCanvasId ?? null}
|
||||
onCreateCanvas={handleCreate}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<CanvasWrapper
|
||||
key={currentCanvasId || 'new'}
|
||||
canvasId={currentCanvas?.id}
|
||||
name={currentCanvas?.name || "Nouvel Espace de Pensée"}
|
||||
initialData={currentCanvas?.data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,13 +2,7 @@ import { getAllNotes } from '@/app/actions/notes'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { HomeClient } from '@/components/home-client'
|
||||
|
||||
/**
|
||||
* Page principale — Server Component.
|
||||
* Les notes et settings sont chargés côté serveur en parallèle,
|
||||
* éliminant le spinner de chargement initial et améliorant le TTI.
|
||||
*/
|
||||
export default async function HomePage() {
|
||||
// Charge notes + settings en parallèle côté serveur
|
||||
const [allNotes, settings] = await Promise.all([
|
||||
getAllNotes(),
|
||||
getAISettings(),
|
||||
|
||||
@@ -10,12 +10,14 @@ import { useLanguage } from '@/lib/i18n'
|
||||
interface AppearanceSettingsFormProps {
|
||||
initialTheme: string
|
||||
initialFontSize: string
|
||||
initialCardSizeMode?: string
|
||||
}
|
||||
|
||||
export function AppearanceSettingsForm({ initialTheme, initialFontSize }: AppearanceSettingsFormProps) {
|
||||
export function AppearanceSettingsForm({ initialTheme, initialFontSize, initialCardSizeMode = 'variable' }: AppearanceSettingsFormProps) {
|
||||
const router = useRouter()
|
||||
const [theme, setTheme] = useState(initialTheme)
|
||||
const [fontSize, setFontSize] = useState(initialFontSize)
|
||||
const [cardSizeMode, setCardSizeMode] = useState(initialCardSizeMode)
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
@@ -55,6 +57,12 @@ export function AppearanceSettingsForm({ initialTheme, initialFontSize }: Appear
|
||||
await updateAI({ fontSize: value as any })
|
||||
}
|
||||
|
||||
const handleCardSizeModeChange = async (value: string) => {
|
||||
setCardSizeMode(value)
|
||||
localStorage.setItem('card-size-mode', value)
|
||||
await updateUser({ cardSizeMode: value as 'variable' | 'uniform' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -102,6 +110,23 @@ export function AppearanceSettingsForm({ initialTheme, initialFontSize }: Appear
|
||||
onChange={handleFontSizeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.cardSizeMode')}
|
||||
icon={<span className="text-2xl">📐</span>}
|
||||
description={t('settings.cardSizeModeDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('settings.cardSizeMode')}
|
||||
description={t('settings.selectCardSizeMode')}
|
||||
value={cardSizeMode}
|
||||
options={[
|
||||
{ value: 'variable', label: t('settings.cardSizeVariable') },
|
||||
{ value: 'uniform', label: t('settings.cardSizeUniform') },
|
||||
]}
|
||||
onChange={handleCardSizeModeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,13 +11,15 @@ interface AppearanceSettingsClientProps {
|
||||
initialFontSize: string
|
||||
initialTheme: string
|
||||
initialNotesViewMode: 'masonry' | 'tabs'
|
||||
initialCardSizeMode?: 'variable' | 'uniform'
|
||||
}
|
||||
|
||||
export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode }: AppearanceSettingsClientProps) {
|
||||
export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode, initialCardSizeMode = 'variable' }: AppearanceSettingsClientProps) {
|
||||
const { t } = useLanguage()
|
||||
const [theme, setTheme] = useState(initialTheme || 'light')
|
||||
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
||||
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs'>(initialNotesViewMode)
|
||||
const [cardSizeMode, setCardSizeMode] = useState<'variable' | 'uniform'>(initialCardSizeMode)
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
setTheme(value)
|
||||
@@ -59,6 +61,14 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
const handleCardSizeModeChange = async (value: string) => {
|
||||
const mode = value === 'uniform' ? 'uniform' : 'variable'
|
||||
setCardSizeMode(mode)
|
||||
localStorage.setItem('card-size-mode', mode)
|
||||
await updateUserSettings({ cardSizeMode: mode })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -122,6 +132,23 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
||||
onChange={handleNotesViewChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.cardSizeMode')}
|
||||
icon={<span className="text-2xl">📐</span>}
|
||||
description={t('settings.cardSizeModeDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('settings.cardSizeMode')}
|
||||
description={t('settings.selectCardSizeMode')}
|
||||
value={cardSizeMode}
|
||||
options={[
|
||||
{ value: 'variable', label: t('settings.cardSizeVariable') },
|
||||
{ value: 'uniform', label: t('settings.cardSizeUniform') },
|
||||
]}
|
||||
onChange={handleCardSizeModeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default async function AppearanceSettingsPage() {
|
||||
initialFontSize={aiSettings.fontSize}
|
||||
initialTheme={userSettings.theme}
|
||||
initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'}
|
||||
initialCardSizeMode={userSettings.cardSizeMode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ interface GeneralSettingsClientProps {
|
||||
preferredLanguage: string
|
||||
emailNotifications: boolean
|
||||
desktopNotifications: boolean
|
||||
anonymousAnalytics: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +21,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
|
||||
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
|
||||
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
|
||||
const [anonymousAnalytics, setAnonymousAnalytics] = useState(initialSettings.anonymousAnalytics ?? false)
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
setLanguage(value)
|
||||
@@ -52,12 +50,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
const handleAnonymousAnalyticsChange = async (enabled: boolean) => {
|
||||
setAnonymousAnalytics(enabled)
|
||||
await updateAISettings({ anonymousAnalytics: enabled })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -116,19 +108,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
onChange={handleDesktopNotificationsChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.privacy')}
|
||||
icon={<span className="text-2xl">🔒</span>}
|
||||
description={t('settings.privacyDesc')}
|
||||
>
|
||||
<SettingToggle
|
||||
label={t('settings.anonymousAnalytics')}
|
||||
description={t('settings.anonymousAnalyticsDesc')}
|
||||
checked={anonymousAnalytics}
|
||||
onChange={handleAnonymousAnalyticsChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { getTrashedNotes } from '@/app/actions/notes'
|
||||
import { MasonryGrid } from '@/components/masonry-grid'
|
||||
import { TrashHeader } from '@/components/trash-header'
|
||||
import { TrashEmptyState } from './trash-empty-state'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function TrashPage() {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<TrashContent />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
export default async function TrashPage() {
|
||||
const notes = await getTrashedNotes()
|
||||
|
||||
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>
|
||||
)
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<TrashHeader noteCount={notes.length} />
|
||||
{notes.length > 0 ? (
|
||||
<MasonryGrid notes={notes} isTrashView />
|
||||
) : (
|
||||
<TrashEmptyState />
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
20
keep-notes/app/(main)/trash/trash-empty-state.tsx
Normal file
20
keep-notes/app/(main)/trash/trash-empty-state.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function TrashEmptyState() {
|
||||
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.emptyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user