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)
This commit is contained in:
2026-03-22 19:39:55 -03:00
parent 8d56f34d68
commit e66a678160
24 changed files with 1286 additions and 42 deletions

View File

@@ -0,0 +1,9 @@
'use client'
export function KeyboardHint() {
return (
<div className="text-xs text-muted-foreground text-center py-2 border-t">
navegar · Enter abrir · E editar · F favoritar · P fijar
</div>
)
}

View File

@@ -0,0 +1,86 @@
'use client'
import { useCallback } from 'react'
import { Note } from '@/types/note'
import { NoteCard } from './note-card'
import { useNoteListKeyboard } from '@/hooks/use-note-list-keyboard'
import { toast } from 'sonner'
interface KeyboardNavigableNoteListProps {
notes: Note[]
onEdit?: (noteId: string) => void
}
export function KeyboardNavigableNoteList({
notes,
onEdit,
}: KeyboardNavigableNoteListProps) {
const handleFavorite = useCallback(async (noteId: string) => {
try {
const res = await fetch(`/api/notes/${noteId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isFavorite: true }),
})
if (res.ok) {
toast.success('Añadido a favoritos')
window.location.reload()
}
} catch {
toast.error('Error al añadir a favoritos')
}
}, [])
const handlePin = useCallback(async (noteId: string) => {
try {
const res = await fetch(`/api/notes/${noteId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isPinned: true }),
})
if (res.ok) {
toast.success('Nota fijada')
window.location.reload()
}
} catch {
toast.error('Error al fijar nota')
}
}, [])
const { selectedIndex } = useNoteListKeyboard({
notes,
onEdit,
onFavorite: handleFavorite,
onPin: handlePin,
})
if (notes.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
<p className="text-lg">No hay notas todavía</p>
<p className="text-sm">Crea tu primera nota para comenzar</p>
</div>
)
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{notes.map((note, index) => (
<div
key={note.id}
className={`relative ${index === selectedIndex ? 'ring-2 ring-primary ring-offset-2 rounded-lg' : ''}`}
data-selected={index === selectedIndex}
>
<NoteCard note={note} />
{index === selectedIndex && (
<div className="absolute bottom-2 right-2 flex gap-1">
<span className="px-1.5 py-0.5 bg-muted text-xs rounded text-muted-foreground">
Enter: abrir | E: editar | F: favoritar | P: fijar
</span>
</div>
)}
</div>
))}
</div>
)
}

View File

