Files
Momento/memento-note/app/(main)/settings/data/page.tsx
Antigravity 77f69fc1d1 fix(i18n): wrap CookieConsentRoot with LanguageProvider
The CookieConsentBanner uses useLanguage() hook but was rendered
outside of LanguageProvider in RootLayout. Added LanguageProvider
wrapper to fix the runtime error.
2026-05-23 09:27:29 +00:00

292 lines
12 KiB
TypeScript

'use client'
import { useState } from 'react'
import { Download, Upload, Trash2, Loader2, RefreshCw, Sparkles, Database, ShieldAlert } 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'
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 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-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 (
<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')}
</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>
)
}