Files
boris/src/components/AddBookmarkModal.tsx
Gigi ca339ac0b2 refactor: remove all eslint-disable statements and fix underlying issues
- Replace @typescript-eslint/no-explicit-any with proper Filter type from nostr-tools/filter in dataFetch.ts and helpers.ts
- Replace @typescript-eslint/no-explicit-any with IAccount and AccountManager types from applesauce-accounts in hooks
- Replace @typescript-eslint/no-explicit-any with unknown type casts in App.tsx for keep-alive subscription
- Fix react-hooks/exhaustive-deps warnings by including all dependencies in useEffect hooks
- Remove unused _settings parameters in useImageCache.ts that were causing no-unused-vars warnings
2025-10-15 09:57:14 +02:00

290 lines
8.8 KiB
TypeScript

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'
interface AddBookmarkModalProps {
onClose: () => void
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
}
// Helper to extract metadata from HTML
function extractMetaTag(html: string, patterns: string[]): string | null {
for (const pattern of patterns) {
const match = html.match(new RegExp(pattern, 'i'))
if (match) return match[1]
}
return null
}
function extractTags(html: string): string[] {
const tags: string[] = []
// Extract keywords meta tag
const keywords = extractMetaTag(html, [
'<meta\\s+name=["\'"]keywords["\'"]\\s+content=["\'"]([^"\']+)["\']'
])
if (keywords) {
keywords.split(/[,;]/)
.map(k => k.trim().toLowerCase())
.filter(k => k.length > 0 && k.length < 30)
.forEach(k => tags.push(k))
}
// Extract article:tag (multiple possible)
const articleTagRegex = /<meta\s+property=["']article:tag["']\s+content=["']([^"']+)["']/gi
let match
while ((match = articleTagRegex.exec(html)) !== null) {
const tag = match[1].trim().toLowerCase()
if (tag && tag.length < 30) tags.push(tag)
}
return Array.from(new Set(tags)).slice(0, 5)
}
const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ 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<string | null>(null)
const fetchTimeoutRef = useRef<number | null>(null)
const lastFetchedUrlRef = useRef<string>('')
// 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 {
const content = await fetchReadableContent(normalizedUrl)
lastFetchedUrlRef.current = normalizedUrl
let extractedAnything = false
// Extract title: prioritize og:title > twitter:title > <title>
if (!title && content.html) {
const extractedTitle = extractMetaTag(content.html, [
'<meta\\s+property=["\'"]og:title["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]twitter:title["\'"]\\s+content=["\'"]([^"\']+)["\']'
]) || content.title
if (extractedTitle) {
setTitle(extractedTitle)
extractedAnything = true
}
}
// Extract description: prioritize og:description > twitter:description > meta description
if (!description && content.html) {
const extractedDesc = extractMetaTag(content.html, [
'<meta\\s+property=["\'"]og:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]twitter:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]description["\'"]\\s+content=["\'"]([^"\']+)["\']'
])
if (extractedDesc) {
setDescription(extractedDesc)
extractedAnything = true
}
}
// Extract tags from keywords and article:tag (only if user hasn't modified tags)
if (!tagsInput && content.html) {
const extractedTags = extractTags(content.html)
// 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(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Add Bookmark</h2>
<IconButton
icon={faTimes}
onClick={onClose}
title="Close"
ariaLabel="Close modal"
variant="ghost"
/>
</div>
<form onSubmit={handleSubmit} className="modal-form">
<div className="form-group">
<label htmlFor="bookmark-url">
URL *
{isFetchingMetadata && (
<span className="fetching-indicator">
<FontAwesomeIcon icon={faSpinner} spin /> Fetching details...
</span>
)}
</label>
<input
id="bookmark-url"
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
disabled={isSaving}
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="bookmark-title">Title</label>
<input
id="bookmark-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Optional title"
disabled={isSaving}
/>
</div>
<div className="form-group">
<label htmlFor="bookmark-description">Description</label>
<textarea
id="bookmark-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
disabled={isSaving}
rows={3}
/>
</div>
<div className="form-group">
<label htmlFor="bookmark-tags">Tags</label>
<input
id="bookmark-tags"
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="comma, separated, tags"
disabled={isSaving}
/>
<div className="form-helper-text">
Separate tags with commas (e.g., "nostr, web3, article")
</div>
</div>
{error && (
<div className="modal-error">{error}</div>
)}
<div className="modal-actions">
<button
type="button"
onClick={onClose}
className="btn-secondary"
disabled={isSaving}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save Bookmark'}
</button>
</div>
</form>
</div>
</div>,
document.body
)
}
export default AddBookmarkModal