Files
recall/src/app/settings/page.tsx
T
darroyo e66a678160 feat: MVP-5 P2 - Export/Import, Settings, Tests y Validaciones
- Ticket 10: Navegación completa de listas por teclado (↑↓ Enter E F P)
- Ticket 13: Historial de navegación contextual con recent-context-list
- Ticket 17: Exportación mejorada a Markdown con frontmatter
- Ticket 18: Exportación HTML simple y legible
- Ticket 19: Importador Markdown mejorado con frontmatter, tags, wiki links
- Ticket 20: Importador Obsidian-compatible (wiki links, #tags inline)
- Ticket 21: Centro de respaldo y portabilidad en Settings
- Ticket 22: Configuración visible de feature flags
- Ticket 24: Tests de command palette y captura externa
- Ticket 25: Harden de validaciones y límites (50MB backup, 10K notas, etc)
2026-03-22 19:39:55 -03:00

239 lines
7.9 KiB
TypeScript

'use client'
import { useState, useRef } from 'react'
import { Download, Upload, History, FileText, Code, FolderOpen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner'
import { BackupList } from '@/components/backup-list'
import { PreferencesPanel } from '@/components/preferences-panel'
function parseMarkdownToNote(content: string, filename: string) {
const lines = content.split('\n')
let title = filename.replace(/\.md$/, '')
let body = content
const firstHeadingMatch = content.match(/^#\s+(.+)$/m)
if (firstHeadingMatch) {
title = firstHeadingMatch[1].trim()
const headingIndex = content.indexOf(firstHeadingMatch[0])
body = content.slice(headingIndex + firstHeadingMatch[0].length).trim()
}
return {
title,
content: body,
type: 'note',
}
}
export default function SettingsPage() {
const [importing, setImporting] = useState(false)
const [exporting, setExporting] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleExport = async (format: 'json' | 'markdown' | 'html') => {
setExporting(format)
try {
const response = await fetch(`/api/export-import?format=${format}`)
if (!response.ok) {
throw new Error('Error al exportar')
}
const data = await response.json()
let blob: Blob
let filename: string
const date = new Date().toISOString().split('T')[0]
if (format === 'json') {
blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
filename = `recall-backup-${date}.json`
} else if (format === 'markdown') {
if (data.files) {
// Multiple files - in the future could be a zip
blob = new Blob([data.files.map((f: { content: string }) => f.content).join('\n\n---\n\n')], { type: 'text/markdown' })
} else {
blob = new Blob([data.content], { type: 'text/markdown' })
}
filename = `recall-export-${date}.md`
} else {
if (data.files) {
blob = new Blob([data.files.map((f: { content: string }) => f.content).join('\n\n')], { type: 'text/html' })
} else {
blob = new Blob([data.content], { type: 'text/html' })
}
filename = `recall-export-${date}.html`
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success(`Notas exportadas en formato ${format.toUpperCase()}`)
} catch {
toast.error('Error al exportar las notas')
} finally {
setExporting(null)
}
}
const handleImport = async () => {
const file = fileInputRef.current?.files?.[0]
if (!file) {
toast.error('Selecciona un archivo JSON o MD')
return
}
setImporting(true)
try {
const text = await file.text()
const isMarkdown = file.name.endsWith('.md')
let payload: object[]
let endpoint = '/api/export-import'
if (isMarkdown) {
const note = parseMarkdownToNote(text, file.name)
payload = [{ markdown: text, filename: file.name }]
endpoint = '/api/import-markdown'
} else {
payload = JSON.parse(text)
}
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Error al importar')
}
const msg = result.warnings
? `${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente (con advertencias)`
: `${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente`
toast.success(msg)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Error al importar las notas')
} finally {
setImporting(false)
}
}
return (
<main className="container mx-auto py-8 px-4">
<h1 className="text-2xl font-bold mb-6">Configuración</h1>
<div className="grid gap-6 max-w-2xl">
{/* Preferences Section */}
<PreferencesPanel />
{/* Backups Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
Backups y Restauración
</CardTitle>
<CardDescription>
Los backups automáticos se guardan localmente. También puedes crear un backup manual antes de operaciones riesgosas.
</CardDescription>
</CardHeader>
<CardContent>
<BackupList />
</CardContent>
</Card>
{/* Export Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" />
Exportar Notas
</CardTitle>
<CardDescription>
Descarga tus notas en diferentes formatos. Elige el que mejor se adapte a tus necesidades.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<Button
onClick={() => handleExport('json')}
disabled={exporting !== null}
variant="outline"
size="sm"
className="gap-2"
>
<FolderOpen className="h-4 w-4" />
{exporting === 'json' ? 'Exportando...' : 'JSON (Backup completo)'}
</Button>
<Button
onClick={() => handleExport('markdown')}
disabled={exporting !== null}
variant="outline"
size="sm"
className="gap-2"
>
<FileText className="h-4 w-4" />
{exporting === 'markdown' ? 'Exportando...' : 'Markdown'}
</Button>
<Button
onClick={() => handleExport('html')}
disabled={exporting !== null}
variant="outline"
size="sm"
className="gap-2"
>
<Code className="h-4 w-4" />
{exporting === 'html' ? 'Exportando...' : 'HTML'}
</Button>
</div>
</CardContent>
</Card>
{/* Import Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Importar Notas
</CardTitle>
<CardDescription>
Importa notas desde archivos JSON o Markdown. Soporta frontmatter, tags, y enlaces wiki.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<input
ref={fileInputRef}
type="file"
accept=".json,.md"
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border file:border-input file:text-sm file:font-medium file:bg-background hover:file:bg-muted"
/>
<Button
onClick={handleImport}
disabled={importing}
variant="outline"
className="gap-2 self-start"
>
<Upload className="h-4 w-4" />
{importing ? 'Importando...' : 'Importar'}
</Button>
</CardContent>
</Card>
</div>
</main>
)
}