import React, { useState, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons' import IconButton from './IconButton' import { fetchReadableContent } from '../services/readerService' import { fetch as fetchOpenGraph } from 'fetch-opengraph' interface AddBookmarkModalProps { onClose: () => void onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise } // Helper to extract tags from OpenGraph data function extractTagsFromOgData(ogData: Record): string[] { const tags: string[] = [] // Extract keywords from OpenGraph data if (ogData.keywords && typeof ogData.keywords === 'string') { ogData.keywords.split(/[,;]/) .map((k: string) => k.trim().toLowerCase()) .filter((k: string) => k.length > 0 && k.length < 30) .forEach((k: string) => tags.push(k)) } // Extract article:tag from OpenGraph data if (ogData['article:tag']) { const articleTagValue = ogData['article:tag'] const articleTags = Array.isArray(articleTagValue) ? articleTagValue : [articleTagValue] articleTags.forEach((tag: unknown) => { if (typeof tag === 'string') { const cleanTag = tag.trim().toLowerCase() if (cleanTag && cleanTag.length < 30) { tags.push(cleanTag) } } }) } return Array.from(new Set(tags)).slice(0, 5) } const AddBookmarkModal: React.FC = ({ onClose, onSave }) => { const [url, setUrl] = useState('') const [title, setTitle] = useState('') const [description, setDescription] = useState('') const [tagsInput, setTagsInput] = useState('') const [isSaving, setIsSaving] = useState(false) const [isFetchingMetadata, setIsFetchingMetadata] = useState(false) const [error, setError] = useState(null) const fetchTimeoutRef = useRef(null) const lastFetchedUrlRef = useRef('') // Fetch metadata when URL changes useEffect(() => { // Clear any pending fetch if (fetchTimeoutRef.current) { clearTimeout(fetchTimeoutRef.current) } // Don't fetch if URL is empty or invalid if (!url.trim()) return // Validate URL format first let parsedUrl: URL try { parsedUrl = new URL(url.trim()) } catch { return // Invalid URL, don't fetch } // Skip if we've already fetched this URL const normalizedUrl = parsedUrl.toString() if (lastFetchedUrlRef.current === normalizedUrl) { return } // Debounce the fetch to avoid spamming the API fetchTimeoutRef.current = window.setTimeout(async () => { setIsFetchingMetadata(true) try { // Fetch both readable content and OpenGraph data in parallel const [content, ogData] = await Promise.all([ fetchReadableContent(normalizedUrl), fetchOpenGraph(normalizedUrl).catch(() => null) // Don't fail if OpenGraph fetch fails ]) lastFetchedUrlRef.current = normalizedUrl let extractedAnything = false // Extract title: prioritize og:title > twitter:title > content.title if (!title) { let extractedTitle = null if (ogData) { extractedTitle = ogData['og:title'] || ogData['twitter:title'] || ogData.title } // Fallback to content.title if no OpenGraph title found if (!extractedTitle) { extractedTitle = content.title } if (extractedTitle) { setTitle(extractedTitle) extractedAnything = true } } // Extract description: prioritize og:description > twitter:description > meta description if (!description && ogData) { const extractedDesc = ogData['og:description'] || ogData['twitter:description'] || ogData.description if (extractedDesc) { setDescription(extractedDesc) extractedAnything = true } } // Extract tags from keywords and article:tag (only if user hasn't modified tags) if (!tagsInput && ogData) { const extractedTags = extractTagsFromOgData(ogData) // Only add boris tag if we extracted something if (extractedAnything || extractedTags.length > 0) { const allTags = extractedTags.length > 0 ? ['boris', ...extractedTags] : ['boris'] setTagsInput(allTags.join(', ')) } } } catch (err) { console.warn('Failed to fetch metadata:', err) // Don't show error to user, just skip auto-fill } finally { setIsFetchingMetadata(false) } }, 800) // Wait 800ms after user stops typing return () => { if (fetchTimeoutRef.current) { clearTimeout(fetchTimeoutRef.current) } } }, [url, title, description, tagsInput]) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError(null) if (!url.trim()) { setError('URL is required') return } // Validate URL try { new URL(url) } catch { setError('Please enter a valid URL') return } try { setIsSaving(true) // Parse tags from comma-separated input const tags = tagsInput .split(',') .map(tag => tag.trim()) .filter(tag => tag.length > 0) await onSave( url.trim(), title.trim() || undefined, description.trim() || undefined, tags.length > 0 ? tags : undefined ) onClose() } catch (err) { console.error('Failed to save bookmark:', err) setError(err instanceof Error ? err.message : 'Failed to save bookmark') } finally { setIsSaving(false) } } return createPortal(
e.stopPropagation()}>

Add Bookmark

setUrl(e.target.value)} placeholder="https://example.com" disabled={isSaving} autoFocus />
setTitle(e.target.value)} placeholder="Optional title" disabled={isSaving} />