10 changed files with 562 additions and 73 deletions
@ -0,0 +1,115 @@ |
|||||||
|
import { SecondaryPageLink } from '@/PageManager' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { ExternalLink, Highlighter } from 'lucide-react' |
||||||
|
import { useMemo } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { nip19 } from 'nostr-tools' |
||||||
|
import { toNote } from '@/lib/link' |
||||||
|
|
||||||
|
export default function Highlight({ |
||||||
|
event, |
||||||
|
className |
||||||
|
}: { |
||||||
|
event: Event |
||||||
|
className?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
|
||||||
|
// Extract the source (e-tag, a-tag, or r-tag)
|
||||||
|
const source = useMemo(() => { |
||||||
|
const eTag = event.tags.find(tag => tag[0] === 'e') |
||||||
|
if (eTag) { |
||||||
|
const eventId = eTag[1] |
||||||
|
return { |
||||||
|
type: 'event' as const, |
||||||
|
value: eventId, |
||||||
|
bech32: nip19.noteEncode(eventId) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const aTag = event.tags.find(tag => tag[0] === 'a') |
||||||
|
if (aTag) { |
||||||
|
const [kind, pubkey, identifier] = aTag[1].split(':') |
||||||
|
const relay = aTag[2] |
||||||
|
return { |
||||||
|
type: 'addressable' as const, |
||||||
|
value: aTag[1], |
||||||
|
bech32: nip19.naddrEncode({ |
||||||
|
kind: parseInt(kind), |
||||||
|
pubkey, |
||||||
|
identifier: identifier || '', |
||||||
|
relays: relay ? [relay] : [] |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const rTag = event.tags.find(tag => tag[0] === 'r' && tag[2] === 'source') |
||||||
|
if (rTag) { |
||||||
|
return { |
||||||
|
type: 'url' as const, |
||||||
|
value: rTag[1], |
||||||
|
bech32: rTag[1] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null |
||||||
|
}, [event.tags]) |
||||||
|
|
||||||
|
// Extract the context (optional comment/surrounding context)
|
||||||
|
const context = useMemo(() => { |
||||||
|
const contextTag = event.tags.find(tag => tag[0] === 'context') |
||||||
|
return contextTag?.[1] || '' |
||||||
|
}, [event.tags]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={`relative border-l-4 border-yellow-500 bg-yellow-50/50 dark:bg-yellow-950/20 rounded-r-lg p-4 ${className || ''}`}> |
||||||
|
<div className="flex items-start gap-3"> |
||||||
|
<Highlighter className="w-5 h-5 text-yellow-600 dark:text-yellow-500 shrink-0 mt-1" /> |
||||||
|
<div className="flex-1 min-w-0"> |
||||||
|
{/* Highlighted text */} |
||||||
|
{event.content && ( |
||||||
|
<blockquote className="text-base font-normal mb-3 whitespace-pre-wrap break-words italic"> |
||||||
|
"{event.content}" |
||||||
|
</blockquote> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* Context (user's comment or surrounding context) - rendered as plaintext */} |
||||||
|
{context && ( |
||||||
|
<div className="text-sm text-muted-foreground bg-background/50 rounded p-2 mb-3 whitespace-pre-wrap break-words"> |
||||||
|
{context} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* Source link */} |
||||||
|
{source && ( |
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-2"> |
||||||
|
<span>{t('Source')}:</span> |
||||||
|
{source.type === 'url' ? ( |
||||||
|
<a |
||||||
|
href={source.value} |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
className="text-blue-500 hover:underline flex items-center gap-1" |
||||||
|
> |
||||||
|
{source.value.length > 50 ? source.value.substring(0, 50) + '...' : source.value} |
||||||
|
<ExternalLink className="w-3 h-3" /> |
||||||
|
</a> |
||||||
|
) : ( |
||||||
|
<SecondaryPageLink |
||||||
|
to={toNote(source.bech32)} |
||||||
|
className="text-blue-500 hover:underline font-mono" |
||||||
|
> |
||||||
|
{source.type === 'event'
|
||||||
|
? `note1${source.bech32.substring(5, 13)}...`
|
||||||
|
: `naddr1${source.bech32.substring(6, 14)}...` |
||||||
|
} |
||||||
|
</SecondaryPageLink> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,166 @@ |
|||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { Input } from '@/components/ui/input' |
||||||
|
import { Label } from '@/components/ui/label' |
||||||
|
import { Textarea } from '@/components/ui/textarea' |
||||||
|
import { X } from 'lucide-react' |
||||||
|
import { useState, useEffect } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { nip19 } from 'nostr-tools' |
||||||
|
|
||||||
|
export interface HighlightData { |
||||||
|
sourceType: 'nostr' | 'url' |
||||||
|
sourceValue: string // nevent/naddr/note/hex for nostr, https:// URL for url
|
||||||
|
sourceHexId?: string // converted hex ID for nostr sources
|
||||||
|
description?: string // optional comment/description
|
||||||
|
} |
||||||
|
|
||||||
|
interface HighlightEditorProps { |
||||||
|
highlightData: HighlightData |
||||||
|
setHighlightData: (data: HighlightData) => void |
||||||
|
setIsHighlight: (value: boolean) => void |
||||||
|
} |
||||||
|
|
||||||
|
export default function HighlightEditor({ |
||||||
|
highlightData, |
||||||
|
setHighlightData, |
||||||
|
setIsHighlight |
||||||
|
}: HighlightEditorProps) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const [sourceInput, setSourceInput] = useState(highlightData.sourceValue) |
||||||
|
const [description, setDescription] = useState(highlightData.description || '') |
||||||
|
const [error, setError] = useState<string>('') |
||||||
|
|
||||||
|
// Validate and parse the source input
|
||||||
|
useEffect(() => { |
||||||
|
if (!sourceInput.trim()) { |
||||||
|
setError('') |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Check if it's a URL
|
||||||
|
if (sourceInput.startsWith('https://')) { |
||||||
|
setError('') |
||||||
|
setHighlightData({ |
||||||
|
sourceType: 'url', |
||||||
|
sourceValue: sourceInput, |
||||||
|
description |
||||||
|
}) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Try to parse as nostr identifier
|
||||||
|
try { |
||||||
|
let hexId: string | undefined |
||||||
|
|
||||||
|
// Check if it's already a hex ID (64 char hex string)
|
||||||
|
if (/^[a-f0-9]{64}$/i.test(sourceInput)) { |
||||||
|
hexId = sourceInput.toLowerCase() |
||||||
|
setError('') |
||||||
|
setHighlightData({ |
||||||
|
sourceType: 'nostr', |
||||||
|
sourceValue: sourceInput, |
||||||
|
sourceHexId: hexId, |
||||||
|
description |
||||||
|
}) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Try to decode as nip19 identifier
|
||||||
|
const decoded = nip19.decode(sourceInput) |
||||||
|
|
||||||
|
if (decoded.type === 'note') { |
||||||
|
hexId = decoded.data |
||||||
|
setError('') |
||||||
|
setHighlightData({ |
||||||
|
sourceType: 'nostr', |
||||||
|
sourceValue: sourceInput, // Keep original for reference
|
||||||
|
sourceHexId: hexId, // Store the hex ID
|
||||||
|
description |
||||||
|
}) |
||||||
|
} else if (decoded.type === 'nevent') { |
||||||
|
hexId = decoded.data.id |
||||||
|
setError('') |
||||||
|
setHighlightData({ |
||||||
|
sourceType: 'nostr', |
||||||
|
sourceValue: sourceInput, // Keep the nevent for relay info
|
||||||
|
sourceHexId: hexId, // Store the hex ID
|
||||||
|
description |
||||||
|
}) |
||||||
|
} else if (decoded.type === 'naddr') { |
||||||
|
// For naddr, we need to keep the full naddr string to extract kind:pubkey:identifier
|
||||||
|
setError('') |
||||||
|
setHighlightData({ |
||||||
|
sourceType: 'nostr', |
||||||
|
sourceValue: sourceInput, // Keep the naddr for a-tag building
|
||||||
|
sourceHexId: undefined, // No hex ID for addressable events
|
||||||
|
description |
||||||
|
}) |
||||||
|
} else { |
||||||
|
setError(t('Invalid source. Please enter a note ID, nevent, naddr, hex ID, or URL.')) |
||||||
|
return |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
setError(t('Invalid source. Please enter a note ID, nevent, naddr, hex ID, or URL.')) |
||||||
|
} |
||||||
|
}, [sourceInput, description, setHighlightData, t]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="rounded-lg border bg-muted/40 p-4 space-y-4"> |
||||||
|
<div className="flex items-center justify-between"> |
||||||
|
<div className="text-sm font-medium">{t('Highlight Settings')}</div> |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
size="icon" |
||||||
|
className="h-6 w-6" |
||||||
|
onClick={() => setIsHighlight(false)} |
||||||
|
> |
||||||
|
<X className="h-4 w-4" /> |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="space-y-2"> |
||||||
|
<Label htmlFor="highlight-source"> |
||||||
|
{t('Source')} <span className="text-destructive">*</span> |
||||||
|
</Label> |
||||||
|
<Input |
||||||
|
id="highlight-source" |
||||||
|
value={sourceInput} |
||||||
|
onChange={(e) => setSourceInput(e.target.value)} |
||||||
|
placeholder={t('nevent1..., naddr1..., note1..., hex ID, or https://...')} |
||||||
|
className={error ? 'border-destructive' : ''} |
||||||
|
/> |
||||||
|
{error && ( |
||||||
|
<p className="text-sm text-destructive">{error}</p> |
||||||
|
)} |
||||||
|
<p className="text-xs text-muted-foreground"> |
||||||
|
{t('Enter a Nostr event identifier (nevent, naddr, note, or hex ID) OR a web URL (https://). Not both.')} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="space-y-2"> |
||||||
|
<Label htmlFor="highlight-context"> |
||||||
|
{t('Context')} <span className="text-muted-foreground text-xs">({t('optional')})</span> |
||||||
|
</Label> |
||||||
|
<Textarea |
||||||
|
id="highlight-context" |
||||||
|
value={description} |
||||||
|
onChange={(e) => setDescription(e.target.value)} |
||||||
|
placeholder={t('Add your comment or surrounding context for this highlight...')} |
||||||
|
rows={3} |
||||||
|
maxLength={500} |
||||||
|
/> |
||||||
|
<p className="text-xs text-muted-foreground"> |
||||||
|
{description.length}/500 {t('characters')} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground bg-background/50 rounded p-2"> |
||||||
|
<p className="font-medium mb-1">{t('About Highlights (NIP-84)')}</p> |
||||||
|
<p> |
||||||
|
{t('The highlighted text goes in the main content. The source and optional context will be added as tags.')} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
Loading…
Reference in new issue