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
This commit is contained in:
2026-03-23 00:03:45 -03:00
parent 33a4705f95
commit 0a96638681
7 changed files with 186 additions and 122 deletions

View File

@@ -52,7 +52,7 @@ function CaptureForm() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
text: `rec: ${title || url}\n\n${fullContent}`, text: `web: ${title || url}\n\n${fullContent}`,
}), }),
}) })

View File

@@ -44,13 +44,16 @@ export function BookmarkletInstructions() {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
{isOpen && ( {!isOpen && (
<div onClick={() => setIsOpen(true)}> <Button
<Button variant="outline" size="sm" className="gap-2"> variant="outline"
size="sm"
className="gap-2"
onClick={() => setIsOpen(true)}
>
<Bookmark className="h-4 w-4" /> <Bookmark className="h-4 w-4" />
Capturar web Capturar web
</Button> </Button>
</div>
)} )}
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>

View File

@@ -1,16 +1,30 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Plus, FileText, Settings, Menu, X } from 'lucide-react' import { Plus, FileText, Settings, Menu, X } from 'lucide-react'
import { QuickAdd } from '@/components/quick-add' import { QuickAdd } from '@/components/quick-add'
import { WorkModeToggle } from '@/components/work-mode-toggle' import { WorkModeToggle } from '@/components/work-mode-toggle'
import { BookmarkletInstructions } from '@/components/bookmarklet-instructions'
import { isWorkModeEnabled } from '@/lib/preferences'
export function Header() { export function Header() {
const pathname = usePathname() const pathname = usePathname()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [workModeToggleVisible, setWorkModeToggleVisible] = useState(true)
useEffect(() => {
setWorkModeToggleVisible(isWorkModeEnabled())
const handlePreferencesChange = () => {
setWorkModeToggleVisible(isWorkModeEnabled())
}
window.addEventListener('preferences-updated', handlePreferencesChange)
return () => window.removeEventListener('preferences-updated', handlePreferencesChange)
}, [])
return ( return (
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@@ -44,7 +58,8 @@ export function Header() {
</nav> </nav>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<QuickAdd /> <QuickAdd />
<WorkModeToggle /> <BookmarkletInstructions />
{workModeToggleVisible && <WorkModeToggle />}
<Link href="/new"> <Link href="/new">
<Button size="sm" className="gap-1.5"> <Button size="sm" className="gap-1.5">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@@ -59,9 +74,8 @@ export function Header() {
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
<span className="text-lg font-bold">Recall</span> <span className="text-lg font-bold">Recall</span>
</Link> </Link>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<QuickAdd /> <QuickAdd />
<WorkModeToggle />
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -102,13 +116,20 @@ export function Header() {
</Button> </Button>
</Link> </Link>
</nav> </nav>
<div className="border-t pt-2"> <div className="border-t pt-2 flex flex-col gap-1">
<Link href="/new" onClick={() => setMobileMenuOpen(false)}> <Link href="/new" onClick={() => setMobileMenuOpen(false)}>
<Button size="sm" className="w-full justify-start gap-2"> <Button size="sm" className="w-full justify-start gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Nueva nota Nueva nota
</Button> </Button>
</Link> </Link>
<BookmarkletInstructions />
{workModeToggleVisible && (
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-sm">Modo trabajo</span>
<WorkModeToggle />
</div>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -5,6 +5,7 @@ import { FeatureFlags, getFeatureFlags, setFeatureFlags } from '@/lib/preference
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { BookmarkletInstructions } from '@/components/bookmarklet-instructions'
export function PreferencesPanel() { export function PreferencesPanel() {
const [flags, setFlags] = useState<FeatureFlags>({ const [flags, setFlags] = useState<FeatureFlags>({
@@ -27,6 +28,8 @@ export function PreferencesPanel() {
const handleWorkModeEnabled = (enabled: boolean) => { const handleWorkModeEnabled = (enabled: boolean) => {
setFeatureFlags({ workModeEnabled: enabled }) setFeatureFlags({ workModeEnabled: enabled })
setFlags(getFeatureFlags()) setFlags(getFeatureFlags())
// Dispatch custom event to notify other components (like Header)
window.dispatchEvent(new CustomEvent('preferences-updated'))
} }
const handleRetentionChange = (value: string) => { const handleRetentionChange = (value: string) => {
@@ -97,11 +100,14 @@ export function PreferencesPanel() {
</div> </div>
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<p className="text-sm font-medium mb-3">Integración externa</p>
<BookmarkletInstructions />
</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Badge variant="outline">Sprint MVP-5</Badge> <Badge variant="outline">Sprint MVP-5</Badge>
<Badge variant="outline">v0.1.0</Badge> <Badge variant="outline">v0.1.0</Badge>
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -5,8 +5,9 @@ import { useRouter } from 'next/navigation'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Plus, Loader2, Text, Sparkles, X } from 'lucide-react' import { Plus, Loader2, Sparkles, X, Text, ChevronDown } from 'lucide-react'
import { inferNoteType, formatContentForType } from '@/lib/type-inference' import { inferNoteType, formatContentForType } from '@/lib/type-inference'
import { NoteType } from '@/types/note' import { NoteType } from '@/types/note'
@@ -30,12 +31,13 @@ const TYPE_LABELS: Record<NoteType, string> = {
export function QuickAdd() { export function QuickAdd() {
const [value, setValue] = useState('') const [value, setValue] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isExpanded, setIsExpanded] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [isMultiline, setIsMultiline] = useState(false) const [isMultiline, setIsMultiline] = useState(false)
const [typeSuggestion, setTypeSuggestion] = useState<TypeSuggestion | null>(null) const [typeSuggestion, setTypeSuggestion] = useState<TypeSuggestion | null>(null)
const [dismissedSuggestion, setDismissedSuggestion] = useState(false) const [dismissedSuggestion, setDismissedSuggestion] = useState(false)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const popupRef = useRef<HTMLDivElement>(null)
const router = useRouter() const router = useRouter()
const detectContentType = useCallback((text: string) => { const detectContentType = useCallback((text: string) => {
@@ -58,7 +60,6 @@ export function QuickAdd() {
}, [dismissedSuggestion]) }, [dismissedSuggestion])
const handlePaste = (e: React.ClipboardEvent) => { const handlePaste = (e: React.ClipboardEvent) => {
// Let the paste happen first
setDismissedSuggestion(false) setDismissedSuggestion(false)
setTimeout(() => { setTimeout(() => {
detectContentType(value) detectContentType(value)
@@ -70,7 +71,7 @@ export function QuickAdd() {
setValue(typeSuggestion.formattedContent) setValue(typeSuggestion.formattedContent)
setTypeSuggestion(null) setTypeSuggestion(null)
setIsMultiline(true) setIsMultiline(true)
setIsExpanded(true) setIsOpen(true)
} }
} }
@@ -101,7 +102,8 @@ export function QuickAdd() {
description: note.title, description: note.title,
}) })
setValue('') setValue('')
setIsExpanded(false) setIsOpen(false)
setIsMultiline(false)
router.refresh() router.refresh()
} catch (error) { } catch (error) {
toast.error('Error', { toast.error('Error', {
@@ -119,7 +121,7 @@ export function QuickAdd() {
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
setValue('') setValue('')
setIsExpanded(false) setIsOpen(false)
setIsMultiline(false) setIsMultiline(false)
inputRef.current?.blur() inputRef.current?.blur()
textareaRef.current?.blur() textareaRef.current?.blur()
@@ -129,25 +131,39 @@ export function QuickAdd() {
const toggleMultiline = () => { const toggleMultiline = () => {
setIsMultiline(!isMultiline) setIsMultiline(!isMultiline)
if (!isMultiline) { if (!isMultiline) {
setIsExpanded(true)
setTimeout(() => textareaRef.current?.focus(), 0) 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 // Focus on keyboard shortcut
useEffect(() => { useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => { 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)) { if ((e.key === 'n' && (e.metaKey || e.ctrlKey)) || (e.key === 'n' && e.altKey)) {
e.preventDefault() e.preventDefault()
inputRef.current?.focus() inputRef.current?.focus()
inputRef.current?.select() inputRef.current?.select()
setIsExpanded(true) setIsOpen(true)
} }
// Escape to blur
if (e.key === 'Escape' && document.activeElement === inputRef.current) { if (e.key === 'Escape' && document.activeElement === inputRef.current) {
inputRef.current?.blur() inputRef.current?.blur()
setIsExpanded(false) setIsOpen(false)
} }
} }
window.addEventListener('keydown', handleGlobalKeyDown) window.addEventListener('keydown', handleGlobalKeyDown)
@@ -155,83 +171,87 @@ export function QuickAdd() {
}, []) }, [])
return ( return (
<div className="relative" ref={popupRef}>
{/* Compact input row */}
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
<div className="relative"> <div className="relative">
<form onSubmit={handleSubmit} className="flex items-end gap-2"> <Input
<div className="relative flex-1"> 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 ? ( {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 <Textarea
ref={textareaRef} ref={textareaRef}
placeholder="cmd: título #tag&#10;&#10;Contenido multilínea..." placeholder="Contenido multilínea..."
value={value} value={value}
onChange={(e) => { onChange={(e) => {
setValue(e.target.value) setValue(e.target.value)
detectContentType(e.target.value) detectContentType(e.target.value)
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={() => setIsExpanded(true)}
onPaste={handlePaste} onPaste={handlePaste}
className={cn( className="min-h-[100px] max-h-[200px] resize-none w-full"
'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} 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 */} {/* Smart paste suggestion */}
{typeSuggestion && ( {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="mt-2 p-2 bg-muted/50 rounded-lg border">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<Sparkles className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" /> <Sparkles className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -266,6 +286,15 @@ export function QuickAdd() {
</div> </div>
</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> </div>
) )
} }

View File

@@ -51,12 +51,16 @@ export function encodeCapturePayload(payload: CapturePayload): string {
} }
export function generateBookmarklet(): string { export function generateBookmarklet(): string {
// Get the current origin (where the app is running)
const origin = typeof window !== 'undefined' ? window.location.origin : ''
const code = ` const code = `
var title = document.title; var title = document.title;
var url = location.href; var url = location.href;
var selection = window.getSelection().toString(); var selection = window.getSelection().toString();
var params = new URLSearchParams({title, url, selection}); var params = new URLSearchParams({title, url, selection});
window.open('/capture?' + params.toString(), '_blank'); var base = ${JSON.stringify(origin)};
window.open(base + '/capture?' + params.toString(), '_blank');
`.replace(/\s+/g, ' ').trim() `.replace(/\s+/g, ' ').trim()
return `javascript:${code}` return `javascript:${code}`
} }

View File

@@ -13,6 +13,7 @@ const TYPE_PREFIXES: Record<string, NoteType> = {
'rec:': 'recipe', 'rec:': 'recipe',
'proc:': 'procedure', 'proc:': 'procedure',
'inv:': 'inventory', 'inv:': 'inventory',
'web:': 'note',
} }
const TAG_REGEX = /#([a-z0-9]+)/g const TAG_REGEX = /#([a-z0-9]+)/g