mirror of
https://github.com/dergigi/boris.git
synced 2025-12-24 18:14:21 +01:00
- 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
290 lines
8.8 KiB
TypeScript
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
|
|
|