@@ -4,7 +4,8 @@ import Link from 'next/link'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ArrowRight, Link2, RefreshCw, ExternalLink, Users, ChevronDown, ChevronRight, History } from 'lucide-react'
import { ArrowRight, Link2, RefreshCw, ExternalLink, Users, ChevronDown, ChevronRight, History, Clock } from 'lucide-react'
import { getNavigationHistory, NavigationEntry } from '@/lib/navigation-history'
interface BacklinkInfo {
id: string
@@ -106,6 +107,7 @@ export function NoteConnections({
}: NoteConnectionsProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [recentVersions, setRecentVersions] = useState<{ id: string; version: number; createdAt: string }[]>([])
const [navigationHistory, setNavigationHistory] = useState<NavigationEntry[]>([])
useEffect(() => {
fetch(`/api/notes/${noteId}/versions`)
@@ -114,6 +116,10 @@ export function NoteConnections({
.catch(() => setRecentVersions([]))
}, [noteId])
useEffect(() => {
setNavigationHistory(getNavigationHistory())
}, [noteId])
const hasAnyConnections =
backlinks.length > 0 || outgoingLinks.length > 0 || relatedNotes.length > 0 || coUsedNotes.length > 0
@@ -206,6 +212,22 @@ export function NoteConnections({
</div>
</div>
)}
{/* Navigation history */}
{navigationHistory.length > 0 && (
<ConnectionGroup
title="Vista recientemente"
icon={Clock}
notes={navigationHistory.slice(0, 5).map((entry) => ({
id: entry.noteId,
title: entry.title,
type: entry.type,
}))}
emptyMessage="No hay historial de navegación"
isCollapsed={collapsed['history']}
onToggle={() => toggleCollapsed('history')}
/>
)}
</CardContent>
</Card>
)

View File

@@ -0,0 +1,108 @@
'use client'
import { useState, useEffect } from 'react'
import { FeatureFlags, getFeatureFlags, setFeatureFlags } from '@/lib/preferences'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
export function PreferencesPanel() {
const [flags, setFlags] = useState<FeatureFlags>({
backupEnabled: true,
backupRetention: 30,
workModeEnabled: true,
})
const [retentionInput, setRetentionInput] = useState('30')
useEffect(() => {
setFlags(getFeatureFlags())
setRetentionInput(getFeatureFlags().backupRetention.toString())
}, [])
const handleBackupEnabled = (enabled: boolean) => {
setFeatureFlags({ backupEnabled: enabled })
setFlags(getFeatureFlags())
}
const handleWorkModeEnabled = (enabled: boolean) => {
setFeatureFlags({ workModeEnabled: enabled })
setFlags(getFeatureFlags())
}
const handleRetentionChange = (value: string) => {
setRetentionInput(value)
const days = parseInt(value, 10)
if (!isNaN(days) && days > 0) {
setFeatureFlags({ backupRetention: days })
setFlags(getFeatureFlags())
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Preferencias
</CardTitle>
<CardDescription>
Configura el comportamiento de la aplicación. Los cambios se guardan automáticamente.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="text-sm font-medium">Backup automático</p>
<p className="text-xs text-muted-foreground">
Crear backups automáticamente al cerrar o cambiar de nota
</p>
</div>
<Button
variant={flags.backupEnabled ? 'default' : 'outline'}
size="sm"
onClick={() => handleBackupEnabled(!flags.backupEnabled)}
>
{flags.backupEnabled ? 'Activado' : 'Desactivado'}
</Button>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Retención de backups (días)</p>
<p className="text-xs text-muted-foreground">
Los backups automáticos se eliminarán después de este período
</p>
<input
type="number"
min="1"
max="365"
value={retentionInput}
onChange={(e) => handleRetentionChange(e.target.value)}
className="w-24 px-3 py-1 border rounded-md text-sm"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="text-sm font-medium">Modo trabajo</p>
<p className="text-xs text-muted-foreground">
Habilitar toggle de modo trabajo en el header
</p>
</div>
<Button
variant={flags.workModeEnabled ? 'default' : 'outline'}
size="sm"
onClick={() => handleWorkModeEnabled(!flags.workModeEnabled)}
>
{flags.workModeEnabled ? 'Activado' : 'Desactivado'}
</Button>
</div>
<div className="pt-4 border-t">
<div className="flex flex-wrap gap-2">
<Badge variant="outline">Sprint MVP-5</Badge>
<Badge variant="outline">v0.1.0</Badge>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { NavigationEntry, getNavigationHistory } from '@/lib/navigation-history'
import { Clock } from 'lucide-react'
export function RecentContextList() {
const [history, setHistory] = useState<NavigationEntry[]>([])
useEffect(() => {
setHistory(getNavigationHistory())
}, [])
if (history.length === 0) {
return null
}
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Clock className="h-4 w-4" />
<span>Vista recientemente</span>
</div>
<div className="space-y-1">
{history.slice(0, 5).map((entry) => (
<Link
key={entry.noteId}
href={`/notes/${entry.noteId}`}
className="block px-2 py-1.5 text-sm rounded hover:bg-muted transition-colors"
>
<div className="truncate">{entry.title}</div>
<div className="text-xs text-muted-foreground">
{entry.type} · {formatRelativeTime(entry.visitedAt)}
</div>
</Link>
))}
</div>
</div>
)
}
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'ahora'
if (diffMins < 60) return `hace ${diffMins}m`
if (diffHours < 24) return `hace ${diffHours}h`
if (diffDays < 7) return `hace ${diffDays}d`
return date.toLocaleDateString()
}

View File

@@ -0,0 +1,18 @@
'use client'
import { useEffect } from 'react'
import { addToNavigationHistory } from '@/lib/navigation-history'
interface TrackNavigationHistoryProps {
noteId: string
title: string
type: string
}
export function TrackNavigationHistory({ noteId, title, type }: TrackNavigationHistoryProps) {
useEffect(() => {
addToNavigationHistory({ noteId, title, type })
}, [noteId, title, type])
return null
}