feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
@@ -6,8 +6,8 @@ export function AISettingsHeader() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">
|
||||
{t('aiSettings.description')}
|
||||
</p>
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AISettingsPanel } from '@/components/ai/ai-settings-panel'
|
||||
import { ByokSettingsPanel } from '@/components/ai/byok-settings-panel'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { AISettingsHeader } from './ai-settings-header'
|
||||
|
||||
@@ -17,6 +18,7 @@ export default async function AISettingsPage() {
|
||||
<div className="space-y-6">
|
||||
<AISettingsHeader />
|
||||
<AISettingsPanel initialSettings={settings} />
|
||||
<ByokSettingsPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { updateUserSettings } from '@/app/actions/user-settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import { Palette, Type } from 'lucide-react'
|
||||
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react'
|
||||
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
const PRESET_COLORS = [
|
||||
{ name: 'Warm Earth', value: '#A47148' },
|
||||
{ name: 'Sage', value: '#4E594A' },
|
||||
{ name: 'Terracotta', value: '#B1523E' },
|
||||
{ name: 'Iron', value: '#4A5568' },
|
||||
{ name: 'Charcoal', value: '#1E293B' },
|
||||
{ name: 'Slate', value: '#475569' },
|
||||
{ name: 'Olive', value: '#7C8363' },
|
||||
{ name: 'Wine', value: '#722F37' },
|
||||
]
|
||||
|
||||
interface AppearanceSettingsClientProps {
|
||||
initialFontSize: string
|
||||
initialTheme: string
|
||||
initialFontFamily?: string
|
||||
initialAccentColor?: string
|
||||
}
|
||||
|
||||
export function AppearanceSettingsClient({
|
||||
initialFontSize,
|
||||
initialTheme,
|
||||
initialFontFamily = 'inter',
|
||||
initialAccentColor = '#A47148',
|
||||
}: AppearanceSettingsClientProps) {
|
||||
const { t } = useLanguage()
|
||||
const [theme, setTheme] = useState<ThemeId>(normalizeThemeId(initialTheme || 'light'))
|
||||
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
||||
const [fontFamily, setFontFamily] = useState(initialFontFamily)
|
||||
const [accentColor, setAccentColor] = useState(initialAccentColor)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('--color-brand-accent', accentColor)
|
||||
}, [accentColor])
|
||||
|
||||
const handleAccentColorChange = async (color: string) => {
|
||||
setAccentColor(color)
|
||||
document.documentElement.style.setProperty('--color-brand-accent', color)
|
||||
localStorage.setItem('accent-color', color)
|
||||
await updateUserSettings({ accentColor: color })
|
||||
}
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
const next = normalizeThemeId(value)
|
||||
@@ -74,21 +100,21 @@ export function AppearanceSettingsClient({
|
||||
optionGroups?: { label: 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">
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-8 group transition-all duration-300 hover:shadow-xl hover:shadow-slate/5">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border group-hover:scale-110 transition-transform duration-300">
|
||||
<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 className="space-y-0.5 text-left">
|
||||
<h4 className="text-base font-bold text-foreground">{title}</h4>
|
||||
<p className="text-[11px] text-muted-foreground leading-tight">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-2">
|
||||
<div className="relative group/select">
|
||||
<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"
|
||||
className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-4 text-sm outline-none focus:ring-1 ring-slate/20 appearance-none cursor-pointer text-foreground font-bold transition-all hover:bg-white dark:hover:bg-black/60"
|
||||
>
|
||||
{optionGroups
|
||||
? optionGroups.map((g) => (
|
||||
@@ -106,9 +132,9 @@ export function AppearanceSettingsClient({
|
||||
</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" />
|
||||
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground group-hover/select:text-foreground transition-colors">
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none">
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,52 +167,116 @@ export function AppearanceSettingsClient({
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Section label — architectural style */}
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t('appearance.description') || "Personnalisez l'interface"}
|
||||
</p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-16 pb-20"
|
||||
>
|
||||
<div className="space-y-10">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t('appearance.description') || "Personnalisez l'interface"}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<SelectCard
|
||||
icon={Palette}
|
||||
title={t('settings.theme')}
|
||||
description={t('appearance.selectTheme')}
|
||||
value={theme}
|
||||
optionGroups={themeOptionGroups}
|
||||
onChange={handleThemeChange}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Accent Color Section */}
|
||||
<div className="md:col-span-2 bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-10 space-y-8 group transition-all duration-300 hover:shadow-xl hover:shadow-brand-accent/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl border border-border group-hover:scale-110 transition-transform duration-300 shadow-sm" style={{ color: accentColor }}>
|
||||
<Palette size={20} />
|
||||
</div>
|
||||
<div className="space-y-0.5 text-left">
|
||||
<h4 className="text-base font-bold text-foreground">{t('appearance.accentColorTitle') || 'Couleur d\'accentuation'}</h4>
|
||||
<p className="text-[11px] text-muted-foreground leading-tight">{t('appearance.accentColorDescription') || 'Définissez la couleur principale de votre espace de travail'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 bg-slate-50 dark:bg-black/20 px-4 py-2 rounded-xl border border-border/40">
|
||||
<div className="w-4 h-4 rounded-full border border-border/60" style={{ backgroundColor: accentColor }} />
|
||||
<span className="text-xs font-mono font-medium text-muted-foreground uppercase tracking-widest">{accentColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectCard
|
||||
icon={Type}
|
||||
title={t('profile.fontSize')}
|
||||
description={t('profile.fontSizeDescription')}
|
||||
value={fontSize}
|
||||
options={[
|
||||
{ value: 'small', label: t('profile.fontSizeSmall') },
|
||||
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
||||
{ value: 'large', label: t('profile.fontSizeLarge') },
|
||||
{ value: 'extra-large', label: t('profile.fontSizeExtraLarge') },
|
||||
]}
|
||||
onChange={handleFontSizeChange}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
onClick={() => handleAccentColorChange(color.value)}
|
||||
className={`relative w-12 h-12 rounded-2xl transition-all duration-300 hover:scale-110 flex items-center justify-center p-1 border-2 ${
|
||||
accentColor.toLowerCase() === color.value.toLowerCase()
|
||||
? 'border-foreground shadow-lg'
|
||||
: 'border-transparent hover:border-muted-foreground/20'
|
||||
}`}
|
||||
title={color.name}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full rounded-xl shadow-inner"
|
||||
style={{ backgroundColor: color.value }}
|
||||
/>
|
||||
{accentColor.toLowerCase() === color.value.toLowerCase() && (
|
||||
<motion.div
|
||||
layoutId="color-check"
|
||||
className="absolute inset-0 flex items-center justify-center text-white mix-blend-difference"
|
||||
>
|
||||
<Palette size={14} />
|
||||
</motion.div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<SelectCard
|
||||
icon={Type}
|
||||
title={t('appearance.fontFamilyLabel') || 'Police'}
|
||||
description={t('appearance.fontFamilyDescription') || "Choisissez la police de l'application"}
|
||||
value={fontFamily}
|
||||
options={[
|
||||
{ value: 'inter', label: 'Inter (défaut)' },
|
||||
{ value: 'playfair', label: 'Playfair Display' },
|
||||
{ value: 'jetbrains', label: 'JetBrains Mono' },
|
||||
{ value: 'system', label: t('appearance.fontSystem') || 'Système' },
|
||||
]}
|
||||
onChange={handleFontFamilyChange}
|
||||
/>
|
||||
<div className="h-12 w-px bg-border/40 mx-2" />
|
||||
|
||||
<div className="relative group/custom">
|
||||
<input
|
||||
type="color"
|
||||
value={accentColor}
|
||||
onChange={(e) => handleAccentColorChange(e.target.value)}
|
||||
className="w-12 h-12 rounded-2xl cursor-pointer opacity-0 absolute inset-0 z-10"
|
||||
/>
|
||||
<div className="w-12 h-12 rounded-2xl border-2 border-dashed border-muted-foreground/30 flex items-center justify-center text-muted-foreground transition-all group-hover/custom:border-foreground group-hover/custom:text-foreground">
|
||||
<Maximize size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectCard
|
||||
icon={Palette}
|
||||
title={t('settings.theme')}
|
||||
description={t('appearance.selectTheme')}
|
||||
value={theme}
|
||||
optionGroups={themeOptionGroups}
|
||||
onChange={handleThemeChange}
|
||||
/>
|
||||
|
||||
<SelectCard
|
||||
icon={Type}
|
||||
title={t('profile.fontSize')}
|
||||
description={t('profile.fontSizeDescription')}
|
||||
value={fontSize}
|
||||
options={[
|
||||
{ value: 'small', label: t('profile.fontSizeSmall') },
|
||||
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
||||
{ value: 'large', label: t('profile.fontSizeLarge') },
|
||||
{ value: 'extra-large', label: t('profile.fontSizeExtraLarge') },
|
||||
]}
|
||||
onChange={handleFontSizeChange}
|
||||
/>
|
||||
|
||||
<SelectCard
|
||||
icon={Type}
|
||||
title={t('appearance.fontFamilyLabel') || 'Police'}
|
||||
description={t('appearance.fontFamilyDescription') || "Choisissez la police de l'application"}
|
||||
value={fontFamily}
|
||||
options={[
|
||||
{ value: 'inter', label: t('appearance.fontInterDefault') },
|
||||
{ value: 'playfair', label: t('appearance.fontPlayfairDisplay') },
|
||||
{ value: 'jetbrains', label: t('appearance.fontJetBrainsMono') },
|
||||
{ value: 'system', label: t('appearance.fontSystem') || 'Système' },
|
||||
]}
|
||||
onChange={handleFontFamilyChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default async function AppearanceSettingsPage() {
|
||||
initialFontSize={aiSettings.fontSize}
|
||||
initialTheme={userSettings.theme}
|
||||
initialFontFamily={aiSettings.fontFamily || 'inter'}
|
||||
initialAccentColor={userSettings.accentColor || '#A47148'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
23
memento-note/app/(main)/settings/billing/page.tsx
Normal file
23
memento-note/app/(main)/settings/billing/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { BillingPlans } from '@/components/settings/billing-plans';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Billing',
|
||||
};
|
||||
|
||||
function Fallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
return (
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<BillingPlans />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Download, Upload, Trash2, Loader2, RefreshCw, Sparkles, Database } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function DataSettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
@@ -117,114 +118,134 @@ export default function DataSettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
icon: Download,
|
||||
iconColor: 'text-zinc-600 dark:text-zinc-400',
|
||||
iconBg: 'bg-zinc-500/10 dark:bg-zinc-500/20',
|
||||
title: t('dataManagement.export.title'),
|
||||
description: t('dataManagement.export.description'),
|
||||
loading: isExporting,
|
||||
loadingText: t('dataManagement.exporting'),
|
||||
buttonText: t('dataManagement.export.button'),
|
||||
onAction: handleExport,
|
||||
btnClass: 'bg-ink text-paper shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
},
|
||||
{
|
||||
icon: Upload,
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
iconBg: 'bg-emerald-500/10 dark:bg-emerald-500/20',
|
||||
title: t('dataManagement.import.title'),
|
||||
description: t('dataManagement.import.description'),
|
||||
loading: isImporting,
|
||||
loadingText: t('dataManagement.importing'),
|
||||
buttonText: t('dataManagement.import.button'),
|
||||
onAction: () => document.getElementById('import-file')?.click(),
|
||||
btnClass: 'bg-white dark:bg-white/10 text-ink dark:text-paper border border-border hover:scale-[1.02] active:scale-95',
|
||||
fileInput: true,
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
iconBg: 'bg-amber-500/10 dark:bg-amber-500/20',
|
||||
title: t('dataManagement.indexing.title'),
|
||||
description: t('dataManagement.indexing.description'),
|
||||
loading: isReindexing,
|
||||
loadingText: t('dataManagement.exporting'),
|
||||
buttonText: t('dataManagement.indexing.button'),
|
||||
onAction: handleReindex,
|
||||
btnClass: 'bg-white dark:bg-white/10 text-ink dark:text-paper border border-border hover:scale-[1.02] active:scale-95',
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
iconColor: 'text-purple-600 dark:text-purple-400',
|
||||
iconBg: 'bg-purple-500/10 dark:bg-purple-500/20',
|
||||
title: t('dataManagement.cleanup.title'),
|
||||
description: t('dataManagement.cleanup.description'),
|
||||
loading: isCleaningUp,
|
||||
loadingText: t('dataManagement.exporting'),
|
||||
buttonText: t('dataManagement.cleanup.button'),
|
||||
onAction: handleCleanup,
|
||||
btnClass: 'bg-white dark:bg-white/10 text-ink dark:text-paper border border-border hover:scale-[1.02] active:scale-95',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-12"
|
||||
>
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">
|
||||
{t('dataManagement.toolsDescription')}
|
||||
</p>
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Export 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-zinc-500/10 flex items-center justify-center text-zinc-600 shrink-0">
|
||||
<Download className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.export.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{t('dataManagement.export.description')}
|
||||
</p>
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.title}
|
||||
className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 flex flex-col justify-between group hover:shadow-xl hover:shadow-black/5 transition-all duration-300"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className={cn('w-12 h-12 rounded-2xl flex items-center justify-center shrink-0 border border-border/50', card.iconBg, card.iconColor)}>
|
||||
<card.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-ink">{card.title}</h4>
|
||||
<p className="text-[11px] text-concrete leading-relaxed mt-1.5">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{card.fileInput && (
|
||||
<input type="file" accept=".json" onChange={handleImport} disabled={isImporting} className="hidden" id="import-file" />
|
||||
)}
|
||||
<button
|
||||
onClick={card.onAction}
|
||||
disabled={card.loading}
|
||||
className={cn(
|
||||
'mt-8 w-full py-3.5 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300',
|
||||
card.btnClass,
|
||||
'disabled:opacity-60 disabled:pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{card.loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <card.icon className="h-4 w-4" />}
|
||||
{card.loading ? card.loadingText : card.buttonText}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={handleExport} disabled={isExporting} className="mt-6 w-full">
|
||||
{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')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Import 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-emerald-500/10 flex items-center justify-center text-emerald-600 shrink-0">
|
||||
<Upload className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.import.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{t('dataManagement.import.description')}
|
||||
</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 className="bg-rose-50/50 dark:bg-rose-500/5 rounded-2xl border border-rose-200/50 dark:border-rose-500/20 p-8 mt-12">
|
||||
<div className="flex items-center gap-5 mb-8">
|
||||
<div className="p-3 bg-rose-500/10 rounded-2xl text-rose-600 dark:text-rose-400 border border-rose-500/20">
|
||||
<Trash2 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-destructive">{t('dataManagement.dangerZone')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('dataManagement.dangerZoneDescription')}</p>
|
||||
<h4 className="text-sm font-bold text-rose-600 dark:text-rose-400">{t('dataManagement.dangerZone')}</h4>
|
||||
<p className="text-[11px] text-concrete mt-0.5">{t('dataManagement.dangerZoneDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-6 bg-white/60 dark:bg-black/20 rounded-2xl border border-rose-200/30 dark:border-rose-500/10 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-destructive">{t('dataManagement.delete.title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('dataManagement.delete.description')}</p>
|
||||
<p className="text-[13px] font-bold text-ink">{t('dataManagement.delete.title')}</p>
|
||||
<p className="text-[11px] text-concrete">{t('dataManagement.delete.description')}</p>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={handleDeleteAll} disabled={isDeleting} className="shrink-0 w-full sm:w-auto">
|
||||
{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')}
|
||||
</Button>
|
||||
<button
|
||||
onClick={handleDeleteAll}
|
||||
disabled={isDeleting}
|
||||
className="shrink-0 px-6 py-3 rounded-2xl bg-rose-600 text-white text-[10px] font-bold uppercase tracking-[0.2em] shadow-xl shadow-rose-600/20 hover:scale-[1.02] active:scale-95 transition-all duration-300 disabled:opacity-60 disabled:pointer-events-none"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
{isDeleting ? t('dataManagement.deleting') : t('dataManagement.delete.button')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { toast } from 'sonner'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Globe, Bell } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { PageEntry } from '@/components/page-entry'
|
||||
|
||||
interface GeneralSettingsClientProps {
|
||||
initialSettings: {
|
||||
@@ -51,7 +53,7 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
await updateAISettings({ desktopNotifications: enabled })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
|
||||
const handleAutoSaveChange = async (enabled: boolean) => {
|
||||
setAutoSave(enabled)
|
||||
await updateAISettings({ autoSave: enabled })
|
||||
@@ -59,29 +61,32 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-12"
|
||||
>
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">
|
||||
{t('generalSettings.description')}
|
||||
</p>
|
||||
</h3>
|
||||
|
||||
{/* 2-column card grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Language 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">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||
<Globe className="h-5 w-5" />
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{t('settings.language')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.selectLanguage')}</p>
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-base font-bold text-ink">{t('settings.language')}</h4>
|
||||
<p className="text-[11px] text-concrete">{t('settings.selectLanguage')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-2">
|
||||
|
||||
<div className="relative group">
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => handleLanguageChange(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"
|
||||
className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-3.5 text-sm outline-none focus:ring-1 ring-brand-accent/20 appearance-none cursor-pointer transition-all hover:bg-white dark:hover:bg-black/60 text-ink font-medium"
|
||||
>
|
||||
<option value="auto">{t('profile.autoDetect')}</option>
|
||||
<option value="en">English</option>
|
||||
@@ -100,75 +105,76 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
<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 className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none opacity-40 text-concrete">
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none">
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications 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">
|
||||
<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" />
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">
|
||||
<Bell size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{t('settings.notifications')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.notificationsDesc')}</p>
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-base font-bold text-ink">{t('settings.notifications')}</h4>
|
||||
<p className="text-[11px] text-concrete">{t('settings.notificationsDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-5">
|
||||
{/* Email toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{t('settings.emailNotifications')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.emailNotificationsDesc')}</p>
|
||||
|
||||
<div className="space-y-6 divide-y divide-border/40 text-left">
|
||||
<div className="flex items-center justify-between pt-0">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-ink">{t('settings.emailNotifications')}</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">{t('settings.emailNotificationsDesc')}</p>
|
||||
</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>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={emailNotifications}
|
||||
onChange={(e) => handleEmailNotificationsChange(e.target.checked)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-ink" />
|
||||
</label>
|
||||
</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 className="flex items-center justify-between pt-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-ink">{t('settings.desktopNotifications')}</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">{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>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={desktopNotifications}
|
||||
onChange={(e) => handleDesktopNotificationsChange(e.target.checked)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-ink" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{t('settings.autoSave') || 'Auto-Save'}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.autoSaveDesc') || 'Sauvegarder automatiquement les modifications'}</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-ink">{t('settings.autoSave') || 'Auto-Save'}</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">{t('settings.autoSaveDesc') || 'Sauvegarder automatiquement les modifications'}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoSave}
|
||||
onClick={() => handleAutoSaveChange(!autoSave)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${autoSave ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSave ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={autoSave}
|
||||
onChange={(e) => handleAutoSaveChange(e.target.checked)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-ink" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,24 +8,20 @@ export default function SettingsLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#F2F0E9]">
|
||||
{/* Architectural header — matches Agents page */}
|
||||
<header className="flex flex-col px-12 pt-10 pb-0 border-b border-border/40 shrink-0">
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">
|
||||
Configuration & Préférences
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-dark-paper">
|
||||
<header className="px-12 pt-20 pb-16 space-y-12 shrink-0">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">
|
||||
Configuration & Préférences
|
||||
</p>
|
||||
</div>
|
||||
{/* Tab nav flush to the border-bottom of header */}
|
||||
<SettingsNav className="-mb-px" />
|
||||
|
||||
<SettingsNav />
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto px-12 py-10 space-y-8">
|
||||
{children}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { McpSettingsPanel } from '@/components/mcp/mcp-settings-panel'
|
||||
import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default async function McpSettingsPage() {
|
||||
const session = await auth()
|
||||
@@ -14,9 +15,9 @@ export default async function McpSettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">
|
||||
Gérez vos clés API et serveurs MCP connectés.
|
||||
</p>
|
||||
</h3>
|
||||
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,8 +2,6 @@ import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { ProfileForm } from './profile-form'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { ProfilePageHeader } from '@/components/profile-page-header'
|
||||
import { AISettingsLinkCard } from './ai-settings-link-card'
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const session = await auth()
|
||||
@@ -12,33 +10,18 @@ export default async function ProfilePage() {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Parallel queries
|
||||
const [user, aiSettings] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { name: true, email: true, role: true }
|
||||
}),
|
||||
prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
])
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { name: true, email: true, role: true, image: true }
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const userAISettings = {
|
||||
preferredLanguage: aiSettings?.preferredLanguage || 'auto',
|
||||
showRecentNotes: aiSettings?.showRecentNotes ?? false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<ProfilePageHeader />
|
||||
<ProfileForm user={user} userAISettings={userAISettings} />
|
||||
|
||||
{/* AI Settings Link */}
|
||||
<AISettingsLinkCard />
|
||||
<ProfileForm user={user} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,91 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { updateProfile, changePassword } from '@/app/actions/profile'
|
||||
import { updateUserSettings } from '@/app/actions/user-settings'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { User, Lock } from 'lucide-react'
|
||||
import { User, Mail, Shield, LogOut, Camera, Bell } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
|
||||
export function ProfileForm({ user, userAISettings }: { user: any; userAISettings?: any }) {
|
||||
export function ProfileForm({ user }: { user: { name: string | null; email: string; image: string | null } }) {
|
||||
const { t } = useLanguage()
|
||||
const [desktopNotif, setDesktopNotif] = useState(false)
|
||||
|
||||
const initial = (user.name || user.email || 'U')[0].toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t('profile.description')}
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||
<User className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{t('profile.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('profile.description')}</p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-12 max-w-2xl"
|
||||
>
|
||||
<div className="space-y-8">
|
||||
{/* Avatar + Name */}
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="relative group">
|
||||
<div className="w-24 h-24 rounded-[32px] bg-brand-accent/10 border-2 border-brand-accent/20 flex items-center justify-center overflow-hidden">
|
||||
{user.image ? (
|
||||
<Avatar className="size-24 rounded-[32px]">
|
||||
<AvatarImage src={user.image} alt="" />
|
||||
<AvatarFallback className="text-2xl font-serif font-bold text-brand-accent">{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<User size={40} className="text-brand-accent" />
|
||||
)}
|
||||
</div>
|
||||
<button className="absolute -bottom-2 -right-2 p-2 bg-ink text-white rounded-xl shadow-lg border border-border opacity-0 group-hover:opacity-100 transition-all hover:scale-110">
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-2xl font-serif font-bold text-ink">{user.name || 'Utilisateur'}</h3>
|
||||
<p className="text-sm text-concrete font-light">{user.email}</p>
|
||||
</div>
|
||||
<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'))
|
||||
}} 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 className="space-y-1.5">
|
||||
<label htmlFor="email" className="text-sm font-medium text-foreground">{t('profile.email')}</label>
|
||||
<Input id="email" value={user.email} disabled className="bg-muted border-border opacity-60" />
|
||||
</div>
|
||||
<Button type="submit" className="w-full mt-2">{t('general.save')}</Button>
|
||||
</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>
|
||||
{/* Personal info */}
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] text-concrete opacity-60">
|
||||
{t('profile.description')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{/* Name edit */}
|
||||
<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'))
|
||||
}} className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<User size={18} className="text-concrete" />
|
||||
<div>
|
||||
<p className="text-[10px] uppercase font-bold text-concrete tracking-widest">{t('profile.displayName')}</p>
|
||||
<Input name="name" defaultValue={user.name || ''} className="border-0 bg-transparent p-0 h-auto text-sm text-ink focus:ring-0 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="text-[10px] font-bold text-brand-accent uppercase tracking-widest hover:underline">
|
||||
{t('general.save')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Email */}
|
||||
<div className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Mail size={18} className="text-concrete" />
|
||||
<div>
|
||||
<p className="text-[10px] uppercase font-bold text-concrete tracking-widest">Email</p>
|
||||
<p className="text-sm text-ink">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security / Password */}
|
||||
<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'))
|
||||
}
|
||||
}} className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Shield size={18} className="text-concrete" />
|
||||
<div>
|
||||
<p className="text-[10px] uppercase font-bold text-concrete tracking-widest">{t('profile.changePassword')}</p>
|
||||
<p className="text-sm text-ink">{t('profile.changePasswordDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 ms-10">
|
||||
<Input name="currentPassword" type="password" required placeholder={t('profile.currentPassword')} className="bg-white/50 dark:bg-black/20 border-border text-sm h-9" />
|
||||
<Input name="newPassword" type="password" required minLength={6} placeholder={t('profile.newPassword')} className="bg-white/50 dark:bg-black/20 border-border text-sm h-9" />
|
||||
<Input name="confirmPassword" type="password" required minLength={6} placeholder={t('profile.confirmPassword')} className="bg-white/50 dark:bg-black/20 border-border text-sm h-9" />
|
||||
</div>
|
||||
<div className="flex justify-end ms-10">
|
||||
<button type="submit" className="text-[10px] font-bold text-brand-accent uppercase tracking-widest hover:underline">
|
||||
{t('profile.updatePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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" />
|
||||
|
||||
{/* Preferences */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] text-concrete opacity-60">
|
||||
{t('profile.preferences') || 'Préférences de compte'}
|
||||
</h4>
|
||||
<div className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Bell size={18} className="text-concrete" />
|
||||
<div>
|
||||
<p className="text-sm text-ink">{t('profile.desktopNotifications') || 'Notification bureau'}</p>
|
||||
<p className="text-[10px] text-concrete font-light pe-4">{t('profile.desktopNotificationsDesc') || 'Recevez des alertes pour vos rappels et activités IA.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDesktopNotif(!desktopNotif)}
|
||||
className={`w-10 h-5 rounded-full relative p-0.5 cursor-pointer transition-all duration-300 ${desktopNotif ? 'bg-brand-accent' : 'bg-gray-200 dark:bg-white/10'}`}
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 bg-white rounded-full transition-all duration-300 ${desktopNotif ? 'translate-x-[18px]' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="pt-8 border-t border-border/40">
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
className="flex items-center gap-3 px-6 py-3 bg-rose-50 dark:bg-rose-500/10 text-rose-600 rounded-xl font-bold uppercase tracking-widest text-[10px] hover:bg-rose-100 transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
{t('sidebar.signOut') || 'Déconnexion'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user