10 changed files with 562 additions and 73 deletions
@ -0,0 +1,115 @@
@@ -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 @@
@@ -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