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:
9
src/components/keyboard-hint.tsx
Normal file
9
src/components/keyboard-hint.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
src/components/keyboard-navigable-note-list.tsx
Normal file
86
src/components/keyboard-navigable-note-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
108
src/components/preferences-panel.tsx
Normal file
108
src/components/preferences-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
src/components/recent-context-list.tsx
Normal file
56
src/components/recent-context-list.tsx
Normal 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()
|
||||
}
|
||||
18
src/components/track-navigation-history.tsx
Normal file
18
src/components/track-navigation-history.tsx
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user