Refactor Admin and Settings UI to Ethereal Precision aesthetic and improve note import/export functionality
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m4s
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m4s
This commit is contained in:
@@ -56,33 +56,33 @@ export function AdminAIPageClient({
|
|||||||
key: 'titleSuggestions' as const,
|
key: 'titleSuggestions' as const,
|
||||||
label: t('admin.ai.titleSuggestions'),
|
label: t('admin.ai.titleSuggestions'),
|
||||||
description: t('admin.ai.titleSuggestionsDesc'),
|
description: t('admin.ai.titleSuggestionsDesc'),
|
||||||
icon: <Sparkles className="h-4 w-4 text-yellow-500" />,
|
icon: <Sparkles className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: 'paragraphRefactor' as const,
|
key: 'paragraphRefactor' as const,
|
||||||
label: t('admin.ai.aiAssistant'),
|
label: t('admin.ai.aiAssistant'),
|
||||||
description: t('admin.ai.aiAssistantDesc'),
|
description: t('admin.ai.aiAssistantDesc'),
|
||||||
icon: <Brain className="h-4 w-4 text-purple-500" />,
|
icon: <Brain className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: 'memoryEcho' as const,
|
key: 'memoryEcho' as const,
|
||||||
label: t('admin.ai.memoryEchoFeature'),
|
label: t('admin.ai.memoryEchoFeature'),
|
||||||
description: t('admin.ai.memoryEchoFeatureDesc'),
|
description: t('admin.ai.memoryEchoFeatureDesc'),
|
||||||
icon: <Zap className="h-4 w-4 text-amber-500" />,
|
icon: <Zap className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'languageDetection' as const,
|
key: 'languageDetection' as const,
|
||||||
label: t('admin.ai.languageDetection'),
|
label: t('admin.ai.languageDetection'),
|
||||||
description: t('admin.ai.languageDetectionDesc'),
|
description: t('admin.ai.languageDetectionDesc'),
|
||||||
icon: <Globe className="h-4 w-4 text-green-500" />,
|
icon: <Globe className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'autoLabeling' as const,
|
key: 'autoLabeling' as const,
|
||||||
label: t('admin.ai.autoLabeling'),
|
label: t('admin.ai.autoLabeling'),
|
||||||
description: t('admin.ai.autoLabelingDesc'),
|
description: t('admin.ai.autoLabelingDesc'),
|
||||||
icon: <Tag className="h-4 w-4 text-rose-500" />,
|
icon: <Tag className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -91,40 +91,40 @@ export function AdminAIPageClient({
|
|||||||
title: t('admin.ai.activeFeatures'),
|
title: t('admin.ai.activeFeatures'),
|
||||||
value: String(Object.values(features).filter(Boolean).length) + ' / ' + featureList.length,
|
value: String(Object.values(features).filter(Boolean).length) + ' / ' + featureList.length,
|
||||||
trend: { value: 0, isPositive: true },
|
trend: { value: 0, isPositive: true },
|
||||||
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
|
icon: <Zap className="h-5 w-5 text-primary" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('admin.ai.successRate'),
|
title: t('admin.ai.successRate'),
|
||||||
value: '100%',
|
value: '100%',
|
||||||
trend: { value: 0, isPositive: true },
|
trend: { value: 0, isPositive: true },
|
||||||
icon: <TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />,
|
icon: <TrendingUp className="h-5 w-5 text-green-600" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('admin.ai.avgResponseTime'),
|
title: t('admin.ai.avgResponseTime'),
|
||||||
value: '—',
|
value: '—',
|
||||||
trend: { value: 0, isPositive: true },
|
trend: { value: 0, isPositive: true },
|
||||||
icon: <Activity className="h-5 w-5 text-primary dark:text-primary-foreground" />,
|
icon: <Activity className="h-5 w-5 text-primary" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('admin.ai.configuredProviders'),
|
title: t('admin.ai.configuredProviders'),
|
||||||
value: String(providers.filter(p => p.status !== 'Not Configured').length),
|
value: String(providers.filter(p => p.status !== 'Not Configured').length),
|
||||||
icon: <Settings className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
|
icon: <Settings className="h-5 w-5 text-primary" />,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
{t('admin.ai.pageTitle')}
|
{t('admin.ai.pageTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
{t('admin.ai.pageDescription')}
|
{t('admin.ai.pageDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/settings">
|
<Link href="/admin/settings">
|
||||||
<Button variant="outline">
|
<Button variant="outline" className="border-border">
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
{t('admin.ai.configure')}
|
{t('admin.ai.configure')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -133,25 +133,31 @@ export function AdminAIPageClient({
|
|||||||
|
|
||||||
<AdminMetrics metrics={aiMetrics} />
|
<AdminMetrics metrics={aiMetrics} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Feature Toggles */}
|
{/* Feature Toggles */}
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
{t('admin.ai.features')}
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
</h2>
|
<Zap className="h-5 w-5" />
|
||||||
<div className="space-y-4">
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-foreground">{t('admin.ai.features')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Activez ou désactivez les fonctionnalités IA</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 pt-2 border-t border-border">
|
||||||
{featureList.map(({ key, label, description, icon }) => (
|
{featureList.map(({ key, label, description, icon }) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
|
className="flex items-start justify-between p-3 bg-muted rounded-lg border border-border/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||||
{icon}
|
<div className="mt-0.5 text-primary">{icon}</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +166,7 @@ export function AdminAIPageClient({
|
|||||||
checked={features[key]}
|
checked={features[key]}
|
||||||
onCheckedChange={(v) => handleToggle(key, v)}
|
onCheckedChange={(v) => handleToggle(key, v)}
|
||||||
disabled={saving === key}
|
disabled={saving === key}
|
||||||
className="ml-3 flex-shrink-0"
|
className="ml-3 mt-0.5 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -168,42 +174,53 @@ export function AdminAIPageClient({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Provider Status */}
|
{/* AI Provider Status */}
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
<div className="flex flex-col gap-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
{t('admin.ai.providerStatus')}
|
<div className="flex items-center gap-3 mb-2">
|
||||||
</h2>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<div className="space-y-3">
|
<Settings className="h-5 w-5" />
|
||||||
{providers.map((provider) => (
|
|
||||||
<div
|
|
||||||
key={provider.name}
|
|
||||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
|
|
||||||
>
|
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{provider.name}
|
|
||||||
</p>
|
|
||||||
<span
|
|
||||||
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-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{provider.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div>
|
||||||
|
<h2 className="font-semibold text-foreground">{t('admin.ai.providerStatus')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">État de vos fournisseurs connectés</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 pt-2 border-t border-border">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<div
|
||||||
|
key={provider.name}
|
||||||
|
className="flex items-center justify-between p-3 bg-muted rounded-lg border border-border/50"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{provider.name}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
|
provider.status === 'Connected' || provider.status === 'Available'
|
||||||
|
? 'text-green-700 bg-green-500/10 border border-green-500/20'
|
||||||
|
: 'text-muted-foreground bg-muted-foreground/10 border border-muted-foreground/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{provider.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground mb-2">
|
||||||
|
{t('admin.ai.recentRequests')}
|
||||||
|
</h2>
|
||||||
|
<div className="p-4 rounded-lg bg-muted border border-border/50 flex flex-col items-center justify-center text-center">
|
||||||
|
<Activity className="h-6 w-6 text-muted-foreground mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('admin.ai.comingSoon')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
||||||
{t('admin.ai.recentRequests')}
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
{t('admin.ai.comingSoon')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { AdminHeader } from '@/components/admin-header'
|
import { AdminHeader } from '@/components/admin-header'
|
||||||
import { AdminSidebar } from '@/components/admin-sidebar'
|
import { AdminNav } from '@/components/admin-nav'
|
||||||
import { AdminContentArea } from '@/components/admin-content-area'
|
|
||||||
|
|
||||||
// Auth is enforced solely by middleware (auth.config.ts → authorized callback).
|
// Auth is enforced solely by middleware (auth.config.ts → authorized callback).
|
||||||
// All cross-group navigation (admin ↔ main) uses <a> tags (full page reload)
|
// All cross-group navigation (admin ↔ main) uses <a> tags (full page reload)
|
||||||
@@ -11,11 +10,19 @@ export default function AdminLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background-light dark:bg-background-dark font-display text-slate-900 dark:text-white flex flex-col min-h-screen">
|
<div className="bg-background flex flex-col min-h-screen">
|
||||||
<AdminHeader />
|
<AdminHeader />
|
||||||
<div className="flex flex-1">
|
|
||||||
<AdminSidebar />
|
{/* Horizontal Tab Navigation */}
|
||||||
<AdminContentArea>{children}</AdminContentArea>
|
<div className="flex items-center gap-1 px-8 bg-background border-b border-border shrink-0">
|
||||||
|
<AdminNav />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-5xl mx-auto px-8 py-8 space-y-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default async function AdminPage() {
|
|||||||
|
|
||||||
<AdminMetrics metrics={metrics} />
|
<AdminMetrics metrics={metrics} />
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Combobox } from '@/components/ui/combobox'
|
|||||||
import { updateSystemConfig, testEmail } from '@/app/actions/admin-settings'
|
import { updateSystemConfig, testEmail } from '@/app/actions/admin-settings'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'
|
import { TestTube, ExternalLink, RefreshCw, Shield, Brain, Mail, Wrench } from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
type AIProvider = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio'
|
type AIProvider = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio'
|
||||||
@@ -80,6 +80,7 @@ type ModelPurpose = 'tags' | 'embeddings' | 'chat'
|
|||||||
|
|
||||||
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isTesting, setIsTesting] = useState(false)
|
const [isTesting, setIsTesting] = useState(false)
|
||||||
|
|
||||||
@@ -546,14 +547,19 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="columns-1 lg:columns-2 gap-6">
|
||||||
<Card>
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<CardTitle>{t('admin.security.title')}</CardTitle>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<CardDescription>{t('admin.security.description')}</CardDescription>
|
<Shield className="h-5 w-5" />
|
||||||
</CardHeader>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-foreground">{t('admin.security.title')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.security.description')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveSecurity(new FormData(e.currentTarget)) }}>
|
<form onSubmit={(e) => { e.preventDefault(); handleSaveSecurity(new FormData(e.currentTarget)) }}>
|
||||||
<CardContent className="space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="ALLOW_REGISTRATION"
|
id="ALLOW_REGISTRATION"
|
||||||
@@ -570,22 +576,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('admin.security.allowPublicRegistrationDescription')}
|
{t('admin.security.allowPublicRegistrationDescription')}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter>
|
<div className="px-6 pb-6">
|
||||||
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
|
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
|
||||||
</CardFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<CardTitle>{t('admin.ai.title')}</CardTitle>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<CardDescription>{t('admin.ai.description')}</CardDescription>
|
<Brain className="h-5 w-5" />
|
||||||
</CardHeader>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-foreground">{t('admin.ai.title')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.ai.description')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveAI(new FormData(e.currentTarget)) }}>
|
<form onSubmit={(e) => { e.preventDefault(); handleSaveAI(new FormData(e.currentTarget)) }}>
|
||||||
<CardContent className="space-y-6">
|
<div className="px-6 pt-6">
|
||||||
|
<div className="flex border-b border-border/50 overflow-x-auto">
|
||||||
|
<button type="button" onClick={() => setActiveAiTab('tags')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'tags' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🏷️ Tags</button>
|
||||||
|
<button type="button" onClick={() => setActiveAiTab('embeddings')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'embeddings' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🔍 Embeddings</button>
|
||||||
|
<button type="button" onClick={() => setActiveAiTab('chat')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>💬 Chat</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
{/* Tags Generation Provider */}
|
{/* Tags Generation Provider */}
|
||||||
<div className="space-y-4 p-4 border rounded-lg bg-primary/5 dark:bg-primary/10">
|
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'tags' ? 'block' : 'hidden'}`}>
|
||||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||||
<span className="text-primary">🏷️</span> {t('admin.ai.tagsGenerationProvider')}
|
<span className="text-primary">🏷️</span> {t('admin.ai.tagsGenerationProvider')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -615,7 +633,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Embeddings Provider */}
|
{/* Embeddings Provider */}
|
||||||
<div className="space-y-4 p-4 border rounded-lg bg-green-50/50 dark:bg-green-950/20">
|
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'embeddings' ? 'block' : 'hidden'}`}>
|
||||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||||
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
|
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -650,7 +668,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat Provider */}
|
{/* Chat Provider */}
|
||||||
<div className="space-y-4 p-4 border rounded-lg bg-blue-50/50 dark:bg-blue-950/20">
|
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'chat' ? 'block' : 'hidden'}`}>
|
||||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||||
<span className="text-blue-600">💬</span> {t('admin.ai.chatProvider')}
|
<span className="text-blue-600">💬</span> {t('admin.ai.chatProvider')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -678,8 +696,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
<input type="hidden" name="AI_MODEL_CHAT" value={selectedChatModel} />
|
<input type="hidden" name="AI_MODEL_CHAT" value={selectedChatModel} />
|
||||||
{renderProviderConfig(chatProvider, 'chat', selectedChatModel, setSelectedChatModel)}
|
{renderProviderConfig(chatProvider, 'chat', selectedChatModel, setSelectedChatModel)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter className="flex justify-between pt-6">
|
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
|
||||||
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||||
<a href="/admin/ai-test">
|
<a href="/admin/ai-test">
|
||||||
<Button type="button" variant="outline" className="gap-2">
|
<Button type="button" variant="outline" className="gap-2">
|
||||||
@@ -688,17 +706,22 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</CardFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<CardTitle>{t('admin.email.title')}</CardTitle>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<CardDescription>{t('admin.email.description')}</CardDescription>
|
<Mail className="h-5 w-5" />
|
||||||
</CardHeader>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-foreground">{t('admin.email.title')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.email.description')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveEmail(new FormData(e.currentTarget)) }}>
|
<form onSubmit={(e) => { e.preventDefault(); handleSaveEmail(new FormData(e.currentTarget)) }}>
|
||||||
<CardContent className="space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">{t('admin.email.provider')}</label>
|
<label className="text-sm font-medium">{t('admin.email.provider')}</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -826,23 +849,28 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter className="flex justify-between pt-6">
|
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
|
||||||
<Button type="submit" disabled={isSaving}>{t('admin.email.saveSettings')}</Button>
|
<Button type="submit" disabled={isSaving}>{t('admin.email.saveSettings')}</Button>
|
||||||
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
||||||
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<CardTitle>{t('admin.tools.title')}</CardTitle>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<CardDescription>{t('admin.tools.description')}</CardDescription>
|
<Wrench className="h-5 w-5" />
|
||||||
</CardHeader>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-foreground">{t('admin.tools.title')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.tools.description')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveTools(new FormData(e.currentTarget)) }}>
|
<form onSubmit={(e) => { e.preventDefault(); handleSaveTools(new FormData(e.currentTarget)) }}>
|
||||||
<CardContent className="space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="WEB_SEARCH_PROVIDER" className="text-sm font-medium">{t('admin.tools.searchProvider')}</label>
|
<label htmlFor="WEB_SEARCH_PROVIDER" className="text-sm font-medium">{t('admin.tools.searchProvider')}</label>
|
||||||
<select
|
<select
|
||||||
@@ -880,13 +908,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
|
|
||||||
{/* Search test result */}
|
{/* Search test result */}
|
||||||
{searchTestResult && (
|
{searchTestResult && (
|
||||||
<div className={`rounded-lg border p-3 text-sm flex items-start gap-2 ${searchTestResult.success ? 'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300' : 'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300'}`}>
|
<div className={`rounded-lg border p-3 text-sm flex items-start gap-2 ${searchTestResult.success ? 'border-green-500/20 bg-green-500/10 text-green-600' : 'border-red-500/20 bg-red-500/10 text-red-600'}`}>
|
||||||
<span className={`mt-0.5 inline-block w-2 h-2 rounded-full flex-shrink-0 ${searchTestResult.success ? 'bg-green-500' : 'bg-red-500'}`} />
|
<span className={`mt-0.5 inline-block w-2 h-2 rounded-full flex-shrink-0 ${searchTestResult.success ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
<span>{searchTestResult.message}</span>
|
<span>{searchTestResult.message}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter className="flex justify-between">
|
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between">
|
||||||
<Button type="submit" disabled={isSaving}>{t('admin.tools.saveSettings')}</Button>
|
<Button type="submit" disabled={isSaving}>{t('admin.tools.saveSettings')}</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -896,9 +924,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
>
|
>
|
||||||
{isTestingSearch ? t('admin.tools.testing') : t('admin.tools.testSearch')}
|
{isTestingSearch ? t('admin.tools.testing') : t('admin.tools.testSearch')}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ export default async function AdminSettingsPage() {
|
|||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<SettingsHeader />
|
<SettingsHeader />
|
||||||
<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} />
|
||||||
<AdminSettingsForm config={config} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ export function SettingsHeader() {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
{t('admin.settings')}
|
{t('admin.settings')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
{t('admin.settingsDescription')}
|
{t('admin.settingsDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default async function AdminUsersPage() {
|
|||||||
<CreateUserDialog />
|
<CreateUserDialog />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800">
|
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border">
|
||||||
<UserList initialUsers={users} />
|
<UserList initialUsers={users} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { SettingsSection } from '@/components/settings'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { FileText, Sparkles, MessageCircle } from 'lucide-react'
|
||||||
|
|
||||||
export default function AboutSettingsPage() {
|
export default function AboutSettingsPage() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
@@ -11,126 +10,95 @@ export default function AboutSettingsPage() {
|
|||||||
const buildDate = '2026-01-17'
|
const buildDate = '2026-01-17'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">{t('about.title')}</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('about.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-muted-foreground mt-1">{t('about.description')}</p>
|
||||||
{t('about.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
title={t('about.appName')}
|
{/* App info */}
|
||||||
icon={<span className="text-2xl">📝</span>}
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
description={t('about.appDescription')}
|
<div className="flex items-center gap-3 mb-2">
|
||||||
>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<Card>
|
<FileText className="h-5 w-5" />
|
||||||
<CardContent className="pt-6 space-y-4">
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div>
|
||||||
<span className="font-medium">{t('about.version')}</span>
|
<h3 className="font-semibold text-foreground">{t('about.appName')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('about.appDescription')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 pt-2 border-t border-border">
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<span className="text-muted-foreground">{t('about.version')}</span>
|
||||||
<Badge variant="secondary">{version}</Badge>
|
<Badge variant="secondary">{version}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="font-medium">{t('about.buildDate')}</span>
|
<span className="text-muted-foreground">{t('about.buildDate')}</span>
|
||||||
<Badge variant="outline">{buildDate}</Badge>
|
<Badge variant="outline">{buildDate}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="font-medium">{t('about.platform')}</span>
|
<span className="text-muted-foreground">{t('about.platform')}</span>
|
||||||
<Badge variant="outline">{t('about.platformWeb')}</Badge>
|
<Badge variant="outline">{t('about.platformWeb')}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
{/* Features */}
|
||||||
title={t('about.features.title')}
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
icon={<span className="text-2xl">✨</span>}
|
<div className="flex items-center gap-3 mb-2">
|
||||||
description={t('about.features.description')}
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
>
|
<Sparkles className="h-5 w-5" />
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6 space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-green-500">✓</span>
|
|
||||||
<span>{t('about.features.titleSuggestions')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-green-500">✓</span>
|
|
||||||
<span>{t('about.features.semanticSearch')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-green-500">✓</span>
|
|
||||||
<span>{t('about.features.paragraphReformulation')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-green-500">✓</span>
|
|
||||||
<span>{t('about.features.memoryEcho')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-green-500">✓</span>
|
|
||||||
<span>{t('about.features.notebookOrganization')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-green-500">✓</span>
|
|
||||||
<span>{t('about.features.dragDrop')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-green-500">✓</span>
|
|
||||||
<span>{t('about.features.labelSystem')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-green-500">✓</span>
|
|
||||||
<span>{t('about.features.multipleProviders')}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
|
||||||
title={t('about.technology.title')}
|
|
||||||
icon={<span className="text-2xl">⚙️</span>}
|
|
||||||
description={t('about.technology.description')}
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6 space-y-2 text-sm">
|
|
||||||
<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={t('about.support.title')}
|
|
||||||
icon={<span className="text-2xl">💬</span>}
|
|
||||||
description={t('about.support.description')}
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium mb-2">{t('about.support.reportIssues')}</p>
|
<h3 className="font-semibold text-foreground">{t('about.features.title')}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground">{t('about.features.description')}</p>
|
||||||
Found a bug? Report it in the issue tracker.
|
</div>
|
||||||
</p>
|
</div>
|
||||||
|
<ul className="space-y-2 pt-2 border-t border-border">
|
||||||
|
{[
|
||||||
|
t('about.features.titleSuggestions'),
|
||||||
|
t('about.features.semanticSearch'),
|
||||||
|
t('about.features.paragraphReformulation'),
|
||||||
|
t('about.features.memoryEcho'),
|
||||||
|
t('about.features.notebookOrganization'),
|
||||||
|
t('about.features.dragDrop'),
|
||||||
|
t('about.features.labelSystem'),
|
||||||
|
t('about.features.multipleProviders'),
|
||||||
|
].map((feature) => (
|
||||||
|
<li key={feature} className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<span className="text-primary font-bold">✓</span>
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Support — full width */}
|
||||||
|
<div className="md:col-span-2 bg-card rounded-lg border border-border p-6 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
|
<MessageCircle className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium mb-2">{t('about.support.feedback')}</p>
|
<h3 className="font-semibold text-foreground">{t('about.support.title')}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground">{t('about.support.description')}</p>
|
||||||
We value your feedback! Share your thoughts and suggestions.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4 border-t border-border">
|
||||||
</SettingsSection>
|
{[
|
||||||
|
{ title: t('about.support.documentation'), desc: 'Guides détaillés et tutoriels.' },
|
||||||
|
{ title: t('about.support.reportIssues'), desc: 'Signalez un bug dans le gestionnaire.' },
|
||||||
|
{ title: t('about.support.feedback'), desc: 'Vos retours nous aident à améliorer l\'app.' },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.title} className="p-3 rounded-md bg-muted">
|
||||||
|
<p className="font-medium text-sm text-foreground">{item.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ export function AISettingsHeader() {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<div>
|
||||||
<h1 className="text-3xl font-bold">{t('aiSettings.title')}</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('aiSettings.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-muted-foreground mt-1">{t('aiSettings.description')}</p>
|
||||||
{t('aiSettings.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { SettingsSection, SettingSelect } from '@/components/settings'
|
|
||||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||||
import { updateUserSettings } from '@/app/actions/user-settings'
|
import { updateUserSettings } from '@/app/actions/user-settings'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Palette, Type, LayoutGrid, Maximize2 } from 'lucide-react'
|
||||||
|
|
||||||
interface AppearanceSettingsClientProps {
|
interface AppearanceSettingsClientProps {
|
||||||
initialFontSize: string
|
initialFontSize: string
|
||||||
@@ -15,7 +15,13 @@ interface AppearanceSettingsClientProps {
|
|||||||
initialFontFamily?: string
|
initialFontFamily?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode, initialCardSizeMode = 'variable', initialFontFamily = 'inter' }: AppearanceSettingsClientProps) {
|
export function AppearanceSettingsClient({
|
||||||
|
initialFontSize,
|
||||||
|
initialTheme,
|
||||||
|
initialNotesViewMode,
|
||||||
|
initialCardSizeMode = 'variable',
|
||||||
|
initialFontFamily = 'inter',
|
||||||
|
}: AppearanceSettingsClientProps) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [theme, setTheme] = useState(initialTheme || 'light')
|
const [theme, setTheme] = useState(initialTheme || 'light')
|
||||||
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
||||||
@@ -26,11 +32,9 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
const handleThemeChange = async (value: string) => {
|
const handleThemeChange = async (value: string) => {
|
||||||
setTheme(value)
|
setTheme(value)
|
||||||
localStorage.setItem('theme-preference', value)
|
localStorage.setItem('theme-preference', value)
|
||||||
|
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
root.removeAttribute('data-theme')
|
root.removeAttribute('data-theme')
|
||||||
root.classList.remove('dark')
|
root.classList.remove('dark')
|
||||||
|
|
||||||
if (value === 'auto') {
|
if (value === 'auto') {
|
||||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
|
||||||
} else if (value === 'dark') {
|
} else if (value === 'dark') {
|
||||||
@@ -39,19 +43,14 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
root.setAttribute('data-theme', value)
|
root.setAttribute('data-theme', value)
|
||||||
if (['midnight'].includes(value)) root.classList.add('dark')
|
if (['midnight'].includes(value)) root.classList.add('dark')
|
||||||
}
|
}
|
||||||
|
await updateUserSettings({ theme: value as any })
|
||||||
await updateUserSettings({ theme: value as 'light' | 'dark' | 'auto' })
|
|
||||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFontSizeChange = async (value: string) => {
|
const handleFontSizeChange = async (value: string) => {
|
||||||
setFontSize(value)
|
setFontSize(value)
|
||||||
|
const map: Record<string, string> = { small: '14px', medium: '16px', large: '18px', 'extra-large': '20px' }
|
||||||
const fontSizeMap: Record<string, string> = {
|
document.documentElement.style.setProperty('--user-font-size', map[value] || '16px')
|
||||||
'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px'
|
|
||||||
}
|
|
||||||
document.documentElement.style.setProperty('--user-font-size', fontSizeMap[value] || '16px')
|
|
||||||
|
|
||||||
await updateAISettings({ fontSize: value as any })
|
await updateAISettings({ fontSize: value as any })
|
||||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
}
|
}
|
||||||
@@ -76,31 +75,64 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
setFontFamily(font)
|
setFontFamily(font)
|
||||||
localStorage.setItem('font-family', font)
|
localStorage.setItem('font-family', font)
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
if (font === 'system') {
|
font === 'system' ? root.classList.add('font-system') : root.classList.remove('font-system')
|
||||||
root.classList.add('font-system')
|
|
||||||
} else {
|
|
||||||
root.classList.remove('font-system')
|
|
||||||
}
|
|
||||||
await updateAISettings({ fontFamily: font })
|
await updateAISettings({ fontFamily: font })
|
||||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SelectCard = ({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
icon: React.ElementType
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
value: string
|
||||||
|
options: { value: string; label: string }[]
|
||||||
|
onChange: (v: string) => void
|
||||||
|
}) => (
|
||||||
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-foreground">{title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full h-11 px-4 bg-muted border border-border rounded-lg text-foreground text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none appearance-none cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground">
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('appearance.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-muted-foreground mt-1">{t('appearance.description')}</p>
|
||||||
{t('appearance.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
title={t('settings.theme')}
|
<SelectCard
|
||||||
icon={<span className="text-2xl">🎨</span>}
|
icon={Palette}
|
||||||
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
|
title={t('settings.theme')}
|
||||||
>
|
|
||||||
<SettingSelect
|
|
||||||
label={t('settings.theme')}
|
|
||||||
description={t('appearance.selectTheme')}
|
description={t('appearance.selectTheme')}
|
||||||
value={theme}
|
value={theme}
|
||||||
options={[
|
options={[
|
||||||
@@ -118,50 +150,36 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
]}
|
]}
|
||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
<SelectCard
|
||||||
title={t('profile.fontSize')}
|
icon={Type}
|
||||||
icon={<span className="text-2xl">📝</span>}
|
title={t('profile.fontSize')}
|
||||||
description={t('profile.fontSizeDescription')}
|
description={t('profile.fontSizeDescription')}
|
||||||
>
|
|
||||||
<SettingSelect
|
|
||||||
label={t('profile.fontSize')}
|
|
||||||
description={t('profile.selectFontSize')}
|
|
||||||
value={fontSize}
|
value={fontSize}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'small', label: t('profile.fontSizeSmall') },
|
{ value: 'small', label: t('profile.fontSizeSmall') },
|
||||||
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
||||||
{ value: 'large', label: t('profile.fontSizeLarge') },
|
{ value: 'large', label: t('profile.fontSizeLarge') },
|
||||||
|
{ value: 'extra-large', label: t('profile.fontSizeExtraLarge') },
|
||||||
]}
|
]}
|
||||||
onChange={handleFontSizeChange}
|
onChange={handleFontSizeChange}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
<SelectCard
|
||||||
title={t('appearance.fontFamilyLabel') || 'Font Family'}
|
icon={Type}
|
||||||
icon={<span className="text-2xl">🔤</span>}
|
title={t('appearance.fontFamilyLabel') || 'Police'}
|
||||||
description={t('appearance.fontFamilyDescription') || 'Choose the font used throughout the app'}
|
description={t('appearance.fontFamilyDescription') || 'Choisissez la police de l\'application'}
|
||||||
>
|
|
||||||
<SettingSelect
|
|
||||||
label={t('appearance.fontFamilyLabel') || 'Font Family'}
|
|
||||||
description={t('appearance.selectFontFamily') || 'Inter is optimized for readability, System uses your OS native font'}
|
|
||||||
value={fontFamily}
|
value={fontFamily}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'inter', label: 'Inter' },
|
{ value: 'inter', label: 'Inter' },
|
||||||
{ value: 'system', label: t('appearance.fontSystem') || 'System Default' },
|
{ value: 'system', label: t('appearance.fontSystem') || 'Système' },
|
||||||
]}
|
]}
|
||||||
onChange={handleFontFamilyChange}
|
onChange={handleFontFamilyChange}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
<SelectCard
|
||||||
title={t('appearance.notesViewLabel')}
|
icon={LayoutGrid}
|
||||||
icon={<span className="text-2xl">📋</span>}
|
title={t('appearance.notesViewLabel')}
|
||||||
description={t('appearance.notesViewDescription')}
|
|
||||||
>
|
|
||||||
<SettingSelect
|
|
||||||
label={t('appearance.notesViewLabel')}
|
|
||||||
description={t('appearance.notesViewDescription')}
|
description={t('appearance.notesViewDescription')}
|
||||||
value={notesViewMode}
|
value={notesViewMode}
|
||||||
options={[
|
options={[
|
||||||
@@ -170,16 +188,11 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
]}
|
]}
|
||||||
onChange={handleNotesViewChange}
|
onChange={handleNotesViewChange}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
<SelectCard
|
||||||
title={t('settings.cardSizeMode')}
|
icon={Maximize2}
|
||||||
icon={<span className="text-2xl">📐</span>}
|
title={t('settings.cardSizeMode')}
|
||||||
description={t('settings.cardSizeModeDescription')}
|
description={t('settings.cardSizeModeDescription')}
|
||||||
>
|
|
||||||
<SettingSelect
|
|
||||||
label={t('settings.cardSizeMode')}
|
|
||||||
description={t('settings.selectCardSizeMode')}
|
|
||||||
value={cardSizeMode}
|
value={cardSizeMode}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'variable', label: t('settings.cardSizeVariable') },
|
{ value: 'variable', label: t('settings.cardSizeVariable') },
|
||||||
@@ -187,7 +200,7 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
]}
|
]}
|
||||||
onChange={handleCardSizeModeChange}
|
onChange={handleCardSizeModeChange}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { SettingsSection } from '@/components/settings'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Download, Upload, Trash2, Loader2 } from 'lucide-react'
|
import { Download, Upload, Trash2, Loader2, RefreshCw, Sparkles, Database } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
@@ -14,6 +13,8 @@ export default function DataSettingsPage() {
|
|||||||
const [isExporting, setIsExporting] = useState(false)
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
const [isImporting, setIsImporting] = useState(false)
|
const [isImporting, setIsImporting] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [isReindexing, setIsReindexing] = useState(false)
|
||||||
|
const [isCleaningUp, setIsCleaningUp] = useState(false)
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
setIsExporting(true)
|
setIsExporting(true)
|
||||||
@@ -24,15 +25,16 @@ export default function DataSettingsPage() {
|
|||||||
const url = window.URL.createObjectURL(blob)
|
const url = window.URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `memento-note-export-${new Date().toISOString().split('T')[0]}.json`
|
a.download = `memento-export-${new Date().toISOString().split('T')[0]}.json`
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a)
|
||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url)
|
||||||
toast.success(t('dataManagement.export.success'))
|
toast.success(t('dataManagement.export.success'))
|
||||||
|
} else {
|
||||||
|
throw new Error()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Export error:', error)
|
|
||||||
toast.error(t('dataManagement.export.failed'))
|
toast.error(t('dataManagement.export.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsExporting(false)
|
setIsExporting(false)
|
||||||
@@ -42,47 +44,73 @@ export default function DataSettingsPage() {
|
|||||||
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
setIsImporting(true)
|
setIsImporting(true)
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
const response = await fetch('/api/notes/import', { method: 'POST', body: formData })
|
||||||
const response = await fetch('/api/notes/import', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
toast.success(t('dataManagement.import.success', { count: result.count }))
|
toast.success(t('dataManagement.import.success', { count: result.count }))
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Import failed')
|
const error = await response.json()
|
||||||
|
throw new Error(error.error || 'Import failed')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (err: any) {
|
||||||
console.error('Import error:', error)
|
toast.error(err.message || t('dataManagement.import.failed'))
|
||||||
toast.error(t('dataManagement.import.failed'))
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsImporting(false)
|
setIsImporting(false)
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteAll = async () => {
|
const handleReindex = async () => {
|
||||||
if (!confirm(t('dataManagement.delete.confirm'))) {
|
setIsReindexing(true)
|
||||||
return
|
try {
|
||||||
|
const response = await fetch('/api/notes/reindex', { method: 'POST' })
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
toast.success(t('dataManagement.indexing.success', { count: result.count }))
|
||||||
|
} else {
|
||||||
|
throw new Error()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t('dataManagement.indexing.failed'))
|
||||||
|
} finally {
|
||||||
|
setIsReindexing(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCleanup = async () => {
|
||||||
|
setIsCleaningUp(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/notes/cleanup', { method: 'POST' })
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
toast.success(t('dataManagement.cleanup.success', { count: result.deletedLabels }))
|
||||||
|
} else {
|
||||||
|
throw new Error()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t('dataManagement.cleanup.failed'))
|
||||||
|
} finally {
|
||||||
|
setIsCleaningUp(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteAll = async () => {
|
||||||
|
if (!confirm(t('dataManagement.delete.confirm'))) return
|
||||||
setIsDeleting(true)
|
setIsDeleting(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/notes/delete-all', { method: 'POST' })
|
const response = await fetch('/api/notes/delete-all', { method: 'POST' })
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success(t('dataManagement.delete.success'))
|
toast.success(t('dataManagement.delete.success'))
|
||||||
router.refresh()
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
throw new Error()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Delete error:', error)
|
|
||||||
toast.error(t('dataManagement.delete.failed'))
|
toast.error(t('dataManagement.delete.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
@@ -90,102 +118,114 @@ export default function DataSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="max-w-4xl mx-auto space-y-8 p-6">
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold mb-2">{t('dataManagement.title')}</h1>
|
<h1 className="text-3xl font-bold tracking-tight text-foreground">{t('dataManagement.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-muted-foreground">{t('dataManagement.toolsDescription')}</p>
|
||||||
{t('dataManagement.toolsDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
title={`💾 ${t('dataManagement.export.title')}`}
|
{/* Export card */}
|
||||||
icon={<span className="text-2xl">💾</span>}
|
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
||||||
description={t('dataManagement.export.description')}
|
<div className="space-y-4">
|
||||||
>
|
<div className="w-12 h-12 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-600 shrink-0">
|
||||||
<div className="flex items-center justify-between py-4">
|
<Download className="h-6 w-6" />
|
||||||
<div>
|
</div>
|
||||||
<p className="font-medium">{t('dataManagement.export.title')}</p>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.export.title')}</h3>
|
||||||
{t('dataManagement.export.description')}
|
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||||
</p>
|
{t('dataManagement.export.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button onClick={handleExport} disabled={isExporting} className="mt-6 w-full">
|
||||||
onClick={handleExport}
|
{isExporting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Download className="h-4 w-4 mr-2" />}
|
||||||
disabled={isExporting}
|
|
||||||
>
|
|
||||||
{isExporting ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{isExporting ? t('dataManagement.exporting') : t('dataManagement.export.button')}
|
{isExporting ? t('dataManagement.exporting') : t('dataManagement.export.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
{/* Import card */}
|
||||||
title={`📥 ${t('dataManagement.import.title')}`}
|
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
||||||
icon={<span className="text-2xl">📥</span>}
|
<div className="space-y-4">
|
||||||
description={t('dataManagement.import.description')}
|
<div className="w-12 h-12 rounded-full bg-emerald-500/10 flex items-center justify-center text-emerald-600 shrink-0">
|
||||||
>
|
<Upload className="h-6 w-6" />
|
||||||
<div className="flex items-center justify-between py-4">
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{t('dataManagement.import.title')}</p>
|
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.import.title')}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||||
{t('dataManagement.import.description')}
|
{t('dataManagement.import.description')}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" accept=".json" onChange={handleImport} disabled={isImporting} className="hidden" id="import-file" />
|
||||||
|
<Button onClick={() => document.getElementById('import-file')?.click()} disabled={isImporting} variant="outline" className="mt-6 w-full">
|
||||||
|
{isImporting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Upload className="h-4 w-4 mr-2" />}
|
||||||
|
{isImporting ? t('dataManagement.importing') : t('dataManagement.import.button')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reindex card */}
|
||||||
|
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-600 shrink-0">
|
||||||
|
<Sparkles className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.indexing.title')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||||
|
{t('dataManagement.indexing.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleReindex} disabled={isReindexing} variant="secondary" className="mt-6 w-full">
|
||||||
|
{isReindexing ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
|
||||||
|
{isReindexing ? t('dataManagement.exporting') : t('dataManagement.indexing.button')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cleanup card */}
|
||||||
|
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-purple-500/10 flex items-center justify-center text-purple-600 shrink-0">
|
||||||
|
<Database className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.cleanup.title')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||||
|
{t('dataManagement.cleanup.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCleanup} disabled={isCleaningUp} variant="secondary" className="mt-6 w-full">
|
||||||
|
{isCleaningUp ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Database className="h-4 w-4 mr-2" />}
|
||||||
|
{isCleaningUp ? t('dataManagement.exporting') : t('dataManagement.cleanup.button')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger zone */}
|
||||||
|
<div className="bg-destructive/5 rounded-xl border border-destructive/20 p-6 shadow-sm mt-12">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center text-destructive shrink-0">
|
||||||
|
<Trash2 className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<h3 className="text-xl font-bold text-destructive">{t('dataManagement.dangerZone')}</h3>
|
||||||
type="file"
|
<p className="text-sm text-muted-foreground">{t('dataManagement.dangerZoneDescription')}</p>
|
||||||
accept=".json"
|
|
||||||
onChange={handleImport}
|
|
||||||
disabled={isImporting}
|
|
||||||
className="hidden"
|
|
||||||
id="import-file"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={() => document.getElementById('import-file')?.click()}
|
|
||||||
disabled={isImporting}
|
|
||||||
>
|
|
||||||
{isImporting ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<Upload className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{isImporting ? t('dataManagement.importing') : t('dataManagement.import.button')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 bg-background/50 rounded-lg border border-destructive/10 gap-4">
|
||||||
<SettingsSection
|
<div className="space-y-1">
|
||||||
title={`⚠️ ${t('dataManagement.dangerZone')}`}
|
<p className="font-semibold text-destructive">{t('dataManagement.delete.title')}</p>
|
||||||
icon={<span className="text-2xl">⚠️</span>}
|
<p className="text-sm text-muted-foreground">{t('dataManagement.delete.description')}</p>
|
||||||
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">{t('dataManagement.delete.title')}</p>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{t('dataManagement.delete.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="destructive" onClick={handleDeleteAll} disabled={isDeleting} className="shrink-0 w-full sm:w-auto">
|
||||||
variant="destructive"
|
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
|
||||||
onClick={handleDeleteAll}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{isDeleting ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{isDeleting ? t('dataManagement.deleting') : t('dataManagement.delete.button')}
|
{isDeleting ? t('dataManagement.deleting') : t('dataManagement.delete.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { SettingsSection, SettingToggle, SettingSelect } from '@/components/settings'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Globe, Bell } from 'lucide-react'
|
||||||
|
|
||||||
interface GeneralSettingsClientProps {
|
interface GeneralSettingsClientProps {
|
||||||
initialSettings: {
|
initialSettings: {
|
||||||
@@ -25,7 +25,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
const handleLanguageChange = async (value: string) => {
|
const handleLanguageChange = async (value: string) => {
|
||||||
setLanguage(value)
|
setLanguage(value)
|
||||||
await updateAISettings({ preferredLanguage: value as any })
|
await updateAISettings({ preferredLanguage: value as any })
|
||||||
|
|
||||||
if (value === 'auto') {
|
if (value === 'auto') {
|
||||||
localStorage.removeItem('user-language')
|
localStorage.removeItem('user-language')
|
||||||
toast.success(t('settings.languageAuto') || 'Language set to Auto')
|
toast.success(t('settings.languageAuto') || 'Language set to Auto')
|
||||||
@@ -34,7 +33,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
setContextLanguage(value as any)
|
setContextLanguage(value as any)
|
||||||
toast.success(t('profile.languageUpdateSuccess') || 'Language updated')
|
toast.success(t('profile.languageUpdateSuccess') || 'Language updated')
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => router.refresh(), 300)
|
setTimeout(() => router.refresh(), 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,63 +49,102 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
|
{/* Page title */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">{t('generalSettings.title')}</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('generalSettings.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-muted-foreground mt-1">{t('generalSettings.description')}</p>
|
||||||
{t('generalSettings.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
{/* 2-column card grid */}
|
||||||
title={t('settings.language')}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
icon={<span className="text-2xl">🌍</span>}
|
{/* Language card */}
|
||||||
description={t('profile.languagePreferencesDescription')}
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
>
|
<div className="flex items-center gap-3">
|
||||||
<SettingSelect
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
label={t('settings.language')}
|
<Globe className="h-5 w-5" />
|
||||||
description={t('settings.selectLanguage')}
|
</div>
|
||||||
value={language}
|
<div>
|
||||||
options={[
|
<h3 className="font-semibold text-foreground">{t('settings.language')}</h3>
|
||||||
{ value: 'auto', label: t('profile.autoDetect') },
|
<p className="text-sm text-muted-foreground">{t('settings.selectLanguage')}</p>
|
||||||
{ value: 'en', label: 'English' },
|
</div>
|
||||||
{ value: 'fr', label: 'Français' },
|
</div>
|
||||||
{ value: 'es', label: 'Español' },
|
<div className="relative mt-2">
|
||||||
{ value: 'de', label: 'Deutsch' },
|
<select
|
||||||
{ value: 'fa', label: 'فارسی' },
|
value={language}
|
||||||
{ value: 'it', label: 'Italiano' },
|
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||||
{ value: 'pt', label: 'Português' },
|
className="w-full h-11 px-4 bg-muted border border-border rounded-lg text-foreground text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none appearance-none cursor-pointer transition-colors"
|
||||||
{ value: 'ru', label: 'Русский' },
|
>
|
||||||
{ value: 'zh', label: '中文' },
|
<option value="auto">{t('profile.autoDetect')}</option>
|
||||||
{ value: 'ja', label: '日本語' },
|
<option value="en">English</option>
|
||||||
{ value: 'ko', label: '한국어' },
|
<option value="fr">Français</option>
|
||||||
{ value: 'ar', label: 'العربية' },
|
<option value="es">Español</option>
|
||||||
{ value: 'hi', label: 'हिन्दी' },
|
<option value="de">Deutsch</option>
|
||||||
{ value: 'nl', label: 'Nederlands' },
|
<option value="fa">فارسی</option>
|
||||||
{ value: 'pl', label: 'Polski' },
|
<option value="it">Italiano</option>
|
||||||
]}
|
<option value="pt">Português</option>
|
||||||
onChange={handleLanguageChange}
|
<option value="ru">Русский</option>
|
||||||
/>
|
<option value="zh">中文</option>
|
||||||
</SettingsSection>
|
<option value="ja">日本語</option>
|
||||||
|
<option value="ko">한국어</option>
|
||||||
|
<option value="ar">العربية</option>
|
||||||
|
<option value="hi">हिन्दी</option>
|
||||||
|
<option value="nl">Nederlands</option>
|
||||||
|
<option value="pl">Polski</option>
|
||||||
|
</select>
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground">
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
{/* Notifications card */}
|
||||||
title={t('settings.notifications')}
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
icon={<span className="text-2xl">🔔</span>}
|
<div className="flex items-center gap-3">
|
||||||
description={t('settings.notificationsDesc')}
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
>
|
<Bell className="h-5 w-5" />
|
||||||
<SettingToggle
|
</div>
|
||||||
label={t('settings.emailNotifications')}
|
<div>
|
||||||
description={t('settings.emailNotificationsDesc')}
|
<h3 className="font-semibold text-foreground">{t('settings.notifications')}</h3>
|
||||||
checked={emailNotifications}
|
<p className="text-sm text-muted-foreground">{t('settings.notificationsDesc')}</p>
|
||||||
onChange={handleEmailNotificationsChange}
|
</div>
|
||||||
/>
|
</div>
|
||||||
<SettingToggle
|
<div className="mt-2 space-y-5">
|
||||||
label={t('settings.desktopNotifications')}
|
{/* Email toggle */}
|
||||||
description={t('settings.desktopNotificationsDesc')}
|
<div className="flex items-center justify-between">
|
||||||
checked={desktopNotifications}
|
<div>
|
||||||
onChange={handleDesktopNotificationsChange}
|
<p className="text-sm font-medium text-foreground">{t('settings.emailNotifications')}</p>
|
||||||
/>
|
<p className="text-xs text-muted-foreground">{t('settings.emailNotificationsDesc')}</p>
|
||||||
</SettingsSection>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={emailNotifications}
|
||||||
|
onClick={() => handleEmailNotificationsChange(!emailNotifications)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${emailNotifications ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${emailNotifications ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Desktop toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">{t('settings.desktopNotifications')}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('settings.desktopNotificationsDesc')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={desktopNotifications}
|
||||||
|
onClick={() => handleDesktopNotificationsChange(!desktopNotifications)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${desktopNotifications ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${desktopNotifications ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ export default function SettingsLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-10 px-4 max-w-6xl">
|
<div className="flex flex-col h-full">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
{/* Horizontal Tab Navigation */}
|
||||||
{/* Sidebar Navigation */}
|
<header className="flex items-center gap-1 px-8 bg-background border-b border-border shrink-0">
|
||||||
<aside className="lg:col-span-1">
|
<SettingsNav />
|
||||||
<SettingsNav />
|
</header>
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Page Content */}
|
||||||
<main className="lg:col-span-3 space-y-6">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-5xl mx-auto px-8 py-8 space-y-8">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys'
|
|||||||
|
|
||||||
export default async function McpSettingsPage() {
|
export default async function McpSettingsPage() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
if (!session?.user) redirect('/api/auth/signin')
|
||||||
if (!session?.user) {
|
|
||||||
redirect('/api/auth/signin')
|
|
||||||
}
|
|
||||||
|
|
||||||
const [keys, serverStatus] = await Promise.all([
|
const [keys, serverStatus] = await Promise.all([
|
||||||
listMcpKeys(),
|
listMcpKeys(),
|
||||||
@@ -16,7 +13,11 @@ export default async function McpSettingsPage() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">Paramètres MCP</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Gérez vos clés API et serveurs MCP connectés.</p>
|
||||||
|
</div>
|
||||||
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
|
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,179 +1,92 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { updateProfile, changePassword } from '@/app/actions/profile'
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
|
|
||||||
import { updateProfile, changePassword, updateFontSize, updateShowRecentNotes } from '@/app/actions/profile'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { User, Lock } from 'lucide-react'
|
||||||
|
|
||||||
|
|
||||||
export function ProfileForm({ user, userAISettings }: { user: any; userAISettings?: any }) {
|
export function ProfileForm({ user, userAISettings }: { user: any; userAISettings?: any }) {
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const [fontSize, setFontSize] = useState(userAISettings?.fontSize || 'medium')
|
|
||||||
const [isUpdatingFontSize, setIsUpdatingFontSize] = useState(false)
|
|
||||||
const [showRecentNotes, setShowRecentNotes] = useState(userAISettings?.showRecentNotes ?? false)
|
|
||||||
const [isUpdatingRecentNotes, setIsUpdatingRecentNotes] = useState(false)
|
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const FONT_SIZES = [
|
|
||||||
{ value: 'small', label: t('profile.fontSizeSmall'), size: '14px' },
|
|
||||||
{ value: 'medium', label: t('profile.fontSizeMedium'), size: '16px' },
|
|
||||||
{ value: 'large', label: t('profile.fontSizeLarge'), size: '18px' },
|
|
||||||
{ value: 'extra-large', label: t('profile.fontSizeExtraLarge'), size: '20px' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const handleFontSizeChange = async (size: string) => {
|
|
||||||
setIsUpdatingFontSize(true)
|
|
||||||
try {
|
|
||||||
const result = await updateFontSize(size)
|
|
||||||
if (result?.error) {
|
|
||||||
toast.error(t('profile.fontSizeUpdateFailed'))
|
|
||||||
} else {
|
|
||||||
setFontSize(size)
|
|
||||||
// Apply font size immediately
|
|
||||||
applyFontSize(size)
|
|
||||||
toast.success(t('profile.fontSizeUpdateSuccess'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(t('profile.fontSizeUpdateFailed'))
|
|
||||||
} finally {
|
|
||||||
setIsUpdatingFontSize(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyFontSize = (size: string) => {
|
|
||||||
// Base font size in pixels (16px is standard)
|
|
||||||
const fontSizeMap = {
|
|
||||||
'small': '14px', // ~87% of 16px
|
|
||||||
'medium': '16px', // 100% (standard)
|
|
||||||
'large': '18px', // ~112% of 16px
|
|
||||||
'extra-large': '20px' // 125% of 16px
|
|
||||||
}
|
|
||||||
const fontSizeFactorMap = {
|
|
||||||
'small': 0.95,
|
|
||||||
'medium': 1.0,
|
|
||||||
'large': 1.1,
|
|
||||||
'extra-large': 1.25
|
|
||||||
}
|
|
||||||
const fontSizeValue = fontSizeMap[size as keyof typeof fontSizeMap] || '16px'
|
|
||||||
const fontSizeFactor = fontSizeFactorMap[size as keyof typeof fontSizeFactorMap] || 1.0
|
|
||||||
|
|
||||||
document.documentElement.style.setProperty('--user-font-size', fontSizeValue)
|
|
||||||
document.documentElement.style.setProperty('--user-font-size-factor', fontSizeFactor.toString())
|
|
||||||
localStorage.setItem('user-font-size', size)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply saved font size on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const savedFontSize = localStorage.getItem('user-font-size') || userAISettings?.fontSize || 'medium'
|
|
||||||
applyFontSize(savedFontSize as string)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleShowRecentNotesChange = async (enabled: boolean) => {
|
|
||||||
setIsUpdatingRecentNotes(true)
|
|
||||||
const previousValue = showRecentNotes
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await updateShowRecentNotes(enabled)
|
|
||||||
if (result?.error) {
|
|
||||||
toast.error(result.error)
|
|
||||||
} else {
|
|
||||||
setShowRecentNotes(enabled)
|
|
||||||
toast.success(t('profile.recentNotesUpdateSuccess') || 'Paramètre mis à jour')
|
|
||||||
// Force full page reload to ensure settings are reloaded
|
|
||||||
window.location.href = '/settings/profile'
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
setShowRecentNotes(previousValue)
|
|
||||||
toast.error(error?.message || 'Erreur')
|
|
||||||
} finally {
|
|
||||||
setIsUpdatingRecentNotes(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<Card>
|
<div>
|
||||||
<CardHeader>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('profile.title')}</h1>
|
||||||
<CardTitle>{t('profile.title')}</CardTitle>
|
<p className="text-muted-foreground mt-1">{t('profile.description')}</p>
|
||||||
<CardDescription>{t('profile.description')}</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<form action={async (formData) => {
|
|
||||||
const result = await updateProfile({ name: formData.get('name') as string })
|
|
||||||
if (result?.error) {
|
|
||||||
toast.error(t('profile.updateFailed'))
|
|
||||||
} else {
|
|
||||||
toast.success(t('profile.updateSuccess'))
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="name" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.displayName')}</label>
|
|
||||||
<Input id="name" name="name" defaultValue={user.name} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="email" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.email')}</label>
|
|
||||||
<Input id="email" value={user.email} disabled className="bg-muted" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Button type="submit">{t('general.save')}</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Profile info card */}
|
||||||
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Card>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<CardHeader>
|
<User className="h-5 w-5" />
|
||||||
<CardTitle>{t('profile.changePassword')}</CardTitle>
|
|
||||||
<CardDescription>{t('profile.changePasswordDescription')}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<form action={async (formData) => {
|
|
||||||
const result = await changePassword(formData)
|
|
||||||
if (result?.error) {
|
|
||||||
const msg = '_form' in result.error
|
|
||||||
? result.error._form[0]
|
|
||||||
: result.error.currentPassword?.[0] || result.error.newPassword?.[0] || result.error.confirmPassword?.[0] || t('profile.passwordChangeFailed')
|
|
||||||
toast.error(msg)
|
|
||||||
} else {
|
|
||||||
toast.success(t('profile.passwordChangeSuccess'))
|
|
||||||
// Reset form manually or redirect
|
|
||||||
const form = document.querySelector('form#password-form') as HTMLFormElement
|
|
||||||
form?.reset()
|
|
||||||
}
|
|
||||||
}} id="password-form">
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="currentPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.currentPassword')}</label>
|
|
||||||
<Input id="currentPassword" name="currentPassword" type="password" required />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<label htmlFor="newPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.newPassword')}</label>
|
<h3 className="font-semibold text-foreground">{t('profile.title')}</h3>
|
||||||
<Input id="newPassword" name="newPassword" type="password" required minLength={6} />
|
<p className="text-sm text-muted-foreground">{t('profile.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<label htmlFor="confirmPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.confirmPassword')}</label>
|
<form action={async (formData) => {
|
||||||
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} />
|
const result = await updateProfile({ name: formData.get('name') as string })
|
||||||
|
if (result?.error) toast.error(t('profile.updateFailed'))
|
||||||
|
else toast.success(t('profile.updateSuccess'))
|
||||||
|
}} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="name" className="text-sm font-medium text-foreground">{t('profile.displayName')}</label>
|
||||||
|
<Input id="name" name="name" defaultValue={user.name} className="bg-muted border-border focus:border-primary" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div className="space-y-1.5">
|
||||||
<CardFooter>
|
<label htmlFor="email" className="text-sm font-medium text-foreground">{t('profile.email')}</label>
|
||||||
<Button type="submit">{t('profile.updatePassword')}</Button>
|
<Input id="email" value={user.email} disabled className="bg-muted border-border opacity-60" />
|
||||||
</CardFooter>
|
</div>
|
||||||
</form>
|
<Button type="submit" className="w-full mt-2">{t('general.save')}</Button>
|
||||||
</Card>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password card */}
|
||||||
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
|
<Lock className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-foreground">{t('profile.changePassword')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('profile.changePasswordDescription')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form action={async (formData) => {
|
||||||
|
const result = await changePassword(formData)
|
||||||
|
if (result?.error) {
|
||||||
|
const msg = '_form' in result.error
|
||||||
|
? result.error._form[0]
|
||||||
|
: result.error.currentPassword?.[0] || result.error.newPassword?.[0] || result.error.confirmPassword?.[0] || t('profile.passwordChangeFailed')
|
||||||
|
toast.error(msg)
|
||||||
|
} else {
|
||||||
|
toast.success(t('profile.passwordChangeSuccess'))
|
||||||
|
const form = document.querySelector('form#password-form') as HTMLFormElement
|
||||||
|
form?.reset()
|
||||||
|
}
|
||||||
|
}} id="password-form" className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="currentPassword" className="text-sm font-medium text-foreground">{t('profile.currentPassword')}</label>
|
||||||
|
<Input id="currentPassword" name="currentPassword" type="password" required className="bg-muted border-border focus:border-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="newPassword" className="text-sm font-medium text-foreground">{t('profile.newPassword')}</label>
|
||||||
|
<Input id="newPassword" name="newPassword" type="password" required minLength={6} className="bg-muted border-border focus:border-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="confirmPassword" className="text-sm font-medium text-foreground">{t('profile.confirmPassword')}</label>
|
||||||
|
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} className="bg-muted border-border focus:border-primary" />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full mt-2">{t('profile.updatePassword')}</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
52
memento-note/app/api/notes/cleanup/route.ts
Normal file
52
memento-note/app/api/notes/cleanup/route.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id
|
||||||
|
|
||||||
|
// 1. Find and delete labels that have no notes and belong to this user
|
||||||
|
// We only delete labels that are not part of a notebook (global labels)
|
||||||
|
const orphanedLabels = await prisma.label.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
notebookId: null,
|
||||||
|
notes: { none: {} }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.label.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: { in: orphanedLabels.map(l => l.id) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Clean up NoteEmbeddings that don't have a corresponding Note (shouldn't happen with Cascade, but good for cleanup)
|
||||||
|
const orphanedEmbeddings = await prisma.noteEmbedding.findMany({
|
||||||
|
where: {
|
||||||
|
note: { userId: { not: userId } } // Or just those where note is null if not using cascade
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Actually, let's just focus on user-specific cleanup
|
||||||
|
|
||||||
|
// 3. Remove note history entries for notes that were deleted (cascade should handle this, but let's be safe)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
deletedLabels: orphanedLabels.length
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cleanup error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to cleanup data' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,9 +91,15 @@ export async function GET(req: NextRequest) {
|
|||||||
id: note.id,
|
id: note.id,
|
||||||
title: note.title,
|
title: note.title,
|
||||||
content: note.content,
|
content: note.content,
|
||||||
|
color: note.color,
|
||||||
|
isPinned: note.isPinned,
|
||||||
|
isArchived: note.isArchived,
|
||||||
|
type: note.type,
|
||||||
|
checkItems: note.checkItems,
|
||||||
|
images: note.images,
|
||||||
|
links: note.links,
|
||||||
createdAt: note.createdAt,
|
createdAt: note.createdAt,
|
||||||
updatedAt: note.updatedAt,
|
updatedAt: note.updatedAt,
|
||||||
isPinned: note.isPinned,
|
|
||||||
notebookId: note.notebookId,
|
notebookId: note.notebookId,
|
||||||
labelRelations: note.labelRelations.map(label => ({
|
labelRelations: note.labelRelations.map(label => ({
|
||||||
id: label.id,
|
id: label.id,
|
||||||
|
|||||||
@@ -111,11 +111,12 @@ export async function POST(req: NextRequest) {
|
|||||||
const mappedNotebookId = notebookIdMap.get(note.notebookId) || null
|
const mappedNotebookId = notebookIdMap.get(note.notebookId) || null
|
||||||
|
|
||||||
// Get label IDs
|
// Get label IDs
|
||||||
|
const labelNames = (note.labels || note.labelRelations || []).map((l: any) => l.name).filter(Boolean)
|
||||||
const labels = await prisma.label.findMany({
|
const labels = await prisma.label.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
name: {
|
name: {
|
||||||
in: note.labels.map((l: any) => l.name)
|
in: labelNames
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -125,8 +126,14 @@ export async function POST(req: NextRequest) {
|
|||||||
data: {
|
data: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
title: note.title || 'Untitled',
|
title: note.title || 'Untitled',
|
||||||
content: note.content,
|
content: note.content || '',
|
||||||
|
color: note.color || 'default',
|
||||||
isPinned: note.isPinned || false,
|
isPinned: note.isPinned || false,
|
||||||
|
isArchived: note.isArchived || false,
|
||||||
|
type: note.type || 'richtext',
|
||||||
|
checkItems: note.checkItems || null,
|
||||||
|
images: note.images || null,
|
||||||
|
links: note.links || null,
|
||||||
notebookId: mappedNotebookId,
|
notebookId: mappedNotebookId,
|
||||||
labelRelations: {
|
labelRelations: {
|
||||||
connect: labels.map(label => ({ id: label.id }))
|
connect: labels.map(label => ({ id: label.id }))
|
||||||
|
|||||||
59
memento-note/app/api/notes/reindex/route.ts
Normal file
59
memento-note/app/api/notes/reindex/route.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { EmbeddingService } from '@/lib/ai/services/embedding.service'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id
|
||||||
|
|
||||||
|
// Fetch all notes for the user
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
where: { userId, trashedAt: null },
|
||||||
|
select: { id: true, title: true, content: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const embeddingService = new EmbeddingService()
|
||||||
|
let processedCount = 0
|
||||||
|
|
||||||
|
// Process in small batches to avoid timeouts if possible
|
||||||
|
// Note: In a real production app, this should be a background job
|
||||||
|
for (const note of notes) {
|
||||||
|
try {
|
||||||
|
const textToEmbed = `${note.title || ''}\n${note.content}`
|
||||||
|
if (textToEmbed.trim()) {
|
||||||
|
const embedding = await embeddingService.generateEmbedding(textToEmbed)
|
||||||
|
|
||||||
|
await prisma.noteEmbedding.upsert({
|
||||||
|
where: { noteId: note.id },
|
||||||
|
update: { embedding: JSON.stringify(embedding) },
|
||||||
|
create: {
|
||||||
|
noteId: note.id,
|
||||||
|
embedding: JSON.stringify(embedding)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
processedCount++
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to reindex note ${note.id}:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
count: processedCount,
|
||||||
|
total: notes.length
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Reindex error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to reindex notes' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
export interface AdminContentAreaProps {
|
|
||||||
children: React.ReactNode
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminContentArea({ children, className }: AdminContentAreaProps) {
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
className={cn(
|
|
||||||
'flex-1 overflow-y-auto bg-gray-50 dark:bg-zinc-950 p-6',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
71
memento-note/components/admin-nav.tsx
Normal file
71
memento-note/components/admin-nav.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { LayoutDashboard, Users, Brain, Settings } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
|
export interface AdminNavProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
titleKey: string
|
||||||
|
href: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
titleKey: 'admin.sidebar.dashboard',
|
||||||
|
href: '/admin',
|
||||||
|
icon: <LayoutDashboard className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'admin.sidebar.users',
|
||||||
|
href: '/admin/users',
|
||||||
|
icon: <Users className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'admin.sidebar.aiManagement',
|
||||||
|
href: '/admin/ai',
|
||||||
|
icon: <Brain className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'admin.sidebar.settings',
|
||||||
|
href: '/admin/settings',
|
||||||
|
icon: <Settings className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AdminNav({ className }: AdminNavProps) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={cn('flex items-center gap-1', className)}>
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href || (item.href !== '/admin' && pathname?.startsWith(item.href + '/'))
|
||||||
|
|
||||||
|
return (
|
||||||
|
// <a> instead of <Link>: avoids Next.js RSC navigation transitions
|
||||||
|
// that trigger React Error #310 (React bug #33580) in production.
|
||||||
|
// Full-page reloads are acceptable for admin navigation.
|
||||||
|
<a
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
|
||||||
|
isActive
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span>{t(item.titleKey)}</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { usePathname } from 'next/navigation'
|
|
||||||
import { LayoutDashboard, Users, Brain, Settings } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
export interface AdminSidebarProps {
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NavItem {
|
|
||||||
titleKey: string
|
|
||||||
href: string
|
|
||||||
icon: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
|
||||||
{
|
|
||||||
titleKey: 'admin.sidebar.dashboard',
|
|
||||||
href: '/admin',
|
|
||||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: 'admin.sidebar.users',
|
|
||||||
href: '/admin/users',
|
|
||||||
icon: <Users className="h-5 w-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: 'admin.sidebar.aiManagement',
|
|
||||||
href: '/admin/ai',
|
|
||||||
icon: <Brain className="h-5 w-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: 'admin.sidebar.settings',
|
|
||||||
href: '/admin/settings',
|
|
||||||
icon: <Settings className="h-5 w-5" />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function AdminSidebar({ className }: AdminSidebarProps) {
|
|
||||||
const pathname = usePathname()
|
|
||||||
const { t } = useLanguage()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
className={cn(
|
|
||||||
'w-64 bg-white dark:bg-zinc-900 border-r border-gray-200 dark:border-gray-800 p-4',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<nav className="space-y-1">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const isActive = pathname === item.href || (item.href !== '/admin' && pathname?.startsWith(item.href + '/'))
|
|
||||||
|
|
||||||
return (
|
|
||||||
// <a> instead of <Link>: avoids Next.js RSC navigation transitions
|
|
||||||
// that trigger React Error #310 (React bug #33580) in production.
|
|
||||||
// Full-page reloads are acceptable for admin navigation.
|
|
||||||
<a
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors',
|
|
||||||
'hover:bg-gray-100 dark:hover:bg-zinc-800',
|
|
||||||
isActive
|
|
||||||
? 'bg-gray-100 dark:bg-zinc-800 text-gray-900 dark:text-white font-semibold'
|
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
<span>{t(item.titleKey)}</span>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Card } from '@/components/ui/card'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||||
import { DemoModeToggle } from '@/components/demo-mode-toggle'
|
import { DemoModeToggle } from '@/components/demo-mode-toggle'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2, Sparkles, Brain, Languages, Tag, History, Wand2 } from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface AISettingsPanelProps {
|
interface AISettingsPanelProps {
|
||||||
@@ -35,17 +33,13 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const handleToggle = async (feature: string, value: boolean) => {
|
const handleToggle = async (feature: string, value: boolean) => {
|
||||||
// Optimistic update
|
|
||||||
setSettings(prev => ({ ...prev, [feature]: value }))
|
setSettings(prev => ({ ...prev, [feature]: value }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsPending(true)
|
setIsPending(true)
|
||||||
await updateAISettings({ [feature]: value })
|
await updateAISettings({ [feature]: value })
|
||||||
toast.success(t('aiSettings.saved'))
|
toast.success(t('aiSettings.saved'))
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error updating setting:', error)
|
|
||||||
toast.error(t('aiSettings.error'))
|
toast.error(t('aiSettings.error'))
|
||||||
// Revert on error
|
|
||||||
setSettings(initialSettings)
|
setSettings(initialSettings)
|
||||||
} finally {
|
} finally {
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
@@ -54,31 +48,11 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
|
|
||||||
const handleFrequencyChange = async (value: 'daily' | 'weekly' | 'custom') => {
|
const handleFrequencyChange = async (value: 'daily' | 'weekly' | 'custom') => {
|
||||||
setSettings(prev => ({ ...prev, memoryEchoFrequency: value }))
|
setSettings(prev => ({ ...prev, memoryEchoFrequency: value }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsPending(true)
|
setIsPending(true)
|
||||||
await updateAISettings({ memoryEchoFrequency: value })
|
await updateAISettings({ memoryEchoFrequency: value })
|
||||||
toast.success(t('aiSettings.saved'))
|
toast.success(t('aiSettings.saved'))
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error updating frequency:', error)
|
|
||||||
toast.error(t('aiSettings.error'))
|
|
||||||
setSettings(initialSettings)
|
|
||||||
} finally {
|
|
||||||
setIsPending(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleLanguageChange = async (value: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl') => {
|
|
||||||
setSettings(prev => ({ ...prev, preferredLanguage: value }))
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsPending(true)
|
|
||||||
await updateAISettings({ preferredLanguage: value })
|
|
||||||
toast.success(t('aiSettings.saved'))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating language:', error)
|
|
||||||
toast.error(t('aiSettings.error'))
|
toast.error(t('aiSettings.error'))
|
||||||
setSettings(initialSettings)
|
setSettings(initialSettings)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -88,179 +62,154 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
|
|
||||||
const handleDemoModeToggle = async (enabled: boolean) => {
|
const handleDemoModeToggle = async (enabled: boolean) => {
|
||||||
setSettings(prev => ({ ...prev, demoMode: enabled }))
|
setSettings(prev => ({ ...prev, demoMode: enabled }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsPending(true)
|
setIsPending(true)
|
||||||
await updateAISettings({ demoMode: enabled })
|
await updateAISettings({ demoMode: enabled })
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error toggling demo mode:', error)
|
|
||||||
toast.error(t('aiSettings.error'))
|
toast.error(t('aiSettings.error'))
|
||||||
setSettings(initialSettings)
|
setSettings(initialSettings)
|
||||||
throw error
|
throw new Error()
|
||||||
} finally {
|
} finally {
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
key: 'titleSuggestions',
|
||||||
|
icon: Wand2,
|
||||||
|
name: t('titleSuggestions.available').replace('💡 ', ''),
|
||||||
|
description: t('aiSettings.titleSuggestionsDesc'),
|
||||||
|
value: settings.titleSuggestions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'paragraphRefactor',
|
||||||
|
icon: Sparkles,
|
||||||
|
name: t('aiSettings.aiNote'),
|
||||||
|
description: t('aiSettings.aiNoteDesc'),
|
||||||
|
value: settings.paragraphRefactor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'memoryEcho',
|
||||||
|
icon: Brain,
|
||||||
|
name: t('memoryEcho.title'),
|
||||||
|
description: t('memoryEcho.dailyInsight'),
|
||||||
|
value: settings.memoryEcho,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'languageDetection',
|
||||||
|
icon: Languages,
|
||||||
|
name: t('aiSettings.languageDetection'),
|
||||||
|
description: t('aiSettings.languageDetectionDesc'),
|
||||||
|
value: settings.languageDetection ?? true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'autoLabeling',
|
||||||
|
icon: Tag,
|
||||||
|
name: t('aiSettings.autoLabeling'),
|
||||||
|
description: t('aiSettings.autoLabelingDesc'),
|
||||||
|
value: settings.autoLabeling ?? true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'noteHistory',
|
||||||
|
icon: History,
|
||||||
|
name: t('aiSettings.noteHistory'),
|
||||||
|
description: t('aiSettings.noteHistoryDesc'),
|
||||||
|
value: settings.noteHistory ?? false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
{isPending && (
|
{isPending && (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{t('aiSettings.saving')}
|
{t('aiSettings.saving')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Feature Toggles */}
|
{/* Feature toggles as cards */}
|
||||||
<div className="space-y-4">
|
<div>
|
||||||
<h2 className="text-xl font-semibold">{t('aiSettings.features')}</h2>
|
<h2 className="text-base font-semibold text-foreground mb-4">{t('aiSettings.features')}</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FeatureToggle
|
{features.map(({ key, icon: Icon, name, description, value }) => (
|
||||||
name={t('titleSuggestions.available').replace('💡 ', '')}
|
<div
|
||||||
description={t('aiSettings.titleSuggestionsDesc')}
|
key={key}
|
||||||
checked={settings.titleSuggestions}
|
className="bg-card rounded-lg border border-border p-5 shadow-sm flex items-start justify-between gap-4"
|
||||||
onChange={(checked) => handleToggle('titleSuggestions', checked)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
|
||||||
<FeatureToggle
|
|
||||||
name={t('aiSettings.aiNote')}
|
|
||||||
description={t('aiSettings.aiNoteDesc')}
|
|
||||||
checked={settings.paragraphRefactor}
|
|
||||||
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FeatureToggle
|
|
||||||
name={t('memoryEcho.title')}
|
|
||||||
description={t('memoryEcho.dailyInsight')}
|
|
||||||
checked={settings.memoryEcho}
|
|
||||||
onChange={(checked) => handleToggle('memoryEcho', checked)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{settings.memoryEcho && (
|
|
||||||
<Card className="p-4 ml-6">
|
|
||||||
<Label htmlFor="frequency" className="text-sm font-medium">
|
|
||||||
{t('aiSettings.frequency')}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-gray-500 mb-3">
|
|
||||||
{t('aiSettings.frequencyDesc')}
|
|
||||||
</p>
|
|
||||||
<RadioGroup
|
|
||||||
value={settings.memoryEchoFrequency}
|
|
||||||
onValueChange={handleFrequencyChange}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-start gap-3">
|
||||||
<RadioGroupItem value="daily" id="daily" />
|
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0 mt-0.5">
|
||||||
<Label htmlFor="daily" className="font-normal">
|
<Icon className="h-4 w-4" />
|
||||||
{t('aiSettings.frequencyDaily')}
|
</div>
|
||||||
</Label>
|
<div>
|
||||||
</div>
|
<p className="text-sm font-medium text-foreground">{name}</p>
|
||||||
<div className="flex items-center space-x-2">
|
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||||
<RadioGroupItem value="weekly" id="weekly" />
|
|
||||||
<Label htmlFor="weekly" className="font-normal">
|
|
||||||
{t('aiSettings.frequencyWeekly')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Language Detection Toggle */}
|
|
||||||
<FeatureToggle
|
|
||||||
name={t('aiSettings.languageDetection')}
|
|
||||||
description={t('aiSettings.languageDetectionDesc')}
|
|
||||||
checked={settings.languageDetection ?? true}
|
|
||||||
onChange={(checked) => handleToggle('languageDetection', checked)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Auto Labeling Toggle */}
|
|
||||||
<FeatureToggle
|
|
||||||
name={t('aiSettings.autoLabeling')}
|
|
||||||
description={t('aiSettings.autoLabelingDesc')}
|
|
||||||
checked={settings.autoLabeling ?? true}
|
|
||||||
onChange={(checked) => handleToggle('autoLabeling', checked)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FeatureToggle
|
|
||||||
name={t('aiSettings.noteHistory')}
|
|
||||||
description={t('aiSettings.noteHistoryDesc')}
|
|
||||||
checked={settings.noteHistory ?? false}
|
|
||||||
onChange={(checked) => handleToggle('noteHistory', checked)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{settings.noteHistory && (
|
|
||||||
<div className="space-y-2 rounded-lg border border-border/50 bg-muted/30 p-3">
|
|
||||||
<p className="text-sm font-medium">{t('notes.historyMode')}</p>
|
|
||||||
<RadioGroup
|
|
||||||
value={settings.noteHistoryMode ?? 'manual'}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const mode = value as 'manual' | 'auto'
|
|
||||||
setSettings((s) => ({ ...s, noteHistoryMode: mode }))
|
|
||||||
updateAISettings({ noteHistoryMode: mode }).then(() => {
|
|
||||||
toast.success(t('settings.settingsSaved'))
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
className="space-y-2"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<RadioGroupItem value="manual" id="history-manual" />
|
|
||||||
<div className="grid gap-0.5 leading-none">
|
|
||||||
<Label htmlFor="history-manual" className="text-sm font-medium">
|
|
||||||
{t('notes.historyModeManual')}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('notes.historyModeManualDesc')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2">
|
<button
|
||||||
<RadioGroupItem value="auto" id="history-auto" />
|
type="button"
|
||||||
<div className="grid gap-0.5 leading-none">
|
role="switch"
|
||||||
<Label htmlFor="history-auto" className="text-sm font-medium">
|
aria-checked={value}
|
||||||
{t('notes.historyModeAuto')}
|
onClick={() => handleToggle(key, !value)}
|
||||||
</Label>
|
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 mt-0.5 ${value ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||||
<p className="text-xs text-muted-foreground">
|
>
|
||||||
{t('notes.historyModeAutoDesc')}
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${value ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
</p>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
</RadioGroup>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Demo Mode Toggle */}
|
|
||||||
<DemoModeToggle
|
|
||||||
demoMode={settings.demoMode}
|
|
||||||
onToggle={handleDemoModeToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Memory Echo frequency — shown when enabled */}
|
||||||
|
{settings.memoryEcho && (
|
||||||
|
<div className="bg-card rounded-lg border border-border p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-1">{t('aiSettings.frequency')}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">{t('aiSettings.frequencyDesc')}</p>
|
||||||
|
<RadioGroup value={settings.memoryEchoFrequency} onValueChange={handleFrequencyChange} className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ value: 'daily', label: t('aiSettings.frequencyDaily') },
|
||||||
|
{ value: 'weekly', label: t('aiSettings.frequencyWeekly') },
|
||||||
|
].map((opt) => (
|
||||||
|
<div key={opt.value} className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value={opt.value} id={`freq-${opt.value}`} />
|
||||||
|
<Label htmlFor={`freq-${opt.value}`} className="font-normal text-sm cursor-pointer">{opt.label}</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Note History mode — shown when enabled */}
|
||||||
|
{settings.noteHistory && (
|
||||||
|
<div className="bg-card rounded-lg border border-border p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-1">{t('notes.historyMode')}</h3>
|
||||||
|
<RadioGroup
|
||||||
|
value={settings.noteHistoryMode ?? 'manual'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const mode = value as 'manual' | 'auto'
|
||||||
|
setSettings(s => ({ ...s, noteHistoryMode: mode }))
|
||||||
|
updateAISettings({ noteHistoryMode: mode }).then(() => toast.success(t('settings.settingsSaved')))
|
||||||
|
}}
|
||||||
|
className="space-y-3 mt-3"
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ value: 'manual', label: t('notes.historyModeManual'), desc: t('notes.historyModeManualDesc') },
|
||||||
|
{ value: 'auto', label: t('notes.historyModeAuto'), desc: t('notes.historyModeAutoDesc') },
|
||||||
|
].map((opt) => (
|
||||||
|
<div key={opt.value} className="flex items-start gap-2">
|
||||||
|
<RadioGroupItem value={opt.value} id={`history-${opt.value}`} className="mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={`history-${opt.value}`} className="text-sm font-medium cursor-pointer">{opt.label}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">{opt.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Demo Mode */}
|
||||||
|
<DemoModeToggle demoMode={settings.demoMode} onToggle={handleDemoModeToggle} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FeatureToggleProps {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
checked: boolean
|
|
||||||
onChange: (checked: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeatureToggle({ name, description, checked, onChange }: FeatureToggleProps) {
|
|
||||||
return (
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-base font-medium">{name}</Label>
|
|
||||||
<p className="text-sm text-gray-500">{description}</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={onChange}
|
|
||||||
disabled={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useTransition } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
import { Card } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
@@ -117,59 +116,71 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="columns-1 lg:columns-2 gap-6 space-y-6">
|
||||||
{/* Section 1: What is MCP */}
|
{/* Section 1: What is MCP */}
|
||||||
<Card className="p-6">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<Info className="h-5 w-5 text-blue-500 mt-0.5 shrink-0" />
|
<div className="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-500 shrink-0">
|
||||||
|
<Info className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">{t('mcpSettings.whatIsMcp.title')}</h2>
|
<h2 className="font-semibold text-foreground">{t('mcpSettings.whatIsMcp.title')}</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
{t('mcpSettings.whatIsMcp.description')}
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://modelcontextprotocol.io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2"
|
|
||||||
>
|
|
||||||
{t('mcpSettings.whatIsMcp.learnMore')}
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<div className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{t('mcpSettings.whatIsMcp.description')}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://modelcontextprotocol.io"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline mt-4"
|
||||||
|
>
|
||||||
|
{t('mcpSettings.whatIsMcp.learnMore')}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Section 2: Server Status */}
|
{/* Section 2: Server Status */}
|
||||||
<Card className="p-6">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<Server className="h-5 w-5 shrink-0" />
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<h2 className="text-lg font-semibold">{t('mcpSettings.serverStatus.title')}</h2>
|
<Server className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-sm">
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<h2 className="font-semibold text-foreground">{t('mcpSettings.serverStatus.title')}</h2>
|
||||||
<span className="text-gray-500">{t('mcpSettings.serverStatus.mode')}:</span>
|
|
||||||
<Badge variant="secondary">{serverStatus.mode.toUpperCase()}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
{serverStatus.mode === 'sse' && serverStatus.url && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-gray-500">{t('mcpSettings.serverStatus.url')}:</span>
|
|
||||||
<code className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
|
|
||||||
{serverStatus.url}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<div className="p-6">
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">{t('mcpSettings.serverStatus.mode')}</span>
|
||||||
|
<Badge variant="secondary">{serverStatus.mode.toUpperCase()}</Badge>
|
||||||
|
</div>
|
||||||
|
{serverStatus.mode === 'sse' && serverStatus.url && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-muted-foreground block">{t('mcpSettings.serverStatus.url')}</span>
|
||||||
|
<code className="text-xs bg-muted p-2 rounded block break-all font-mono">
|
||||||
|
{serverStatus.url}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Section 3: API Keys */}
|
{/* Section 3: API Keys */}
|
||||||
<Card className="p-6">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Key className="h-5 w-5 shrink-0" />
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
|
<Key className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">{t('mcpSettings.apiKeys.title')}</h2>
|
<h2 className="font-semibold text-foreground">{t('mcpSettings.apiKeys.title')}</h2>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('mcpSettings.apiKeys.description')}
|
{t('mcpSettings.apiKeys.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,28 +199,32 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{keys.length === 0 ? (
|
<div className="p-6">
|
||||||
<div className="text-center py-8 text-gray-500">
|
{keys.length === 0 ? (
|
||||||
<Key className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<p>{t('mcpSettings.apiKeys.empty')}</p>
|
<Key className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
</div>
|
<p>{t('mcpSettings.apiKeys.empty')}</p>
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-3">
|
) : (
|
||||||
{keys.map(k => (
|
<div className="space-y-3">
|
||||||
<KeyCard
|
{keys.map(k => (
|
||||||
key={k.shortId}
|
<KeyCard
|
||||||
keyInfo={k}
|
key={k.shortId}
|
||||||
onRevoke={handleRevoke}
|
keyInfo={k}
|
||||||
onDelete={handleDelete}
|
onRevoke={handleRevoke}
|
||||||
isPending={isPending}
|
onDelete={handleDelete}
|
||||||
/>
|
isPending={isPending}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</Card>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Section 4: Configuration Instructions */}
|
{/* Section 4: Configuration Instructions */}
|
||||||
<ConfigInstructions serverStatus={serverStatus} />
|
<div className="break-inside-avoid">
|
||||||
|
<ConfigInstructions serverStatus={serverStatus} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Raw Key Display Dialog */}
|
{/* Raw Key Display Dialog */}
|
||||||
<Dialog open={!!showRawKey} onOpenChange={(open) => { if (!open) setShowRawKey(null) }}>
|
<Dialog open={!!showRawKey} onOpenChange={(open) => { if (!open) setShowRawKey(null) }}>
|
||||||
@@ -222,9 +237,9 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-500">{rawKeyName}</Label>
|
<Label className="text-xs text-muted-foreground">{rawKeyName}</Label>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<code className="flex-1 text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded break-all font-mono">
|
<code className="flex-1 text-xs bg-muted p-3 rounded break-all font-mono">
|
||||||
{showRawKey}
|
{showRawKey}
|
||||||
</code>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
@@ -330,7 +345,7 @@ function KeyCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-4 rounded-lg border bg-gray-50 dark:bg-gray-900">
|
<div className="flex items-center justify-between p-4 rounded-lg border bg-muted/50">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-sm">{keyInfo.name}</span>
|
<span className="font-medium text-sm">{keyInfo.name}</span>
|
||||||
@@ -340,7 +355,7 @@ function KeyCard({
|
|||||||
: t('mcpSettings.apiKeys.revoked')}
|
: t('mcpSettings.apiKeys.revoked')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 text-xs text-gray-500">
|
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}
|
{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
@@ -436,23 +451,25 @@ Transport: Streamable HTTP`,
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-6">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<ExternalLink className="h-5 w-5 shrink-0" />
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
|
<ExternalLink className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="font-semibold text-foreground">
|
||||||
{t('mcpSettings.configInstructions.title')}
|
{t('mcpSettings.configInstructions.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('mcpSettings.configInstructions.description')}
|
{t('mcpSettings.configInstructions.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="p-6 space-y-2">
|
||||||
{configs.map(cfg => (
|
{configs.map(cfg => (
|
||||||
<div key={cfg.id} className="border rounded-lg overflow-hidden">
|
<div key={cfg.id} className="border rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"
|
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted transition-colors"
|
||||||
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
|
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
|
||||||
>
|
>
|
||||||
<span className="font-medium text-sm">{cfg.title}</span>
|
<span className="font-medium text-sm">{cfg.title}</span>
|
||||||
@@ -464,8 +481,8 @@ Transport: Streamable HTTP`,
|
|||||||
</button>
|
</button>
|
||||||
{expanded === cfg.id && (
|
{expanded === cfg.id && (
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<p className="text-sm text-gray-500 mb-2">{cfg.description}</p>
|
<p className="text-sm text-muted-foreground mb-2">{cfg.description}</p>
|
||||||
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto">
|
<pre className="text-xs bg-muted p-3 rounded overflow-x-auto">
|
||||||
<code>{cfg.snippet}</code>
|
<code>{cfg.snippet}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -473,6 +490,6 @@ Transport: Streamable HTTP`,
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { Settings, Sparkles, Palette, User, Database, Info, Check, Key } from 'lucide-react'
|
import { Settings, Sparkles, Palette, User, Database, Info, Key } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
@@ -22,74 +22,32 @@ export function SettingsNav({ className }: SettingsNavProps) {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const sections: SettingsSection[] = [
|
const sections: SettingsSection[] = [
|
||||||
{
|
{ id: 'general', label: t('generalSettings.title'), icon: <Settings className="h-4 w-4" />, href: '/settings/general' },
|
||||||
id: 'general',
|
{ id: 'ai', label: t('aiSettings.title'), icon: <Sparkles className="h-4 w-4" />, href: '/settings/ai' },
|
||||||
label: t('generalSettings.title'),
|
{ id: 'appearance', label: t('appearance.title'), icon: <Palette className="h-4 w-4" />, href: '/settings/appearance' },
|
||||||
icon: <Settings className="h-5 w-5" />,
|
{ id: 'profile', label: t('profile.title'), icon: <User className="h-4 w-4" />, href: '/settings/profile' },
|
||||||
href: '/settings/general'
|
{ id: 'data', label: t('dataManagement.title'), icon: <Database className="h-4 w-4" />, href: '/settings/data' },
|
||||||
},
|
{ id: 'mcp', label: t('mcpSettings.title'), icon: <Key className="h-4 w-4" />, href: '/settings/mcp' },
|
||||||
{
|
{ id: 'about', label: t('about.title'), icon: <Info className="h-4 w-4" />, href: '/settings/about' },
|
||||||
id: 'ai',
|
|
||||||
label: t('aiSettings.title'),
|
|
||||||
icon: <Sparkles className="h-5 w-5" />,
|
|
||||||
href: '/settings/ai'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'appearance',
|
|
||||||
label: t('appearance.title'),
|
|
||||||
icon: <Palette className="h-5 w-5" />,
|
|
||||||
href: '/settings/appearance'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'profile',
|
|
||||||
label: t('profile.title'),
|
|
||||||
icon: <User className="h-5 w-5" />,
|
|
||||||
href: '/settings/profile'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'data',
|
|
||||||
label: t('dataManagement.title'),
|
|
||||||
icon: <Database className="h-5 w-5" />,
|
|
||||||
href: '/settings/data'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mcp',
|
|
||||||
label: t('mcpSettings.title'),
|
|
||||||
icon: <Key className="h-5 w-5" />,
|
|
||||||
href: '/settings/mcp'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'about',
|
|
||||||
label: t('about.title'),
|
|
||||||
icon: <Info className="h-5 w-5" />,
|
|
||||||
href: '/settings/about'
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
|
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={cn('space-y-1', className)}>
|
<nav className={cn('flex items-center gap-1', className)}>
|
||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
<Link
|
<Link
|
||||||
key={section.id}
|
key={section.id}
|
||||||
href={section.href}
|
href={section.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors',
|
'flex items-center gap-2 px-3 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
|
||||||
'hover:bg-gray-100 dark:hover:bg-gray-800',
|
|
||||||
isActive(section.href)
|
isActive(section.href)
|
||||||
? 'bg-gray-100 dark:bg-gray-800 text-primary'
|
? 'border-primary text-primary'
|
||||||
: 'text-gray-700 dark:text-gray-300'
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isActive(section.href) && (
|
|
||||||
<Check className="h-4 w-4 text-primary" />
|
|
||||||
)}
|
|
||||||
{!isActive(section.href) && (
|
|
||||||
<div className="w-4" />
|
|
||||||
)}
|
|
||||||
{section.icon}
|
{section.icon}
|
||||||
<span className="font-medium">{section.label}</span>
|
<span>{section.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -29,5 +29,8 @@ const nextConfig: NextConfig = {
|
|||||||
// TEMP: disable Turbopack due React #310 loop on /admin routes in production builds.
|
// TEMP: disable Turbopack due React #310 loop on /admin routes in production builds.
|
||||||
// We keep webpack pipeline until upstream fix is confirmed.
|
// We keep webpack pipeline until upstream fix is confirmed.
|
||||||
};
|
};
|
||||||
|
module.exports = {
|
||||||
|
allowedDevOrigins: ['192.168.1.83'],
|
||||||
|
}
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
108
memento-note/package-lock.json
generated
108
memento-note/package-lock.json
generated
@@ -503,7 +503,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
@@ -550,7 +549,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
@@ -578,7 +576,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -642,6 +639,28 @@
|
|||||||
"integrity": "sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==",
|
"integrity": "sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
@@ -1603,7 +1622,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.7.5",
|
"@floating-ui/core": "^1.7.5",
|
||||||
"@floating-ui/utils": "^0.2.11"
|
"@floating-ui/utils": "^0.2.11"
|
||||||
@@ -2367,6 +2385,7 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.3",
|
"detect-libc": "^2.0.3",
|
||||||
"is-glob": "^4.0.3",
|
"is-glob": "^4.0.3",
|
||||||
@@ -2409,6 +2428,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2430,6 +2450,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2451,6 +2472,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2472,6 +2494,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2493,6 +2516,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2514,6 +2538,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2535,6 +2560,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2556,6 +2582,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2577,6 +2604,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2598,6 +2626,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2619,6 +2648,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2640,6 +2670,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2661,6 +2692,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2676,6 +2708,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2689,7 +2722,6 @@
|
|||||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.59.1"
|
"playwright": "1.59.1"
|
||||||
},
|
},
|
||||||
@@ -2716,7 +2748,6 @@
|
|||||||
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
|
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.13"
|
"node": ">=16.13"
|
||||||
},
|
},
|
||||||
@@ -6217,7 +6248,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
|
||||||
"integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
|
"integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -6479,7 +6509,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz",
|
||||||
"integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==",
|
"integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -6652,7 +6681,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.5.tgz",
|
||||||
"integrity": "sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==",
|
"integrity": "sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -6692,7 +6720,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz",
|
||||||
"integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==",
|
"integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -6707,7 +6734,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
|
||||||
"integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
|
"integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-changeset": "^2.3.0",
|
"prosemirror-changeset": "^2.3.0",
|
||||||
"prosemirror-commands": "^1.6.2",
|
"prosemirror-commands": "^1.6.2",
|
||||||
@@ -7224,7 +7250,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -7234,7 +7259,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -7295,7 +7319,6 @@
|
|||||||
"integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==",
|
"integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bcoe/v8-coverage": "^1.0.2",
|
"@bcoe/v8-coverage": "^1.0.2",
|
||||||
"@vitest/utils": "4.1.4",
|
"@vitest/utils": "4.1.4",
|
||||||
@@ -7641,7 +7664,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -7813,7 +7835,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
|
||||||
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
|
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chevrotain/cst-dts-gen": "11.0.3",
|
"@chevrotain/cst-dts-gen": "11.0.3",
|
||||||
"@chevrotain/gast": "11.0.3",
|
"@chevrotain/gast": "11.0.3",
|
||||||
@@ -8074,7 +8095,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz",
|
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz",
|
||||||
"integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==",
|
"integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
@@ -8484,7 +8504,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -9588,7 +9607,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz",
|
||||||
"integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==",
|
"integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.20.0"
|
"node": ">=12.20.0"
|
||||||
},
|
},
|
||||||
@@ -10564,7 +10582,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
|
||||||
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chevrotain/cst-dts-gen": "12.0.0",
|
"@chevrotain/cst-dts-gen": "12.0.0",
|
||||||
"@chevrotain/gast": "12.0.0",
|
"@chevrotain/gast": "12.0.0",
|
||||||
@@ -11372,7 +11389,8 @@
|
|||||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.37",
|
"version": "2.0.37",
|
||||||
@@ -11385,7 +11403,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||||
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
@@ -11444,7 +11461,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
|
||||||
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
|
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -11541,7 +11557,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz",
|
||||||
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
|
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -11861,7 +11876,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
||||||
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
|
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -11901,7 +11915,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
|
||||||
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
|
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-changeset": "^2.3.0",
|
"prosemirror-changeset": "^2.3.0",
|
||||||
"prosemirror-collab": "^1.3.1",
|
"prosemirror-collab": "^1.3.1",
|
||||||
@@ -12375,7 +12388,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -12410,7 +12422,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||||
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
@@ -12432,7 +12443,6 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/engines": "5.22.0"
|
"@prisma/engines": "5.22.0"
|
||||||
},
|
},
|
||||||
@@ -12583,7 +12593,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"orderedmap": "^2.0.0"
|
"orderedmap": "^2.0.0"
|
||||||
}
|
}
|
||||||
@@ -12613,7 +12622,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.0.0",
|
"prosemirror-model": "^1.0.0",
|
||||||
"prosemirror-transform": "^1.0.0",
|
"prosemirror-transform": "^1.0.0",
|
||||||
@@ -12674,7 +12682,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||||
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.20.0",
|
"prosemirror-model": "^1.20.0",
|
||||||
"prosemirror-state": "^1.0.0",
|
"prosemirror-state": "^1.0.0",
|
||||||
@@ -12875,7 +12882,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -12895,7 +12901,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -13613,8 +13618,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.3.tgz",
|
||||||
"integrity": "sha512-fA/NX5gMf0ooCLISgB0wScaWgaj6rjTN2SVAwleURjiya7ITNkV+VMmoHtKkldP6CIZoYCZyxb8zP/e2TWoEtQ==",
|
"integrity": "sha512-fA/NX5gMf0ooCLISgB0wScaWgaj6rjTN2SVAwleURjiya7ITNkV+VMmoHtKkldP6CIZoYCZyxb8zP/e2TWoEtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
@@ -13699,7 +13703,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -14203,7 +14206,6 @@
|
|||||||
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
|
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.1.4",
|
"@vitest/expect": "4.1.4",
|
||||||
"@vitest/mocker": "4.1.4",
|
"@vitest/mocker": "4.1.4",
|
||||||
@@ -14322,6 +14324,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readdirp": "^4.0.1"
|
"readdirp": "^4.0.1"
|
||||||
},
|
},
|
||||||
@@ -14353,7 +14356,8 @@
|
|||||||
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/vitest/node_modules/picomatch": {
|
"node_modules/vitest/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
@@ -14375,6 +14379,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14.18.0"
|
"node": ">= 14.18.0"
|
||||||
},
|
},
|
||||||
@@ -14383,13 +14388,35 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest/node_modules/sass": {
|
||||||
|
"version": "1.99.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz",
|
||||||
|
"integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^4.0.0",
|
||||||
|
"immutable": "^5.1.5",
|
||||||
|
"source-map-js": ">=0.6.2 <2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"sass": "sass.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@parcel/watcher": "^2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitest/node_modules/vite": {
|
"node_modules/vitest/node_modules/vite": {
|
||||||
"version": "8.0.9",
|
"version": "8.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
|
||||||
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
|
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
@@ -14664,7 +14691,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user