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

This commit is contained in:
Antigravity
2026-05-03 12:51:25 +00:00
parent 635e516616
commit b611ec874d
27 changed files with 1151 additions and 1081 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 }
)
}
}

View File

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

View File

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

View 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 }
)
}
}

View File

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

View 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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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