feat: MVP-5 Sprint 4 - External Capture via Bookmarklet

- Add bookmarklet for capturing web content from any page
- Add capture confirmation page with edit before save
- Add secure /api/capture endpoint with rate limiting
- Add bookmarklet instructions component with drag-and-drop
This commit is contained in:
2026-03-22 18:35:53 -03:00
parent a40ab18b1b
commit 8d56f34d68
4 changed files with 377 additions and 0 deletions

View File

@@ -0,0 +1,132 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { generateBookmarklet } from '@/lib/external-capture'
import { Bookmark, Copy, Check, Info } from 'lucide-react'
export function BookmarkletInstructions() {
const [isOpen, setIsOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const bookmarkletCode = generateBookmarklet()
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(bookmarkletCode)
setCopied(true)
toast.success('Código copiado al portapapeles')
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error('Error al copiar el código')
}
}
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setData('text/plain', bookmarkletCode)
e.dataTransfer.effectAllowed = 'copy'
setIsDragging(true)
}
const handleDragEnd = () => {
setIsDragging(false)
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{isOpen && (
<div onClick={() => setIsOpen(true)}>
<Button variant="outline" size="sm" className="gap-2">
<Bookmark className="h-4 w-4" />
Capturar web
</Button>
</div>
)}
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bookmark className="h-5 w-5" />
Capturar desde web
</DialogTitle>
<DialogDescription>
Guarda contenido de cualquier página web directamente en tus notas.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-sm font-medium mb-2">Instrucciones:</p>
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
<li>Arrastra el botón de abajo a tu barra de marcadores</li>
<li>Cuando quieras capturar algo, haz clic en el marcador</li>
<li>Se abrirá una página para confirmar y guardar</li>
</ol>
</div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">Botón del marcador:</p>
</div>
<div className="flex items-center justify-center p-4 bg-muted/30 rounded-lg border-2 border-dashed border-muted">
<button
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onClick={(e) => e.preventDefault()}
className={`px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium cursor-grab active:cursor-grabbing transition-all ${
isDragging ? 'opacity-50 scale-95' : ''
}`}
title="Arrastra esto a tu barra de marcadores"
>
Capturar a Recall
</button>
</div>
<p className="text-xs text-muted-foreground text-center">
No puedes arrastrar? Usa el botón copiar y crea un marcador manualmente.
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 gap-2"
onClick={handleCopy}
>
{copied ? (
<>
<Check className="h-4 w-4" />
Copiado
</>
) : (
<>
<Copy className="h-4 w-4" />
Copiar código
</>
)}
</Button>
</div>
<div className="bg-muted/30 rounded-lg p-3">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<p className="text-xs text-muted-foreground">
El marcador capturará el título de la página, la URL y cualquier texto que hayas seleccionado antes de hacer clic.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}