Skip to content

Commit 9cce482

Browse files
committed
feat: auto-suggest on URL paste with selectable tag chips
- Fix enabled state reactivity bug in useContentSuggestion hook - Auto-trigger suggestions when pasting a URL - Show loading spinner and placeholder text while fetching - Add cancel button to abort in-flight requests - Display suggested tags as clickable chips instead of auto-applying - Add "Add all" and "Dismiss" options for suggested tags
1 parent 473109b commit 9cce482

2 files changed

Lines changed: 143 additions & 12 deletions

File tree

src/components/bookmarks/BookmarkForm.jsx

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ import { getAllTags } from '../../services/bookmarks'
88
import { useHotkeys } from '../../hooks/useHotkeys'
99
import { useContentSuggestion } from '../../hooks/useContentSuggestion'
1010

11+
// Helper to detect if a string looks like a URL
12+
function looksLikeUrl(str) {
13+
if (!str) return false
14+
const trimmed = str.trim()
15+
return /^https?:\/\//i.test(trimmed) || /^www\./i.test(trimmed)
16+
}
17+
1118
export function BookmarkForm({ isOpen, onClose, onSave, initialData = null }) {
1219
const isEditing = Boolean(initialData)
1320
const formRef = useRef(null)
@@ -23,8 +30,9 @@ export function BookmarkForm({ isOpen, onClose, onSave, initialData = null }) {
2330
const [errors, setErrors] = useState({})
2431
const [loading, setLoading] = useState(false)
2532
const [allTags, setAllTags] = useState([])
33+
const [suggestedTags, setSuggestedTags] = useState([]) // Tags from suggestions, not yet applied
2634

27-
const { suggestions, loading: suggesting, error: suggestError, suggest, clear: clearSuggestions, enabled: suggestionsEnabled } = useContentSuggestion()
35+
const { suggestions, loading: suggesting, error: suggestError, suggest, clear: clearSuggestions, cancel: cancelSuggestion, enabled: suggestionsEnabled } = useContentSuggestion()
2836

2937
useEffect(() => {
3038
if (initialData) {
@@ -45,6 +53,7 @@ export function BookmarkForm({ isOpen, onClose, onSave, initialData = null }) {
4553
})
4654
}
4755
setErrors({})
56+
setSuggestedTags([])
4857
clearSuggestions()
4958
}, [initialData, isOpen, clearSuggestions])
5059

@@ -58,16 +67,26 @@ export function BookmarkForm({ isOpen, onClose, onSave, initialData = null }) {
5867
}
5968
}, [isOpen])
6069

61-
// Apply suggestions to empty fields when they arrive
70+
// Apply suggestions to empty fields when they arrive (except tags - show those separately)
6271
useEffect(() => {
6372
if (!suggestions) return
6473
setFormData((prev) => ({
6574
...prev,
6675
title: prev.title || suggestions.title || prev.title,
6776
description: prev.description || suggestions.description || prev.description,
68-
tags: prev.tags.length > 0 ? prev.tags : suggestions.suggestedTags || prev.tags,
77+
// Don't auto-apply tags - let user pick from suggestedTags
6978
}))
70-
}, [suggestions])
79+
// Show suggested tags that aren't already in the form
80+
if (suggestions.suggestedTags?.length > 0) {
81+
setSuggestedTags((prev) => {
82+
// Filter out tags already in formData
83+
const newSuggested = suggestions.suggestedTags.filter(
84+
(tag) => !formData.tags.includes(tag)
85+
)
86+
return newSuggested
87+
})
88+
}
89+
}, [suggestions, formData.tags])
7190

