- settings/layout: serif h1 title + uppercase tracking subtitle, matching Agents page - SettingsNav: uppercase tracking-wider tabs with foreground underline on active - All settings pages (general, ai, appearance, profile, mcp, about, data): remove duplicate h1 (now in layout header), replace with uppercase section label - notes.ts: decouple history guards from global userAISettings - note-document-info-panel: add 'Save version' button with loading feedback
231 lines
9.7 KiB
TypeScript
231 lines
9.7 KiB
TypeScript
'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'
|
|
|
|
export default function DataSettingsPage() {
|
|
const { t } = useLanguage()
|
|
const router = useRouter()
|
|
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)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
|
{t('dataManagement.toolsDescription')}
|
|
</p>
|
|
|
|
<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-blue-500/10 flex items-center justify-center text-blue-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>
|
|
</div>
|
|
</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>
|
|
<div>
|
|
<h3 className="text-xl font-bold text-destructive">{t('dataManagement.dangerZone')}</h3>
|
|
<p className="text-sm text-muted-foreground">{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="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>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|