The CookieConsentBanner uses useLanguage() hook but was rendered outside of LanguageProvider in RootLayout. Added LanguageProvider wrapper to fix the runtime error.
292 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|