- Desktop: single row with logo, nav links, QuickAdd, New note button - Mobile: logo + QuickAdd + hamburger icon → dropdown menu - Improved QuickAdd sizing for small screens Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
272 lines
8.5 KiB
TypeScript
272 lines
8.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 { cn } from '@/lib/utils'
|
|
import { Plus, Loader2, Text, Sparkles, X } 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 [isExpanded, setIsExpanded] = 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 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) => {
|
|
// Let the paste happen first
|
|
setDismissedSuggestion(false)
|
|
setTimeout(() => {
|
|
detectContentType(value)
|
|
}, 0)
|
|
}
|
|
|
|
const acceptSuggestion = () => {
|
|
if (typeSuggestion) {
|
|
setValue(typeSuggestion.formattedContent)
|
|
setTypeSuggestion(null)
|
|
setIsMultiline(true)
|
|
setIsExpanded(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('')
|
|
setIsExpanded(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('')
|
|
setIsExpanded(false)
|
|
setIsMultiline(false)
|
|
inputRef.current?.blur()
|
|
textareaRef.current?.blur()
|
|
}
|
|
}
|
|
|
|
const toggleMultiline = () => {
|
|
setIsMultiline(!isMultiline)
|
|
if (!isMultiline) {
|
|
setIsExpanded(true)
|
|
setTimeout(() => textareaRef.current?.focus(), 0)
|
|
}
|
|
}
|
|
|
|
// Focus on keyboard shortcut
|
|
useEffect(() => {
|
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
|
// Ctrl+N or Cmd+N to focus quick add
|
|
if ((e.key === 'n' && (e.metaKey || e.ctrlKey)) || (e.key === 'n' && e.altKey)) {
|
|
e.preventDefault()
|
|
inputRef.current?.focus()
|
|
inputRef.current?.select()
|
|
setIsExpanded(true)
|
|
}
|
|
// Escape to blur
|
|
if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
|
inputRef.current?.blur()
|
|
setIsExpanded(false)
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handleGlobalKeyDown)
|
|
return () => window.removeEventListener('keydown', handleGlobalKeyDown)
|
|
}, [])
|
|
|
|
return (
|
|
<div className="relative">
|
|
<form onSubmit={handleSubmit} className="flex items-end gap-2">
|
|
<div className="relative flex-1">
|
|
{isMultiline ? (
|
|
<Textarea
|
|
ref={textareaRef}
|
|
placeholder="cmd: título #tag Contenido multilínea..."
|
|
value={value}
|
|
onChange={(e) => {
|
|
setValue(e.target.value)
|
|
detectContentType(e.target.value)
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={() => setIsExpanded(true)}
|
|
onPaste={handlePaste}
|
|
className={cn(
|
|
'min-h-[80px] max-h-[200px] transition-all duration-200 resize-none',
|
|
isExpanded && 'w-full'
|
|
)}
|
|
disabled={isLoading}
|
|
rows={isExpanded ? 4 : 2}
|
|
/>
|
|
) : (
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder="cmd: título #tag..."
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={() => setIsExpanded(true)}
|
|
onPaste={handlePaste}
|
|
className={cn(
|
|
'w-24 xs:w-32 sm:w-48 transition-all duration-200',
|
|
isExpanded && 'w-40 xs:w-48 sm:w-72'
|
|
)}
|
|
disabled={isLoading}
|
|
/>
|
|
)}
|
|
{isLoading && (
|
|
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={toggleMultiline}
|
|
className={cn(
|
|
'inline-flex items-center justify-center rounded-lg border bg-background p-1.5 sm:p-2',
|
|
'hover:bg-accent hover:text-accent-foreground',
|
|
'transition-colors',
|
|
isMultiline && 'bg-accent text-accent-foreground'
|
|
)}
|
|
title={isMultiline ? 'Modo línea' : 'Modo multilínea'}
|
|
>
|
|
<Text className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={!value.trim() || isLoading}
|
|
className={cn(
|
|
'inline-flex items-center justify-center rounded-lg border bg-background p-1.5 sm:p-2',
|
|
'hover:bg-accent hover:text-accent-foreground',
|
|
'disabled:pointer-events-none disabled:opacity-50',
|
|
'transition-colors'
|
|
)}
|
|
>
|
|
{isLoading ? (
|
|
<Loader2 className="h-3 w-3 sm:h-4 sm:w-4 animate-spin" />
|
|
) : (
|
|
<Plus className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
)}
|
|
</button>
|
|
</form>
|
|
|
|
{/* Smart paste suggestion */}
|
|
{typeSuggestion && (
|
|
<div className="absolute top-full left-0 right-0 mt-2 p-3 bg-popover border rounded-lg shadow-md z-50">
|
|
<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>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|