Files
Momento/memento-note/app/(main)/settings/data/page.tsx
Antigravity 5f9b8a9a09
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m7s
CI / Deploy production (on server) (push) Successful in 24s
fix(ui): thème des réglages et derniers textes de la revue
Les boutons des pages Agents, MCP, Données, Intégrations et Généraux suivent la couleur choisie. Les libellés encore techniques (recherche par le sens, notes proches, connexion unique) sont en langage courant.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:42:28 +00:00

349 lines
14 KiB
TypeScript

'use client'
import { useState } from 'react'
import { Download, Upload, Trash2, Loader2, RefreshCw, Sparkles, Database, ShieldAlert, FolderArchive } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { useSession } from 'next-auth/react'
import { DeleteAccountDialog } from '@/components/legal/delete-account-dialog'
import { useRouter } from 'next/navigation'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
export default function DataSettingsPage() {
const { t } = useLanguage()
const router = useRouter()
const { data: session } = useSession()
const [deleteOpen, setDeleteOpen] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isReindexing, setIsReindexing] = useState(false)
const [isCleaningUp, setIsCleaningUp] = useState(false)
const [isZipExporting, setIsZipExporting] = useState(false)
const handleZipExport = async () => {
setIsZipExporting(true)
try {
const response = await fetch('/api/user/export')
if (!response.ok) {
let message = t('dataManagement.zipExport.failed')
try {
const body = (await response.json()) as { error?: string }
if (body.error) message = body.error
} catch {
/* non-JSON error body */
}
toast.error(message)
return
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `memento-workspace-export-${new Date().toISOString().split('T')[0]}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
toast.success(t('dataManagement.zipExport.success'))
} catch {
toast.error(t('dataManagement.zipExport.failed'))
} finally {
setIsZipExporting(false)
}
}
const handleExport = async () => {
setIsExporting(true)
try {
const response = await fetch('/api/notes/export')
if (response.ok) {
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `memento-export-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
toast.success(t('dataManagement.export.success'))
} else {
throw new Error()
}
} catch {
toast.error(t('dataManagement.export.failed'))
} finally {
setIsExporting(false)
}
}
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setIsImporting(true)
try {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/notes/import', { method: 'POST', body: formData })
if (response.ok) {
const result = await response.json()
toast.success(t('dataManagement.import.success', { count: result.count }))
router.refresh()
} else {
const error = await response.json()
throw new Error(error.error || 'Import failed')
}
} catch (err: any) {
toast.error(err.message || t('dataManagement.import.failed'))
} finally {
setIsImporting(false)
event.target.value = ''
}
}
const handleReindex = async () => {
setIsReindexing(true)
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)
try {
const response = await fetch('/api/notes/delete-all', { method: 'POST' })
if (response.ok) {
toast.success(t('dataManagement.delete.success'))
router.refresh()
} else {
throw new Error()
}
} catch {
toast.error(t('dataManagement.delete.failed'))
} finally {
setIsDeleting(false)
}
}
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-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
},
{
icon: FolderArchive,
iconColor: 'text-amber-600 dark:text-amber-400',
iconBg: 'bg-amber-500/10 dark:bg-amber-500/20',
title: t('dataManagement.zipExport.title'),
description: t('dataManagement.zipExport.description'),
loading: isZipExporting,
loadingText: t('dataManagement.zipExporting'),
buttonText: t('dataManagement.zipExport.button'),
onAction: handleZipExport,
btnClass: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
},
{
icon: Upload,
iconColor: 'text-primary/80 dark:text-primary',
iconBg: 'bg-primary/10 dark:bg-primary/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 (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-12"
>
<SettingsHelpBox
title="Import / Export — comment ça fonctionne ?"
steps={[
{ text: 'Export ZIP : télécharge toutes vos notes en fichiers Markdown + images + métadonnées. Format universel, lisible partout.' },
{ text: 'Export JSON : version structurée de vos données — utile pour migrer ou sauvegarder programmatiquement.' },
{ text: 'Import Markdown : importez un fichier .md existant directement dans Memento en tant que nouvelle note.' },
{ text: 'Ré-indexer : si la recherche sémantique ne trouve pas certaines notes, relancez l\'indexation pour recalculer les embeddings.' },
{ icon: '⚠️', text: 'Supprimer le compte efface définitivement toutes vos données. Pensez à exporter d\'abord.' },
]}
/>
<h3 className="text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">
{t('dataManagement.toolsDescription')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{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>
))}
</div>
<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>
<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-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="text-[13px] font-bold text-ink">{t('dataManagement.delete.title')}</p>
<p className="text-[11px] text-concrete">{t('dataManagement.delete.description')}</p>
</div>
<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 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-6">
<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">
<ShieldAlert size={20} />
</div>
<div>
<h4 className="text-sm font-bold text-rose-600 dark:text-rose-400">{t('account.deleteAccount.sectionTitle')}</h4>
<p className="text-[11px] text-concrete mt-0.5">{t('account.deleteAccount.sectionDescription')}</p>
</div>
</div>
<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="text-[13px] font-bold text-ink">{t('account.deleteAccount.dialogTitle')}</p>
<p className="text-[11px] text-concrete">{t('account.deleteAccount.sectionDescription')}</p>
</div>
<button
onClick={() => setDeleteOpen(true)}
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"
>
<div className="flex items-center justify-center gap-2">
<ShieldAlert className="h-4 w-4" />
{t('account.deleteAccount.buttonLabel')}
</div>
</button>
</div>
</div>
{session?.user?.email && (
<DeleteAccountDialog
userEmail={session.user.email}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
)}
</motion.div>
)
}