@ -23,10 +23,10 @@ import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-
import client from '@/services/client.service'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import indexedDb from '@/services/indexed-db.service'
import { getRelaysForSpellCatalogSync } from '@/services/spell.service'
import { getRelaysForSpellCatalogSync } from '@/services/spell.service'
import { Minus , Plus , X } from 'lucide-react'
import { Info , Minus , Plus , X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from 'react-i18next'
import type { Event as NostrEvent } from 'nostr-tools'
import type { Event as NostrEvent } from 'nostr-tools'
import { useCallback , useEffect , useRef , useState } from 'react'
import { useCallback , useEffect , useRef , useState , type ReactNode } from 'react'
import logger from '@/lib/logger'
import logger from '@/lib/logger'
/** Arrow keys should control the control, not the dialog scroll */
/** Arrow keys should control the control, not the dialog scroll */
@ -140,6 +140,44 @@ function DynamicStringListField({
)
)
}
}
/** Bottom-of-form panel: name, description, catalog topics — not part of NIP-A7 REQ filter. */
function SpellMetadataSection ( {
title ,
badge ,
hint ,
children
} : {
title : string
badge : string
hint : string
children : ReactNode
} ) {
return (
< div
className = "rounded-xl border-2 border-dashed border-muted-foreground/35 bg-muted/25"
role = "region"
aria - labelledby = "spell-form-metadata-title"
>
< div className = "space-y-1.5 border-b border-border/80 bg-muted/40 px-4 py-3" >
< div className = "flex flex-wrap items-center gap-2" >
< Info className = "size-4 shrink-0 text-muted-foreground" aria - hidden / >
< h3 id = "spell-form-metadata-title" className = "text-sm font-semibold tracking-tight" >
{ title }
< / h3 >
< span
className = "rounded-md border border-muted-foreground/45 bg-background/80 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
title = { hint }
>
{ badge }
< / span >
< / div >
< p className = "ps-6 text-xs leading-relaxed text-muted-foreground" > { hint } < / p >
< / div >
< div className = "grid gap-4 p-4" > { children } < / div >
< / div >
)
}
function TagFiltersEditor ( {
function TagFiltersEditor ( {
tagFilters ,
tagFilters ,
onChange
onChange
@ -156,7 +194,7 @@ function TagFiltersEditor({
}
}
return (
return (
< div className = "grid gap-2" >
< div className = "grid gap-2" >
< Label > { t ( 'REQ tag filters ' ) } < / Label >
< Label > { t ( 'spellFormTagFiltersLabel ' ) } < / Label >
< p className = "text-xs text-muted-foreground" > { t ( 'spellTagFiltersHint' ) } < / p >
< p className = "text-xs text-muted-foreground" > { t ( 'spellTagFiltersHint' ) } < / p >
{ tagFilters . length === 0 ? (
{ tagFilters . length === 0 ? (
< p className = "text-xs text-muted-foreground" > { t ( 'spellTagFiltersEmpty' ) } < / p >
< p className = "text-xs text-muted-foreground" > { t ( 'spellTagFiltersEmpty' ) } < / p >
@ -405,9 +443,7 @@ export default function CreateSpellDialog({
< p className = "mt-2 text-sm text-muted-foreground" >
< p className = "mt-2 text-sm text-muted-foreground" >
{ spellToClone
{ spellToClone
? t ( 'Clone spell intro' )
? t ( 'Clone spell intro' )
: t (
: t ( 'spellCreateIntro' ) }
'Spells are saved relay filters (NIP-A7). Fill in the filter fields below. Use $me for your pubkey and $contacts for your follow list when executing.'
) }
< / p >
< / p >
< / div >
< / div >
@ -458,153 +494,166 @@ export default function CreateSpellDialog({
) : null }
) : null }
< / div >
< / div >
< div className = "grid gap-2" >
< div className = "space-y-4 border-t border-border pt-4" >
< Label > { t ( 'Command' ) } < / Label >
< div className = "space-y-1" >
< select
< h3 className = "text-sm font-semibold text-foreground" > { t ( 'spellFormSectionQueryTitle' ) } < / h3 >
className = "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
< p className = "text-xs text-muted-foreground" > { t ( 'spellFormSectionQueryHint' ) } < / p >
value = { form . cmd }
< / div >
onChange = { ( e ) = > {
const cmd = e . target . value as 'REQ' | 'COUNT'
setForm ( ( f ) = >
cmd === 'COUNT' ? { . . . f , cmd , closeOnEose : false } : { . . . f , cmd }
)
} }
>
< option value = "REQ" > REQ ( subscribe to events ) < / option >
< option value = "COUNT" > COUNT ( count only ) < / option >
< / select >
< p className = "text-xs text-muted-foreground" > { t ( 'REQ returns a feed; COUNT returns a number.' ) } < / p >
< / div >
< div className = "grid gap-2" >
< div className = "grid gap-2" >
< Label > { t ( 'Name' ) } < / Label >
< Label > { t ( 'Command' ) } < / Label >
< Input
< select
value = { form . name }
className = "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
onChange = { ( e ) = > setForm ( ( f ) = > ( { . . . f , name : e.target.value } ) ) }
value = { form . cmd }
placeholder = { t ( 'Human-readable spell name' ) }
onChange = { ( e ) = > {
/ >
const cmd = e . target . value as 'REQ' | 'COUNT'
< / div >
setForm ( ( f ) = >
cmd === 'COUNT' ? { . . . f , cmd , closeOnEose : false } : { . . . f , cmd }
)
} }
>
< option value = "REQ" > REQ ( subscribe to events ) < / option >
< option value = "COUNT" > COUNT ( count only ) < / option >
< / select >
< p className = "text-xs text-muted-foreground" > { t ( 'REQ returns a feed; COUNT returns a number.' ) } < / p >
< / div >
< div className = "grid gap-2" >
< DynamicStringListField
< Label > { t ( 'Description (content)' ) } < / Label >
label = { t ( 'Kinds ' ) }
< Textarea
hint = { t ( 'One kind number per row (e.g. 1 for notes).' ) }
value = { form . content }
placeholder = "1"
onChange = { ( e ) = > setForm ( ( f ) = > ( { . . . f , content : e.target.value } ) ) }
inputType = "number"
placeholder = { t ( 'Plain text description of the query' ) }
values = { form . kinds }
rows = { 2 }
onChange = { ( kinds ) = > setForm ( ( f ) = > ( { . . . f , kinds } ) ) }
/ >
/ >
< / div >
< DynamicStringListField
label = { t ( 'Kinds' ) }
hint = { t ( 'One kind number per row (e.g. 1 for notes).' ) }
placeholder = "1"
inputType = "number"
values = { form . kinds }
onChange = { ( kinds ) = > setForm ( ( f ) = > ( { . . . f , kinds } ) ) }
/ >
< DynamicStringListField
label = { t ( 'Authors' ) }
hint = { t ( 'One author per row: $me, $contacts, or hex pubkey / npub.' ) }
placeholder = "$me"
values = { form . authors }
onChange = { ( authors ) = > setForm ( ( f ) = > ( { . . . f , authors } ) ) }
/ >
< DynamicStringListField
< DynamicStringListField
label = { t ( 'Event IDs (ids)' ) }
label = { t ( 'Authors' ) }
hint = { t ( 'One hex event id per row.' ) }
hint = { t ( 'One author per row: $me, $contacts, or hex pubkey / npub.' ) }
placeholder = "hex id…"
placeholder = "$me"
values = { form . ids }
values = { form . authors }
onChange = { ( ids ) = > setForm ( ( f ) = > ( { . . . f , ids } ) ) }
onChange = { ( authors ) = > setForm ( ( f ) = > ( { . . . f , authors } ) ) }
/ >
< div className = "grid gap-2" >
< Label > { t ( 'Limit' ) } < / Label >
< Input
type = "number"
value = { form . limit }
onChange = { ( e ) = > setForm ( ( f ) = > ( { . . . f , limit : e.target.value } ) ) }
placeholder = "50"
/ >
/ >
< / div >
< div className = "grid gap-2" >
< DynamicStringListField
< Label > { t ( 'Since' ) } < / Label >
label = { t ( 'Event IDs (ids)' ) }
< Input
hint = { t ( 'One hex event id per row.' ) }
value = { form . since }
placeholder = "hex id…"
onChange = { ( e ) = > setForm ( ( f ) = > ( { . . . f , since : e.target.value } ) ) }
values = { form . ids }
placeholder = "7d or 1704067200 or now"
onChange = { ( ids ) = > setForm ( ( f ) = > ( { . . . f , ids } ) ) }
/ >
/ >
< p className = "text-xs text-muted-foreground" >
{ t ( 'Relative: 7d, 24h, 1w, 1mo, 1y. Or Unix timestamp.' ) }
< / p >
< / div >
< div className = "grid gap-2" >
< TagFiltersEditor
< Label > { t ( 'Until' ) } < / Label >
tagFilters = { form . tagFilters }
< Input
onChange = { ( tagFilters ) = > setForm ( ( f ) = > ( { . . . f , tagFilters } ) ) }
value = { form . until }
onChange = { ( e ) = > setForm ( ( f ) = > ( { . . . f , until : e.target.value } ) ) }
placeholder = { t ( 'Optional' ) }
/ >
/ >
< / div >
< div className = "grid gap-2" >
< div className = "grid gap-2" >
< Label > { t ( 'Search (NIP-50)' ) } < / Label >
< Label > { t ( 'Limit' ) } < / Label >
< Input
< Input
value = { form . search }
type = "number"
onChange = { ( e ) = > setForm ( ( f ) = > ( { . . . f , search : e.target.value } ) ) }
value = { form . limit }
placeholder = { t ( 'Full-text search query' ) }
onChange = { ( e ) = > setForm ( ( f ) = > ( { . . . f , limit : e.target.value } ) ) }
/ >
placeholder = "50"
< / div >
/ >
< / div >
< DynamicStringListField
< div className = "grid gap-2" >
label = { t ( 'Relays' ) }
< Label > { t ( 'Since' ) } < / Label >
hint = { t ( 'One wss:// URL per row. Leave empty to use your write relays.' ) }
< Input
placeholder = "wss://…"
value = { form . since }
values = { form . relays }
onChange = { ( e ) = > setForm ( ( f ) = > ( { . . . f , since : e.target.value } ) ) }
onChange = { ( relays ) = > setForm ( ( f ) = > ( { . . . f , relays } ) ) }
placeholder = "7d or 1704067200 or now"
/ >
/ >
< p className = "text-xs text-muted-foreground" >
{ t ( 'Relative: 7d, 24h, 1w, 1mo, 1y. Or Unix timestamp.' ) }
< / p >
< / div >
< DynamicStringListField
< div className = "grid gap-2" >
label = { t ( 'Topics (t tags for categorization)' ) }
< Label > { t ( 'Until' ) } < / Label >
hint = { t ( 'One topic per row.' ) }
< Input
placeholder = { t ( 'topic' ) }
value = { form . until }
values = { form . topics }
onChange = { ( e ) = > setForm ( ( f ) = > ( { . . . f , until : e.target.value } ) ) }
onChange = { ( topics ) = > setForm ( ( f ) = > ( { . . . f , topics } ) ) }
placeholder = { t ( 'Optional' ) }
/ >
/ >
< / div >
< TagFiltersEditor
< div className = "grid gap-2" >
tagFilters = { form . tagFilters }
< Label > { t ( 'Search (NIP-50)' ) } < / Label >
onChange = { ( tagFilters ) = > setForm ( ( f ) = > ( { . . . f , tagFilters } ) ) }
< Input
/ >
value = { form . search }
onChange = { ( e ) = > setForm ( ( f ) = > ( { . . . f , search : e.target.value } ) ) }
placeholder = { t ( 'Full-text search query' ) }
/ >
< / div >
{ form . cmd === 'REQ' ? (
< DynamicStringListField
< div className = "flex flex-col gap-1.5" >
label = { t ( 'Relays' ) }
< Label > { t ( 'Mode' ) } < / Label >
hint = { t ( 'One wss:// URL per row. Leave empty to use your write relays.' ) }
< div className = "flex rounded-lg border border-input bg-muted p-0.5" >
placeholder = "wss://…"
< button
values = { form . relays }
type = "button"
onChange = { ( relays ) = > setForm ( ( f ) = > ( { . . . f , relays } ) ) }
className = { ` flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${ ! form . closeOnEose ? 'bg-background text-foreground shadow' : 'text-muted-foreground hover:text-foreground' } ` }
/ >
onClick = { ( ) = > setForm ( ( f ) = > ( { . . . f , closeOnEose : false } ) ) }
>
{ form . cmd === 'REQ' ? (
{ t ( 'Feed' ) }
< div className = "flex flex-col gap-1.5" >
< / button >
< Label > { t ( 'Mode' ) } < / Label >
< button
< div className = "flex rounded-lg border border-input bg-muted p-0.5" >
type = "button"
< button
className = { ` flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${ form . closeOnEose ? 'bg-background text-foreground shadow' : 'text-muted-foreground hover:text-foreground' } ` }
type = "button"
onClick = { ( ) = > setForm ( ( f ) = > ( { . . . f , closeOnEose : true } ) ) }
className = { ` flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${ ! form . closeOnEose ? 'bg-background text-foreground shadow' : 'text-muted-foreground hover:text-foreground' } ` }
>
onClick = { ( ) = > setForm ( ( f ) = > ( { . . . f , closeOnEose : false } ) ) }
{ t ( 'Fetch' ) }
>
< / button >
{ t ( 'Feed' ) }
< / button >
< button
type = "button"
className = { ` flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${ form . closeOnEose ? 'bg-background text-foreground shadow' : 'text-muted-foreground hover:text-foreground' } ` }
onClick = { ( ) = > setForm ( ( f ) = > ( { . . . f , closeOnEose : true } ) ) }
>
{ t ( 'Fetch' ) }
< / button >
< / div >
< p className = "text-xs text-muted-foreground" >
{ form . closeOnEose ? t ( 'Fetch once, then stop.' ) : t ( 'Live feed; keeps updating.' ) }
< / p >
< / div >
< / div >
< p className = "text-xs text-muted-foreground" >
) : null }
{ form . closeOnEose ? t ( 'Fetch once, then stop.' ) : t ( 'Live feed; keeps updating.' ) }
< / div >
< / p >
< SpellMetadataSection
title = { t ( 'spellFormSectionMetadataTitle' ) }
badge = { t ( 'spellFormSectionMetadataBadge' ) }
hint = { t ( 'spellFormSectionMetadataHint' ) }
>
< div className = "grid gap-2" >
< Label > { t ( 'Name' ) } < / Label >
< Input
value = { form . name }
onChange = { ( e ) = > setForm ( ( f ) = > ( { . . . f , name : e.target.value } ) ) }
placeholder = { t ( 'Human-readable spell name' ) }
/ >
< / div >
< / div >
) : null }
< div className = "grid gap-2" >
< Label > { t ( 'Description (content)' ) } < / Label >
< Textarea
value = { form . content }
onChange = { ( e ) = > setForm ( ( f ) = > ( { . . . f , content : e.target.value } ) ) }
placeholder = { t ( 'Plain text description of the query' ) }
rows = { 2 }
/ >
< / div >
< DynamicStringListField
label = { t ( 'spellFormCatalogTopicsLabel' ) }
hint = { t ( 'spellTopicsMetadataHint' ) }
placeholder = { t ( 'topic' ) }
values = { form . topics }
onChange = { ( topics ) = > setForm ( ( f ) = > ( { . . . f , topics } ) ) }
/ >
< / SpellMetadataSection >
< / div >
< / div >
< / div >
< / div >