7291
const submitForm = useCallback(() => {
7392
if (formRef.current && !loading) {
@@ -153,6 +172,43 @@ export function BookmarkForm({ isOpen, onClose, onSave, initialData = null }) {
153172
}
154173
}
155174

175+
// Auto-suggest when URL is pasted
176+
const handleUrlPaste = (e) => {
177+
const pastedText = e.clipboardData?.getData('text')
178+
if (pastedText && looksLikeUrl(pastedText) && suggestionsEnabled && !isEditing) {
179+
// Small delay to let the input update first
180+
setTimeout(() => {
181+
const url = pastedText.trim().startsWith('http')
182+
? pastedText.trim()
183+
: `https://${pastedText.trim()}`
184+
suggest(url)
185+
}, 100)
186+
}
187+
}
188+
189+
// Add a single suggested tag
190+
const addSuggestedTag = (tag) => {
191+
setFormData((prev) => ({
192+
...prev,
193+
tags: [...prev.tags, tag],
194+
}))
195+
setSuggestedTags((prev) => prev.filter((t) => t !== tag))
196+
}
197+
198+
// Add all suggested tags
199+
const addAllSuggestedTags = () => {
200+
setFormData((prev) => ({
201+
...prev,
202+
tags: [...prev.tags, ...suggestedTags],
203+
}))
204+
setSuggestedTags([])
205+
}
206+
207+
// Dismiss all suggested tags
208+
const dismissSuggestedTags = () => {
209+
setSuggestedTags([])
210+
}
211+
156212
return (
157213
<Modal
158214
isOpen={isOpen}
@@ -167,6 +223,7 @@ export function BookmarkForm({ isOpen, onClose, onSave, initialData = null }) {
167223
type="url"
168224
value={formData.url}
169225
onChange={(value) => updateField('url', value)}
226+
onPaste={handleUrlPaste}
170227
placeholder="https://example.com"
171228
required
172229
error={errors.url}
@@ -178,17 +235,24 @@ export function BookmarkForm({ isOpen, onClose, onSave, initialData = null }) {
178235
<Button
179236
type="button"
180237
variant="ghost"
181-
onClick={handleSuggest}
182-
disabled={loading || suggesting || !formData.url.trim()}
238+
onClick={suggesting ? cancelSuggestion : handleSuggest}
239+
disabled={loading || !formData.url.trim()}
183240
className="text-xs whitespace-nowrap"
184241
>
185-
{suggesting ? 'Fetching...' : 'Suggest'}
242+
{suggesting ? 'Cancel' : 'Suggest'}
186243
</Button>
187244
</div>
188245
)}
189246
</div>
190247

191-
{suggestError && (
248+
{suggesting && (
249+
<p className="text-xs text-muted-foreground -mt-2 mb-3 flex items-center gap-1.5">
250+
<span className="inline-block w-3 h-3 border-2 border-muted-foreground/30 border-t-muted-foreground rounded-full animate-spin" />
251+
Fetching suggestions...
252+
</p>
253+
)}
254+
255+
{suggestError && !suggesting && (
192256
<p className="text-xs text-muted-foreground -mt-2 mb-3">
193257
Could not fetch suggestions
194258
</p>
@@ -199,7 +263,7 @@ export function BookmarkForm({ isOpen, onClose, onSave, initialData = null }) {
199263
type="text"
200264
value={formData.title}
201265
onChange={(value) => updateField('title', value)}
202-
placeholder="Bookmark title"
266+
placeholder={suggesting ? 'Loading...' : 'Bookmark title'}
203267
required
204268
error={errors.title}
205269
disabled={loading}
@@ -233,13 +297,51 @@ export function BookmarkForm({ isOpen, onClose, onSave, initialData = null }) {
233297
{errors.tags}
234298
</p>
235299
)}
300+
301+
{suggestedTags.length > 0 && (
302+
<div className="mt-3 p-2.5 rounded-md bg-muted/50 border border-border/50">
303+
<div className="flex items-center justify-between mb-2">
304+
<span className="text-xs font-medium text-muted-foreground">Suggested tags</span>
305+
<div className="flex gap-1.5">
306+
<button
307+
type="button"
308+
onClick={addAllSuggestedTags}
309+
className="text-xs text-primary hover:underline"
310+
>
311+
Add all
312+
</button>
313+
<span className="text-muted-foreground/50">·</span>
314+
<button
315+
type="button"
316+
onClick={dismissSuggestedTags}
317+
className="text-xs text-muted-foreground hover:text-foreground"
318+
>
319+
Dismiss
320+
</button>
321+
</div>
322+
</div>
323+
<div className="flex flex-wrap gap-1">
324+
{suggestedTags.map((tag) => (
325+
<button
326+
key={tag}
327+
type="button"
328+
onClick={() => addSuggestedTag(tag)}
329+
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-md bg-background border border-border hover:border-primary hover:text-primary transition-colors cursor-pointer"
330+
>
331+
<span className="text-muted-foreground">+</span>
332+
{tag}
333+
</button>
334+
))}
335+
</div>
336+
</div>
337+
)}
236338
</div>
237339

238340
<TextArea
239341
label="Description"
240342
value={formData.description}
241343
onChange={(value) => updateField('description', value)}
242-
placeholder="Optional description..."
344+
placeholder={suggesting ? 'Loading...' : 'Optional description...'}
243345
rows={3}
244346
disabled={loading}
245347
/>

src/hooks/useContentSuggestion.js

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Provides loading state, suggestions data, and suggest action
44
*/
55

6-
import { useState, useCallback, useRef } from 'react'
6+
import { useState, useCallback, useRef, useEffect } from 'react'
77
import { fetchSuggestions, isSuggestionsEnabled } from '../services/content-suggestion'
88

99
/**
@@ -13,15 +13,43 @@ import { fetchSuggestions, isSuggestionsEnabled } from '../services/content-sugg
1313
* error: string | null,
1414
* suggest: (url: string) => Promise<void>,
1515
* clear: () => void,
16+
* cancel: () => void,
1617
* enabled: boolean,
1718
* }}
1819
*/
1920
export function useContentSuggestion() {
2021
const [suggestions, setSuggestions] = useState(null)
2122
const [loading, setLoading] = useState(false)
2223
const [error, setError] = useState(null)
24+
const [enabled, setEnabled] = useState(() => isSuggestionsEnabled())
2325
const abortRef = useRef(null)
2426

27+
// Re-check enabled state when localStorage changes (e.g., from settings)
28+
useEffect(() => {
29+
const handleStorage = (e) => {
30+
if (e.key === 'hypermark_suggestions_enabled') {
31+
setEnabled(isSuggestionsEnabled())
32+
}
33+
}
34+
window.addEventListener('storage', handleStorage)
35+
return () => window.removeEventListener('storage', handleStorage)
36+
}, [])
37+
38+
// Also check on mount/focus in case changed in same tab
39+
useEffect(() => {
40+
const handleFocus = () => setEnabled(isSuggestionsEnabled())
41+
window.addEventListener('focus', handleFocus)
42+
return () => window.removeEventListener('focus', handleFocus)
43+
}, [])
44+
45+
const cancel = useCallback(() => {
46+
if (abortRef.current) {
47+
abortRef.current.abort()
48+
abortRef.current = null
49+
}
50+
setLoading(false)
51+
}, [])
52+
2553
const suggest = useCallback(async (url) => {
2654
if (!url || !isSuggestionsEnabled()) return
2755

@@ -72,6 +100,7 @@ export function useContentSuggestion() {
72100
error,
73101
suggest,
74102
clear,
75-
enabled: isSuggestionsEnabled(),
103+
cancel,
104+
enabled,
76105
}
77106
}

0 commit comments

Comments
 (0)