Files
recall/src/components/quick-add.tsx
Daniel Arroyo 0a96638681 fix: Bookmarklet improvements and header layout fixes
- Fix bookmarklet URL to use absolute path with window.location.origin
- Change capture prefix from 'rec:' to 'web:' for web captures
- Add BookmarkletInstructions to header and preferences panel
- Redesign QuickAdd as dropdown popup (no header overflow)
- Move capture button and work mode to mobile menu
- Fix isOpen bug in BookmarkletInstructions dialog
2026-03-23 00:03:45 -03:00

301 lines
9.5 KiB
TypeScript

'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Plus, Loader2, Sparkles, X, Text, ChevronDown } from 'lucide-react'
import { inferNoteType, formatContentForType } from '@/lib/type-inference'
import { NoteType } from '@/types/note'
interface TypeSuggestion {
type: NoteType
confidence: 'high' | 'medium' | 'low'
reason: string
formattedContent: string
}
const TYPE_LABELS: Record<NoteType, string> = {
command: 'Comando',
snippet: 'Snippet',
procedure: 'Procedimiento',
recipe: 'Receta',
decision: 'Decisión',
inventory: 'Inventario',
note: 'Nota',
}
export function QuickAdd() {
const [value, setValue] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [isMultiline, setIsMultiline] = useState(false)
const [typeSuggestion, setTypeSuggestion] = useState<TypeSuggestion | null>(null)
const [dismissedSuggestion, setDismissedSuggestion] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const popupRef = useRef<HTMLDivElement>(null)
const router = useRouter()
const detectContentType = useCallback((text: string) => {
if (!text.trim() || text.length < 10) {
setTypeSuggestion(null)
return
}
if (dismissedSuggestion) return
const suggestion = inferNoteType(text)
if (suggestion && suggestion.confidence === 'high') {
const formatted = formatContentForType(text, suggestion.type)
setTypeSuggestion({
...suggestion,
formattedContent: formatted,
})
} else {
setTypeSuggestion(null)
}
}, [dismissedSuggestion])
const handlePaste = (e: React.ClipboardEvent) => {
setDismissedSuggestion(false)
setTimeout(() => {
detectContentType(value)
}, 0)
}
const acceptSuggestion = () => {
if (typeSuggestion) {
setValue(typeSuggestion.formattedContent)
setTypeSuggestion(null)
setIsMultiline(true)
setIsOpen(true)
}
}
const dismissSuggestion = () => {
setTypeSuggestion(null)
setDismissedSuggestion(true)
}
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!value.trim() || isLoading) return
setIsLoading(true)
try {
const response = await fetch('/api/notes/quick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: value }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Error creating note')
}
const note = await response.json()
toast.success('Nota creada', {
description: note.title,
})
setValue('')
setIsOpen(false)
setIsMultiline(false)
router.refresh()
} catch (error) {
toast.error('Error', {
description: error instanceof Error ? error.message : 'No se pudo crear la nota',
})
} finally {
setIsLoading(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && !isMultiline) {
e.preventDefault()
handleSubmit()
}
if (e.key === 'Escape') {
setValue('')
setIsOpen(false)
setIsMultiline(false)
inputRef.current?.blur()
textareaRef.current?.blur()
}
}
const toggleMultiline = () => {
setIsMultiline(!isMultiline)
if (!isMultiline) {
setTimeout(() => textareaRef.current?.focus(), 0)
}
}
const handleInputFocus = () => {
setIsOpen(true)
}
// Close popup when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
// Focus on keyboard shortcut
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if ((e.key === 'n' && (e.metaKey || e.ctrlKey)) || (e.key === 'n' && e.altKey)) {
e.preventDefault()
inputRef.current?.focus()
inputRef.current?.select()
setIsOpen(true)
}
if (e.key === 'Escape' && document.activeElement === inputRef.current) {
inputRef.current?.blur()
setIsOpen(false)
}
}
window.addEventListener('keydown', handleGlobalKeyDown)
return () => window.removeEventListener('keydown', handleGlobalKeyDown)
}, [])
return (
<div className="relative" ref={popupRef}>
{/* Compact input row */}
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
<div className="relative">
<Input
ref={inputRef}
type="text"
placeholder="cmd: título..."
value={value}
onChange={(e) => {
setValue(e.target.value)
detectContentType(e.target.value)
if (e.target.value) setIsOpen(true)
}}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onPaste={handlePaste}
className="w-full sm:w-80 h-9 pr-16"
disabled={isLoading}
/>
{isLoading && (
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
{/* Action buttons inside input */}
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
<button
type="button"
onClick={toggleMultiline}
className={cn(
'p-1 rounded hover:bg-accent transition-colors',
isMultiline && 'bg-accent text-accent-foreground'
)}
title={isMultiline ? 'Modo línea' : 'Modo multilínea'}
>
{isMultiline ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<Text className="h-3.5 w-3.5" />
)}
</button>
<button
type="submit"
disabled={!value.trim() || isLoading}
className={cn(
'p-1 rounded hover:bg-accent transition-colors',
'disabled:pointer-events-none disabled:opacity-30'
)}
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
</form>
{/* Expanded popup */}
{isOpen && (
<div className="absolute top-full left-0 right-0 mt-2 p-3 bg-popover border rounded-lg shadow-lg z-50">
{/* Multiline textarea (shown when multiline mode) */}
{isMultiline && (
<Textarea
ref={textareaRef}
placeholder="Contenido multilínea..."
value={value}
onChange={(e) => {
setValue(e.target.value)
detectContentType(e.target.value)
}}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className="min-h-[100px] max-h-[200px] resize-none w-full"
disabled={isLoading}
/>
)}
{/* Smart paste suggestion */}
{typeSuggestion && (
<div className="mt-2 p-2 bg-muted/50 rounded-lg border">
<div className="flex items-start gap-2">
<Sparkles className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
Detectado: <span className="text-primary">{TYPE_LABELS[typeSuggestion.type]}</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">{typeSuggestion.reason}</p>
</div>
<button
type="button"
onClick={dismissSuggestion}
className="p-1 hover:bg-accent rounded"
>
<X className="h-3 w-3" />
</button>
</div>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={acceptSuggestion}
className="text-xs px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90"
>
Usar plantilla
</button>
<button
type="button"
onClick={dismissSuggestion}
className="text-xs px-2 py-1 text-muted-foreground hover:bg-accent rounded"
>
Descartar
</button>
</div>
</div>
)}
{/* Help text */}
{!value && !typeSuggestion && (
<div className="text-xs text-muted-foreground">
Usa prefijos como <span className="font-mono bg-muted px-1 rounded">cmd:</span>, <span className="font-mono bg-muted px-1 rounded">snip:</span> para тип notes
</div>
)}
</div>
)}
</div>
)
}