Browse Source

fix double-pane mode

make spells page vertically-aligned
imwald
Silberengel 1 month ago
parent
commit
f492532fee
  1. 58
      src/PageManager.tsx
  2. 58
      src/components/CacheRelaysSetting/index.tsx
  3. 11
      src/components/MailboxSetting/NewMailboxRelayInput.tsx
  4. 75
      src/i18n/locales/de.ts
  5. 23
      src/i18n/locales/en.ts
  6. 12
      src/layouts/PrimaryPageLayout/index.tsx
  7. 4
      src/layouts/SecondaryPageLayout/index.tsx
  8. 19
      src/lib/draft-event.ts
  9. 410
      src/pages/primary/SpellsPage/CreateSpellDialog.tsx
  10. 212
      src/pages/primary/SpellsPage/index.tsx
  11. 36
      src/services/spell.service.ts

58
src/PageManager.tsx

@ -31,8 +31,10 @@ import {
cloneElement, cloneElement,
createContext, createContext,
createRef, createRef,
lazy,
ReactNode, ReactNode,
RefObject, RefObject,
Suspense,
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
@ -40,6 +42,7 @@ import {
useState useState
} from 'react' } from 'react'
import BottomNavigationBar from './components/BottomNavigationBar' import BottomNavigationBar from './components/BottomNavigationBar'
import { useTranslation } from 'react-i18next'
import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog' import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog'
import { normalizeUrl } from './lib/url' import { normalizeUrl } from './lib/url'
import ExplorePage from './pages/primary/ExplorePage' import ExplorePage from './pages/primary/ExplorePage'
@ -49,8 +52,10 @@ import ProfilePage from './pages/primary/ProfilePage'
import RelayPage from './pages/primary/RelayPage' import RelayPage from './pages/primary/RelayPage'
import SearchPage from './pages/primary/SearchPage' import SearchPage from './pages/primary/SearchPage'
import DiscussionsPage from './pages/primary/DiscussionsPage' import DiscussionsPage from './pages/primary/DiscussionsPage'
import SpellsPage from './pages/primary/SpellsPage'
import { useScreenSize } from './providers/ScreenSizeProvider' import { useScreenSize } from './providers/ScreenSizeProvider'
/** Lazy-loaded so PageManager does not synchronously import SpellsPage (avoids HMR cycle: SpellsPage → PrimaryPageLayout → PageManager → SpellsPage). */
const SpellsPageLazy = lazy(() => import('./pages/primary/SpellsPage'))
import { routes } from './routes' import { routes } from './routes'
import modalManager from './services/modal-manager.service' import modalManager from './services/modal-manager.service'
import CreateWalletGuideToast from './components/CreateWalletGuideToast' import CreateWalletGuideToast from './components/CreateWalletGuideToast'
@ -100,7 +105,17 @@ const getPrimaryPageMap = () => ({
relay: <RelayPage ref={PRIMARY_PAGE_REF_MAP.relay} />, relay: <RelayPage ref={PRIMARY_PAGE_REF_MAP.relay} />,
search: <SearchPage ref={PRIMARY_PAGE_REF_MAP.search} />, search: <SearchPage ref={PRIMARY_PAGE_REF_MAP.search} />,
discussions: <DiscussionsPage ref={PRIMARY_PAGE_REF_MAP.discussions} />, discussions: <DiscussionsPage ref={PRIMARY_PAGE_REF_MAP.discussions} />,
spells: <SpellsPage ref={PRIMARY_PAGE_REF_MAP.spells} /> spells: (
<Suspense
fallback={
<div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
Loading
</div>
}
>
<SpellsPageLazy ref={PRIMARY_PAGE_REF_MAP.spells} />
</Suspense>
)
}) })
// Type for primary page names - use the return type of getPrimaryPageMap // Type for primary page names - use the return type of getPrimaryPageMap
@ -493,13 +508,13 @@ function MainContentArea({
primaryNoteView: !!primaryNoteView primaryNoteView: !!primaryNoteView
}) })
// Always use single column layout since double-panel is disabled. flex + min-h-0 so primary page ScrollArea gets a height and can scroll. // flex + min-h-0 + min-w-0 so primary pages get a real height in flex parents and can shrink horizontally (double-pane).
return ( return (
<div className="flex-1 flex flex-col min-h-0 w-full pr-2 py-2"> <div className="flex min-h-0 min-w-0 flex-1 flex-col w-full pr-2 py-2">
<div className="flex-1 flex flex-col min-h-0 rounded-lg shadow-lg bg-background overflow-hidden"> <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg bg-background shadow-lg">
{primaryNoteView ? ( {primaryNoteView ? (
// Show note view with back button // Show note view with back button
<div className="flex flex-col h-full w-full"> <div className="flex h-full min-h-0 min-w-0 w-full flex-col">
<div className="flex justify-center py-1 border-b"> <div className="flex justify-center py-1 border-b">
<span className="text-green-600 dark:text-green-500 font-semibold text-sm"> <span className="text-green-600 dark:text-green-500 font-semibold text-sm">
Imwald Imwald
@ -539,10 +554,10 @@ function MainContentArea({
return ( return (
<div <div
key={name} key={name}
className="flex flex-col h-full min-h-0 w-full" className={cn(
style={{ 'flex h-full min-h-0 w-full min-w-0 flex-col',
display: isCurrentPage ? 'block' : 'none' isCurrentPage ? 'flex' : 'hidden'
}} )}
> >
{(() => { {(() => {
try { try {
@ -564,6 +579,7 @@ function MainContentArea({
} }
export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
// DEPRECATED: showRecommendedRelaysPanel removed - double-panel functionality disabled // DEPRECATED: showRecommendedRelaysPanel removed - double-panel functionality disabled
const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home') const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home')
@ -1574,9 +1590,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (panelMode === 'double') { if (panelMode === 'double') {
// Double-pane mode: show feed on left (flexible, maintains width), secondary stack on right (1042px, same as drawer) // Double-pane mode: show feed on left (flexible, maintains width), secondary stack on right (1042px, same as drawer)
return ( return (
<div className="flex-1 flex overflow-hidden"> <div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
{/* Left panel: Feed (flexible, takes remaining space after 1042px) */} {/* Left: primary column — must be a flex column so MainContentArea flex-1 gets height */}
<div className="flex-1 min-w-0 overflow-auto border-r"> <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden border-r">
<MainContentArea <MainContentArea
primaryPages={primaryPages} primaryPages={primaryPages}
currentPrimaryPage={currentPrimaryPage} currentPrimaryPage={currentPrimaryPage}
@ -1585,25 +1601,27 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
goBack={goBack} goBack={goBack}
/> />
</div> </div>
{/* Right panel: Secondary stack (1042px fixed width, same as drawer) */} {/* Right: secondary stack — max width so left pane keeps space on small desktops */}
<div className="w-[1042px] shrink-0 overflow-auto"> <div className="flex h-full min-h-0 w-[min(1042px,50vw)] shrink-0 flex-col overflow-hidden border-l border-border/60 bg-muted/20">
{secondaryStack.length > 0 ? ( {secondaryStack.length > 0 ? (
secondaryStack.map((item, index) => { secondaryStack.map((item, index) => {
const isLast = index === secondaryStack.length - 1 const isLast = index === secondaryStack.length - 1
return ( return (
<div <div
key={item.index} key={item.index}
style={{ className={cn(
display: isLast ? 'block' : 'none' 'h-full min-h-0 min-w-0 flex-col',
}} isLast ? 'flex' : 'hidden'
)}
> >
{item.component} {item.component}
</div> </div>
) )
}) })
) : ( ) : (
<div className="h-full flex items-center justify-center text-muted-foreground"> <div className="flex h-full min-h-0 flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
{/* Empty state - no secondary content */} <p>{t('doublePane.secondaryEmpty')}</p>
<p className="text-xs opacity-80">{t('doublePane.secondaryEmptyHint')}</p>
</div> </div>
)} )}
</div> </div>

58
src/components/CacheRelaysSetting/index.tsx

@ -848,45 +848,25 @@ export default function CacheRelaysSetting() {
<div className="text-xs text-muted-foreground space-y-1"> <div className="text-xs text-muted-foreground space-y-1">
<div>{t('Clear cached data stored in your browser, including IndexedDB events, localStorage settings, and service worker caches.')}</div> <div>{t('Clear cached data stored in your browser, including IndexedDB events, localStorage settings, and service worker caches.')}</div>
</div> </div>
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex min-w-0 flex-wrap gap-2">
<Button <Button variant="outline" className="shrink-0" onClick={handleClearCache}>
variant="outline" <Trash2 className="mr-2 h-4 w-4" />
className="flex-1 w-full sm:w-auto"
onClick={handleClearCache}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('Clear Cache')} {t('Clear Cache')}
</Button> </Button>
<Button <Button variant="outline" className="shrink-0" onClick={handleRefreshCache}>
variant="outline" <RefreshCw className="mr-2 h-4 w-4" />
className="flex-1 w-full sm:w-auto"
onClick={handleRefreshCache}
>
<RefreshCw className="h-4 w-4 mr-2" />
{t('Refresh Cache')} {t('Refresh Cache')}
</Button> </Button>
<Button <Button variant="outline" className="shrink-0" onClick={handleBrowseCache}>
variant="outline" <Database className="mr-2 h-4 w-4" />
className="flex-1 w-full sm:w-auto"
onClick={handleBrowseCache}
>
<Database className="h-4 w-4 mr-2" />
{t('Browse Cache')} {t('Browse Cache')}
</Button> </Button>
<Button <Button variant="outline" className="shrink-0" onClick={handleClearServiceWorker}>
variant="outline" <XCircle className="mr-2 h-4 w-4" />
className="flex-1 w-full sm:w-auto"
onClick={handleClearServiceWorker}
>
<XCircle className="h-4 w-4 mr-2" />
{t('Clear Service Worker')} {t('Clear Service Worker')}
</Button> </Button>
<Button <Button variant="outline" className="shrink-0" onClick={handleShowConsoleLogs}>
variant="outline" <Terminal className="mr-2 h-4 w-4" />
className="flex-1 w-full sm:w-auto"
onClick={handleShowConsoleLogs}
>
<Terminal className="h-4 w-4 mr-2" />
{t('View Console Logs')} ({consoleLogRef.current.length}) {t('View Console Logs')} ({consoleLogRef.current.length})
</Button> </Button>
</div> </div>
@ -1285,15 +1265,15 @@ export default function CacheRelaysSetting() {
</div> </div>
</div> </div>
</DrawerHeader> </DrawerHeader>
<div className="px-4 pb-2 space-y-2"> <div className="space-y-2 px-4 pb-2">
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex min-w-0 flex-wrap gap-2">
<Input <Input
placeholder={t('Search logs...')} placeholder={t('Search logs...')}
value={consoleLogSearch} value={consoleLogSearch}
onChange={(e) => setConsoleLogSearch(e.target.value)} onChange={(e) => setConsoleLogSearch(e.target.value)}
className="flex-1" className="min-w-0 flex-1 basis-[min(100%,12rem)]"
/> />
<div className="flex gap-1 shrink-0"> <div className="flex shrink-0 gap-1">
<Button <Button
type="button" type="button"
variant={consoleLogLevel === 'errors-warnings' ? 'secondary' : 'outline'} variant={consoleLogLevel === 'errors-warnings' ? 'secondary' : 'outline'}
@ -1408,15 +1388,15 @@ export default function CacheRelaysSetting() {
</div> </div>
</div> </div>
</DialogHeader> </DialogHeader>
<div className="px-6 pb-4 space-y-2"> <div className="space-y-2 px-6 pb-4">
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex min-w-0 flex-wrap gap-2">
<Input <Input
placeholder={t('Search logs...')} placeholder={t('Search logs...')}
value={consoleLogSearch} value={consoleLogSearch}
onChange={(e) => setConsoleLogSearch(e.target.value)} onChange={(e) => setConsoleLogSearch(e.target.value)}
className="flex-1" className="min-w-0 flex-1 basis-[min(100%,12rem)]"
/> />
<div className="flex gap-1 shrink-0"> <div className="flex shrink-0 gap-1">
<Button <Button
type="button" type="button"
variant={consoleLogLevel === 'errors-warnings' ? 'secondary' : 'outline'} variant={consoleLogLevel === 'errors-warnings' ? 'secondary' : 'outline'}

11
src/components/MailboxSetting/NewMailboxRelayInput.tsx

@ -34,17 +34,20 @@ export default function NewMailboxRelayInput({
} }
return ( return (
<div> <div className="min-w-0">
<div className="flex flex-col sm:flex-row gap-4"> {/* flex-wrap: narrow panes (e.g. double-pane) use viewport breakpoints, not container width */}
<div className="flex flex-wrap gap-2">
<Input <Input
className={newRelayUrlError ? 'border-destructive' : ''} className={`min-w-0 flex-1 basis-[min(100%,16rem)] ${newRelayUrlError ? 'border-destructive' : ''}`}
placeholder={t('Add a new relay')} placeholder={t('Add a new relay')}
value={newRelayUrl} value={newRelayUrl}
onKeyDown={handleRelayUrlInputKeyDown} onKeyDown={handleRelayUrlInputKeyDown}
onChange={handleRelayUrlInputChange} onChange={handleRelayUrlInputChange}
onBlur={save} onBlur={save}
/> />
<Button className="w-full sm:w-auto" onClick={save}>{t('Add')}</Button> <Button className="shrink-0" onClick={save}>
{t('Add')}
</Button>
</div> </div>
{newRelayUrlError && <div className="text-destructive text-xs mt-1">{newRelayUrlError}</div>} {newRelayUrlError && <div className="text-destructive text-xs mt-1">{newRelayUrlError}</div>}
</div> </div>

75
src/i18n/locales/de.ts

@ -618,6 +618,79 @@ export default {
'shortcuts.activate': 'Schaltflächen und viele Steuerelemente auslösen', 'shortcuts.activate': 'Schaltflächen und viele Steuerelemente auslösen',
'shortcuts.closeOverlays': 'Dialoge, Menüs und Such-Dropdown schließen', 'shortcuts.closeOverlays': 'Dialoge, Menüs und Such-Dropdown schließen',
'shortcuts.scrollWhenFocused': 'Den fokussierten scrollbaren Bereich scrollen', 'shortcuts.scrollWhenFocused': 'Den fokussierten scrollbaren Bereich scrollen',
'shortcuts.browserBack': 'Zurück im Browser (Verlauf)' 'shortcuts.browserBack': 'Zurück im Browser (Verlauf)',
Spells: 'Zaubersprüche',
Tags: 'Tags',
Close: 'Schließen',
'Back to spell list': 'Zurück zur Zauberspruch-Liste',
'Create a Spell': 'Zauberspruch anlegen',
'No spells yet. Create one with the button above.':
'Noch keine Zaubersprüche. Lege mit dem Button oben einen an.',
'Select a spell…': 'Zauberspruch wählen…',
'View definition': 'Definition anzeigen',
'Add to favorites': 'Zu Favoriten hinzufügen',
'Remove from favorites': 'Aus Favoriten entfernen',
'COUNT spells show a number, not a feed.':
'COUNT-Zaubersprüche zeigen eine Zahl, keinen Feed.',
'Log in to run this spell (it uses $me or $contacts).':
'Zum Ausführen anmelden (verwendet $me oder $contacts).',
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add read relays in settings.':
'Zauberspruch konnte nicht ausgeführt werden. Prüfe REQ/COUNT oder füge Lese-Relays in den Einstellungen hinzu.',
'Select a spell to view its feed.': 'Wähle einen Zauberspruch, um den Feed zu sehen.',
'Add another row': 'Weitere Zeile hinzufügen',
'Remove this row': 'Diese Zeile entfernen',
'One kind number per row (e.g. 1 for notes).':
'Eine Kind-Nummer pro Zeile (z. B. 1 für Notizen).',
'One author per row: $me, $contacts, or hex pubkey / npub.':
'Ein Autor pro Zeile: $me, $contacts oder Hex-Pubkey / npub.',
'One hex event id per row.': 'Eine Hex-Event-ID pro Zeile.',
'One wss:// URL per row. Leave empty to use your read relays.':
'Eine wss://-URL pro Zeile. Alle leer lassen für deine Lese-Relays.',
'One topic per row.': 'Ein Thema pro Zeile.',
topic: 'Thema',
'Spell form fields': 'Zauberspruch-Formularfelder',
'Spell definition': 'Zauberspruch-Definition',
'Spell published': 'Zauberspruch veröffentlicht',
'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.':
'Zaubersprüche sind gespeicherte Relay-Filter (NIP-A7). Fülle die Felder unten. $me = dein Pubkey, $contacts = deine Follow-Liste bei der Ausführung.',
Command: 'Befehl',
'REQ returns a feed; COUNT returns a number.':
'REQ liefert einen Feed; COUNT nur eine Zahl.',
Name: 'Name',
'Human-readable spell name': 'Lesbarer Name des Zauberspruchs',
'Description (content)': 'Beschreibung (Inhalt)',
'Plain text description of the query': 'Klartext-Beschreibung der Abfrage',
Kinds: 'Kinds',
'Comma-separated kind numbers (e.g. 1 for notes).':
'Kind-Nummern, durch Komma getrennt (z. B. 1 für Notizen).',
Authors: 'Autoren',
'$me = your pubkey, $contacts = your follow list. Comma-separated.':
'$me = dein Pubkey, $contacts = deine Follow-Liste. Komma-getrennt.',
'Event IDs (ids)': 'Event-IDs (ids)',
'Comma-separated event ids': 'Event-IDs, komma-getrennt',
Limit: 'Limit',
Since: 'Seit',
'Relative: 7d, 24h, 1w, 1mo, 1y. Or Unix timestamp.':
'Relativ: 7d, 24h, 1w, 1mo, 1y. Oder Unix-Zeitstempel.',
Until: 'Bis',
Optional: 'Optional',
'Search (NIP-50)': 'Suche (NIP-50)',
'Full-text search query': 'Volltextsuchanfrage',
'Leave empty to use your read relays.':
'Leer lassen, um deine Lese-Relays zu verwenden.',
'Topics (t tags for categorization)': 'Themen (t-Tags zur Kategorisierung)',
'Comma-separated topics': 'Themen, komma-getrennt',
Mode: 'Modus',
Feed: 'Feed',
Fetch: 'Abrufen',
'Fetch once, then stop.': 'Einmal abrufen, dann stoppen.',
'Live feed; keeps updating.': 'Live-Feed; wird fortgesetzt aktualisiert.',
'Saving…': 'Speichern…',
Clear: 'Leeren',
'doublePane.secondaryEmpty':
'Öffne eine Notiz, ein Profil oder Einstellungen, um sie hier anzuzeigen.',
'doublePane.secondaryEmptyHint': 'Feed und Hauptseiten bleiben links.'
} }
} }

23
src/i18n/locales/en.ts

@ -699,6 +699,27 @@ export default {
'shortcuts.activate': 'Activate buttons and many controls', 'shortcuts.activate': 'Activate buttons and many controls',
'shortcuts.closeOverlays': 'Close dialogs, menus, and the search dropdown', 'shortcuts.closeOverlays': 'Close dialogs, menus, and the search dropdown',
'shortcuts.scrollWhenFocused': 'Scroll the focused scrollable area', 'shortcuts.scrollWhenFocused': 'Scroll the focused scrollable area',
'shortcuts.browserBack': 'Browser back (history)' 'shortcuts.browserBack': 'Browser back (history)',
'No spells yet. Create one with the button above.':
'No spells yet. Create one with the button above.',
'Select a spell…': 'Select a spell…',
'Select a spell to view its feed.': 'Select a spell to view its feed.',
'Add another row': 'Add another row',
'Remove this row': 'Remove this row',
'One kind number per row (e.g. 1 for notes).': 'One kind number per row (e.g. 1 for notes).',
'One author per row: $me, $contacts, or hex pubkey / npub.':
'One author per row: $me, $contacts, or hex pubkey / npub.',
'One hex event id per row.': 'One hex event id per row.',
'One wss:// URL per row. Leave empty to use your read relays.':
'One wss:// URL per row. Leave empty to use your read relays.',
'One topic per row.': 'One topic per row.',
topic: 'topic',
'Spell form fields': 'Spell form fields',
Spells: 'Spells',
'doublePane.secondaryEmpty': 'Open a note, profile, or settings item to show it here.',
'doublePane.secondaryEmptyHint': 'Your feed and primary pages stay on the left.'
} }
} }

12
src/layouts/PrimaryPageLayout/index.tsx

@ -117,15 +117,21 @@ const PrimaryPageLayout = forwardRef(
return ( return (
<DeepBrowsingProvider active={current === pageName && display} scrollAreaRef={scrollAreaRef}> <DeepBrowsingProvider active={current === pageName && display} scrollAreaRef={scrollAreaRef}>
<div className="relative h-full min-h-0 flex flex-col"> <div className="relative flex h-full min-h-0 min-w-0 flex-col">
<PrimaryPageTitlebar hideBottomBorder={hideTitlebarBottomBorder}> <PrimaryPageTitlebar hideBottomBorder={hideTitlebarBottomBorder}>
{titlebar} {titlebar}
</PrimaryPageTitlebar> </PrimaryPageTitlebar>
{subHeader && <div className="shrink-0 bg-background">{subHeader}</div>} {subHeader && (
<div className="min-w-0 shrink-0 bg-background">{subHeader}</div>
)}
<div <div
ref={scrollAreaRef} ref={scrollAreaRef}
tabIndex={-1} tabIndex={-1}
className={subHeader ? 'flex-1 min-h-0 overflow-y-auto overflow-x-hidden' : 'absolute top-12 left-0 right-0 bottom-0 overflow-y-auto overflow-x-hidden'} className={
subHeader
? 'min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-auto'
: 'absolute bottom-0 left-0 right-0 top-12 min-w-0 overflow-y-auto overflow-x-auto'
}
> >
{children} {children}
<div className="h-4" /> <div className="h-4" />

4
src/layouts/SecondaryPageLayout/index.tsx

@ -113,7 +113,7 @@ const SecondaryPageLayout = forwardRef(
return ( return (
<DeepBrowsingProvider active={currentIndex === index} scrollAreaRef={scrollAreaRef}> <DeepBrowsingProvider active={currentIndex === index} scrollAreaRef={scrollAreaRef}>
<div className="h-full flex flex-col"> <div className="flex h-full min-h-0 min-w-0 flex-col">
{title && ( {title && (
<> <>
<div className="flex justify-center py-1 border-b"> <div className="flex justify-center py-1 border-b">
@ -133,7 +133,7 @@ const SecondaryPageLayout = forwardRef(
<div <div
ref={scrollAreaRef} ref={scrollAreaRef}
tabIndex={-1} tabIndex={-1}
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden" className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-auto"
> >
{children} {children}
<div className="h-4" /> <div className="h-4" />

19
src/lib/draft-event.ts

@ -612,9 +612,14 @@ export function createSpellDraftEvent(params: TSpellDraftParams): TDraftEvent {
const tags: string[][] = [['cmd', params.cmd]] const tags: string[][] = [['cmd', params.cmd]]
if (params.name?.trim()) tags.push(['name', params.name.trim()]) if (params.name?.trim()) tags.push(['name', params.name.trim()])
if (params.alt?.trim()) tags.push(['alt', params.alt.trim()]) if (params.alt?.trim()) tags.push(['alt', params.alt.trim()])
params.kinds.filter((k) => k.trim()).forEach((k) => tags.push(['k', k.trim()])) params.kinds
if (params.authors.length) tags.push(['authors', ...params.authors]) .map((k) => k.trim())
if (params.ids.length) tags.push(['ids', ...params.ids]) .filter(Boolean)
.forEach((k) => tags.push(['k', k]))
const authors = params.authors.map((a) => a.trim()).filter(Boolean)
if (authors.length) tags.push(['authors', ...authors])
const ids = params.ids.map((id) => id.trim()).filter(Boolean)
if (ids.length) tags.push(['ids', ...ids])
params.tagFilters.forEach(({ letter, values }) => { params.tagFilters.forEach(({ letter, values }) => {
if (letter?.trim() && values.some((v) => v?.trim())) { if (letter?.trim() && values.some((v) => v?.trim())) {
tags.push(['tag', letter.trim(), ...values.map((v) => v.trim()).filter(Boolean)]) tags.push(['tag', letter.trim(), ...values.map((v) => v.trim()).filter(Boolean)])
@ -627,8 +632,12 @@ export function createSpellDraftEvent(params: TSpellDraftParams): TDraftEvent {
if (params.since.trim()) tags.push(['since', params.since.trim()]) if (params.since.trim()) tags.push(['since', params.since.trim()])
if (params.until.trim()) tags.push(['until', params.until.trim()]) if (params.until.trim()) tags.push(['until', params.until.trim()])
if (params.search.trim()) tags.push(['search', params.search.trim()]) if (params.search.trim()) tags.push(['search', params.search.trim()])
if (params.relays.length) tags.push(['relays', ...params.relays]) const relays = params.relays.map((r) => r.trim()).filter(Boolean)
params.topics.filter((t) => t?.trim()).forEach((t) => tags.push(['t', t.trim()])) if (relays.length) tags.push(['relays', ...relays])
params.topics
.map((t) => t.trim())
.filter(Boolean)
.forEach((t) => tags.push(['t', t]))
if (params.closeOnEose) tags.push(['close-on-eose']) if (params.closeOnEose) tags.push(['close-on-eose'])
return { return {
kind: ExtendedKind.SPELL, kind: ExtendedKind.SPELL,

410
src/pages/primary/SpellsPage/CreateSpellDialog.tsx

@ -12,11 +12,25 @@ import { createSpellDraftEvent, type TSpellDraftParams } from '@/lib/draft-event
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { X } from 'lucide-react' import { Minus, Plus, X } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useState } from 'react' import { useCallback, useRef, useState } from 'react'
import logger from '@/lib/logger' import logger from '@/lib/logger'
/** Arrow keys should control the control, not the dialog scroll */
function keyboardTargetUsesArrowKeys(target: EventTarget | null): boolean {
if (!target || !(target instanceof HTMLElement)) return false
const tag = target.tagName
if (tag === 'TEXTAREA' || tag === 'SELECT') return true
if (tag === 'INPUT') {
const type = (target as HTMLInputElement).type
if (type === 'number' || type === 'range' || type === 'date' || type === 'time') return true
}
return target.isContentEditable
}
const SCROLL_STEP_PX = 48
const DEFAULT_PARAMS: TSpellDraftParams = { const DEFAULT_PARAMS: TSpellDraftParams = {
cmd: 'REQ', cmd: 'REQ',
content: '', content: '',
@ -35,6 +49,82 @@ const DEFAULT_PARAMS: TSpellDraftParams = {
closeOnEose: false closeOnEose: false
} }
/** One input per list item; add/remove rows. */
function DynamicStringListField({
label,
hint,
values,
onChange,
placeholder,
inputType = 'text'
}: {
label: string
hint?: string
values: string[]
onChange: (next: string[]) => void
placeholder?: string
inputType?: 'text' | 'number'
}) {
const { t } = useTranslation()
const rows = values.length > 0 ? values : ['']
const updateAt = (i: number, v: string) => {
const base = values.length > 0 ? [...values] : ['']
base[i] = v
onChange(base)
}
const removeAt = (i: number) => {
const base = values.length > 0 ? [...values] : ['']
if (base.length <= 1) {
onChange([''])
return
}
base.splice(i, 1)
onChange(base)
}
const addRow = () => {
const base = values.length > 0 ? [...values] : ['']
onChange([...base, ''])
}
return (
<div className="grid gap-2">
<Label>{label}</Label>
<div className="flex flex-col gap-2">
{rows.map((v, i) => (
<div key={i} className="flex gap-2">
<Input
type={inputType}
value={v}
onChange={(e) => updateAt(i, e.target.value)}
placeholder={placeholder}
className="min-w-0 flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => removeAt(i)}
title={t('Remove this row')}
aria-label={t('Remove this row')}
>
<Minus className="size-4" />
</Button>
</div>
))}
</div>
<Button type="button" variant="outline" size="sm" className="h-9 w-fit gap-1" onClick={addRow}>
<Plus className="size-4" />
{t('Add another row')}
</Button>
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
</div>
)
}
export default function CreateSpellDialog({ export default function CreateSpellDialog({
open, open,
onOpenChange, onOpenChange,
@ -48,6 +138,31 @@ export default function CreateSpellDialog({
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const [form, setForm] = useState<TSpellDraftParams>(DEFAULT_PARAMS) const [form, setForm] = useState<TSpellDraftParams>(DEFAULT_PARAMS)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const scrollBodyRef = useRef<HTMLDivElement>(null)
const handleScrollBodyKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
const el = scrollBodyRef.current
if (!el) return
if (e.key === 'PageDown' || e.key === 'PageUp') {
e.preventDefault()
const page = el.clientHeight * 0.85
el.scrollBy({ top: e.key === 'PageDown' ? page : -page, behavior: 'smooth' })
return
}
if (keyboardTargetUsesArrowKeys(e.target)) return
if (e.key === 'ArrowDown') {
e.preventDefault()
el.scrollBy({ top: SCROLL_STEP_PX, behavior: 'smooth' })
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
el.scrollBy({ top: -SCROLL_STEP_PX, behavior: 'smooth' })
}
}, [])
const handleClear = () => setForm({ ...DEFAULT_PARAMS }) const handleClear = () => setForm({ ...DEFAULT_PARAMS })
const handleCancel = () => { const handleCancel = () => {
@ -77,185 +192,182 @@ export default function CreateSpellDialog({
} }
} }
const kindsStr = form.kinds.length ? form.kinds.join(', ') : ''
const setKindsStr = (s: string) =>
setForm((f) => ({ ...f, kinds: s.split(/[\s,]+/).filter(Boolean) }))
const authorsStr = form.authors.join(', ')
const setAuthorsStr = (s: string) =>
setForm((f) => ({ ...f, authors: s.split(/[\s,]+/).filter(Boolean) }))
const idsStr = form.ids.join(', ')
const setIdsStr = (s: string) =>
setForm((f) => ({ ...f, ids: s.split(/[\s,]+/).filter(Boolean) }))
const relaysStr = form.relays.join(', ')
const setRelaysStr = (s: string) =>
setForm((f) => ({ ...f, relays: s.split(/[\s,]+/).filter(Boolean) }))
const topicsStr = form.topics.join(', ')
const setTopicsStr = (s: string) =>
setForm((f) => ({ ...f, topics: s.split(/[\s,]+/).filter(Boolean) }))
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto max-w-2xl" withoutClose> <DialogContent
<DialogHeader className="flex flex-row items-center justify-between gap-2 pr-8"> className="flex max-h-[90vh] max-w-2xl flex-col gap-0 overflow-hidden p-0"
<DialogTitle>{t('Create a Spell')}</DialogTitle> withoutClose
>
{/* Fixed top: not inside overflow-y-auto so title/intro never scroll away or clip */}
<div className="relative shrink-0 border-b px-6 pb-4 pt-6">
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 shrink-0" className="absolute right-4 top-4 z-10 h-8 w-8 shrink-0"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
aria-label={t('Close')} aria-label={t('Close')}
> >
<X className="size-4" /> <X className="size-4" />
</Button> </Button>
</DialogHeader> <DialogHeader className="space-y-1.5 pr-10 text-left sm:text-left">
<p className="text-sm text-muted-foreground"> <DialogTitle>{t('Create a Spell')}</DialogTitle>
{t('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.')} </DialogHeader>
</p> <p className="mt-2 text-sm text-muted-foreground">
{t(
'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>
</div>
<div className="grid gap-4 py-2"> <div
<div className="grid gap-2"> ref={scrollBodyRef}
<Label>{t('Command')}</Label> tabIndex={0}
<select role="region"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm" aria-label={t('Spell form fields')}
value={form.cmd} className="min-h-0 flex-1 overflow-y-auto px-6 py-4 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background"
onChange={(e) => setForm((f) => ({ ...f, cmd: e.target.value as 'REQ' | 'COUNT' }))} onKeyDown={handleScrollBodyKeyDown}
> >
<option value="REQ">REQ (subscribe to events)</option> <div className="grid gap-4">
<option value="COUNT">COUNT (count only)</option> <div className="grid gap-2">
</select> <Label>{t('Command')}</Label>
<p className="text-xs text-muted-foreground">{t('REQ returns a feed; COUNT returns a number.')}</p> <select
</div> className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
value={form.cmd}
onChange={(e) => setForm((f) => ({ ...f, cmd: e.target.value as 'REQ' | 'COUNT' }))}
>
<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('Name')}</Label>
<Input <Input
value={form.name} value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder={t('Human-readable spell name')} placeholder={t('Human-readable spell name')}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t('Description (content)')}</Label> <Label>{t('Description (content)')}</Label>
<Textarea <Textarea
value={form.content} value={form.content}
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
placeholder={t('Plain text description of the query')} placeholder={t('Plain text description of the query')}
rows={2} rows={2}
/> />
</div> </div>
<div className="grid gap-2"> <DynamicStringListField
<Label>{t('Kinds')}</Label> label={t('Kinds')}
<Input hint={t('One kind number per row (e.g. 1 for notes).')}
value={kindsStr} placeholder="1"
onChange={(e) => setKindsStr(e.target.value)} inputType="number"
placeholder="e.g. 1, 6, 7" values={form.kinds}
onChange={(kinds) => setForm((f) => ({ ...f, kinds }))}
/> />
<p className="text-xs text-muted-foreground">{t('Comma-separated kind numbers (e.g. 1 for notes).')}</p>
</div>
<div className="grid gap-2"> <DynamicStringListField
<Label>{t('Authors')}</Label> label={t('Authors')}
<Input hint={t('One author per row: $me, $contacts, or hex pubkey / npub.')}
value={authorsStr} placeholder="$me"
onChange={(e) => setAuthorsStr(e.target.value)} values={form.authors}
placeholder="$me, $contacts, or npub1..." onChange={(authors) => setForm((f) => ({ ...f, authors }))}
/> />
<p className="text-xs text-muted-foreground">{t('$me = your pubkey, $contacts = your follow list. Comma-separated.')}</p>
</div>
<div className="grid gap-2"> <DynamicStringListField
<Label>{t('Event IDs (ids)')}</Label> label={t('Event IDs (ids)')}
<Input hint={t('One hex event id per row.')}
value={idsStr} placeholder="hex id…"
onChange={(e) => setIdsStr(e.target.value)} values={form.ids}
placeholder={t('Comma-separated event ids')} onChange={(ids) => setForm((f) => ({ ...f, ids }))}
/> />
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t('Limit')}</Label> <Label>{t('Limit')}</Label>
<Input <Input
type="number" type="number"
value={form.limit} value={form.limit}
onChange={(e) => setForm((f) => ({ ...f, limit: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, limit: e.target.value }))}
placeholder="50" placeholder="50"
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t('Since')}</Label> <Label>{t('Since')}</Label>
<Input <Input
value={form.since} value={form.since}
onChange={(e) => setForm((f) => ({ ...f, since: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, since: e.target.value }))}
placeholder="7d or 1704067200 or now" placeholder="7d or 1704067200 or now"
/> />
<p className="text-xs text-muted-foreground">{t('Relative: 7d, 24h, 1w, 1mo, 1y. Or Unix timestamp.')}</p> <p className="text-xs text-muted-foreground">
</div> {t('Relative: 7d, 24h, 1w, 1mo, 1y. Or Unix timestamp.')}
</p>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t('Until')}</Label> <Label>{t('Until')}</Label>
<Input <Input
value={form.until} value={form.until}
onChange={(e) => setForm((f) => ({ ...f, until: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, until: e.target.value }))}
placeholder={t('Optional')} placeholder={t('Optional')}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t('Search (NIP-50)')}</Label> <Label>{t('Search (NIP-50)')}</Label>
<Input <Input
value={form.search} value={form.search}
onChange={(e) => setForm((f) => ({ ...f, search: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, search: e.target.value }))}
placeholder={t('Full-text search query')} placeholder={t('Full-text search query')}
/> />
</div> </div>
<div className="grid gap-2"> <DynamicStringListField
<Label>{t('Relays')}</Label> label={t('Relays')}
<Input hint={t('One wss:// URL per row. Leave empty to use your read relays.')}
value={relaysStr} placeholder="wss://…"
onChange={(e) => setRelaysStr(e.target.value)} values={form.relays}
placeholder="wss://relay.example.com, ..." onChange={(relays) => setForm((f) => ({ ...f, relays }))}
/> />
<p className="text-xs text-muted-foreground">{t('Leave empty to use your read relays.')}</p>
</div>
<div className="grid gap-2"> <DynamicStringListField
<Label>{t('Topics (t tags for categorization)')}</Label> label={t('Topics (t tags for categorization)')}
<Input hint={t('One topic per row.')}
value={topicsStr} placeholder={t('topic')}
onChange={(e) => setTopicsStr(e.target.value)} values={form.topics}
placeholder={t('Comma-separated topics')} onChange={(topics) => setForm((f) => ({ ...f, topics }))}
/> />
</div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>{t('Mode')}</Label> <Label>{t('Mode')}</Label>
<div className="flex rounded-lg border border-input bg-muted p-0.5"> <div className="flex rounded-lg border border-input bg-muted p-0.5">
<button <button
type="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'}`} 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 }))} onClick={() => setForm((f) => ({ ...f, closeOnEose: false }))}
> >
{t('Feed')} {t('Feed')}
</button> </button>
<button <button
type="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'}`} 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 }))} onClick={() => setForm((f) => ({ ...f, closeOnEose: true }))}
> >
{t('Fetch')} {t('Fetch')}
</button> </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">
{form.closeOnEose ? t('Fetch once, then stop.') : t('Live feed; keeps updating.')}
</p>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2 justify-end pt-2 border-t"> <div className="flex shrink-0 flex-wrap justify-end gap-2 border-t px-6 py-4">
<Button variant="outline" onClick={handleClear}> <Button variant="outline" onClick={handleClear}>
{t('Clear')} {t('Clear')}
</Button> </Button>

212
src/pages/primary/SpellsPage/index.tsx

@ -12,8 +12,14 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
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'
@ -25,17 +31,19 @@ import {
spellIsCount spellIsCount
} from '@/services/spell.service' } from '@/services/spell.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest } from '@/types'
import { ChevronLeft, FileText, MoreVertical, Plus, Trash2, Wand2 } from 'lucide-react' import { FileText, MoreVertical, Plus, Star, Trash2, Wand2 } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useState } from 'react' import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog' import CreateSpellDialog from './CreateSpellDialog'
import type { TPageRef } from '@/types' import type { TPageRef } from '@/types'
/** Sentinel value for Radix Select when no spell is selected */
const SPELL_SELECT_NONE = '__spell_none__'
const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, relayList } = useNostr() const { pubkey, relayList } = useNostr()
const { isSmallScreen } = useScreenSize()
const [spells, setSpells] = useState<Event[]>([]) const [spells, setSpells] = useState<Event[]>([])
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set()) const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set())
const [selectedSpell, setSelectedSpell] = useState<Event | null>(null) const [selectedSpell, setSelectedSpell] = useState<Event | null>(null)
@ -127,159 +135,173 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
ref={ref} ref={ref}
pageName="spells" pageName="spells"
titlebar={ titlebar={
isSmallScreen ? ( <div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center justify-between w-full gap-2">
{selectedSpell ? (
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => setSelectedSpell(null)}
title={t('Back to spell list')}
>
<ChevronLeft className="size-5" />
</Button>
) : (
<div className="w-10 shrink-0" />
)}
<div className="font-semibold flex-1 text-center min-w-0 truncate">
{selectedSpell ? getSpellName(selectedSpell) : t('Spells')}
</div>
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => setCreateOpen(true)}
title={t('Create a Spell')}
>
<Plus className="size-5" />
</Button>
</div>
) : (
<div className="font-semibold">{t('Spells')}</div> <div className="font-semibold">{t('Spells')}</div>
)
}
displayScrollToTopButton
>
<div className="flex flex-col md:flex-row min-h-0 flex-1 gap-4 p-4">
{/* Left (desktop) / Top (mobile) pane: spell list */}
<div className={`flex flex-col gap-2 shrink-0 ${isSmallScreen ? 'order-1 border-b border-border pb-4' : 'w-64 border-r border-border pr-4'}`}>
<Button <Button
className="w-full justify-start gap-2" variant="ghost"
variant="outline" size="titlebar-icon"
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
title={t('Create a Spell')}
> >
<Wand2 className="size-4" /> <Plus className="size-5" />
{t('Create a Spell')}
</Button> </Button>
<ul className="space-y-1 overflow-y-auto min-h-0"> </div>
{orderedSpells.length === 0 && ( }
<li className="text-sm text-muted-foreground py-2">{t('No spells yet. Create one above.')}</li> displayScrollToTopButton
)} >
{orderedSpells.map((spell) => ( <div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
<li key={spell.id} className="flex items-center gap-1"> {/* Spell picker + actions above the feed */}
<button <div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
type="button" <Select
className={`flex-1 text-left text-sm px-2 py-1.5 rounded truncate min-w-0 ${selectedSpell?.id === spell.id ? 'bg-primary/10 text-primary font-medium' : 'hover:bg-muted'}`} value={selectedSpell?.id ?? SPELL_SELECT_NONE}
onClick={() => setSelectedSpell(spell)} onValueChange={(v) => {
if (v === SPELL_SELECT_NONE) setSelectedSpell(null)
else setSelectedSpell(orderedSpells.find((s) => s.id === v) ?? null)
}}
disabled={orderedSpells.length === 0}
>
<SelectTrigger className="min-w-0 flex-1 sm:max-w-md">
<SelectValue placeholder={t('Select a spell…')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={SPELL_SELECT_NONE}>{t('Select a spell…')}</SelectItem>
{orderedSpells.map((spell) => (
<SelectItem key={spell.id} value={spell.id}>
{favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
className="justify-start gap-2"
variant="outline"
onClick={() => setCreateOpen(true)}
>
<Wand2 className="size-4" />
{t('Create a Spell')}
</Button>
{selectedSpell && (
<>
<Button
variant="outline"
size="icon"
className="shrink-0"
title={
favoriteIds.has(selectedSpell.id)
? t('Remove from favorites')
: t('Add to favorites')
}
onClick={() => toggleFavorite(selectedSpell.id)}
> >
{getSpellName(spell)} <Star
</button> className={`size-4 ${favoriteIds.has(selectedSpell.id) ? 'fill-amber-400 text-amber-500' : ''}`}
/>
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button variant="outline" size="icon" className="shrink-0" title={t('More options')}>
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="size-4" /> <MoreVertical className="size-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setDefinitionSpell(spell)}> <DropdownMenuItem onClick={() => setDefinitionSpell(selectedSpell)}>
<FileText className="size-4" /> <FileText className="size-4" />
{t('View definition')} {t('View definition')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
onClick={() => handleDeleteSpell(spell)} onClick={() => handleDeleteSpell(selectedSpell)}
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
{t('Delete')} {t('Delete')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<button </>
type="button" )}
className="shrink-0 p-1 text-muted-foreground hover:text-foreground" </div>
onClick={() => toggleFavorite(spell.id)}
title={favoriteIds.has(spell.id) ? t('Remove from favorites') : t('Add to favorites')}
>
{favoriteIds.has(spell.id) ? '★' : '☆'}
</button>
</li>
))}
</ul>
</div> </div>
{/* Right (desktop) / Bottom (mobile) pane: feed */} {orderedSpells.length === 0 && (
<div className="flex-1 min-w-0 flex flex-col"> <p className="text-sm text-muted-foreground">{t('No spells yet. Create one with the button above.')}</p>
)}
{/* Feed */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{selectedSpell ? ( {selectedSpell ? (
subRequests.length > 0 ? ( subRequests.length > 0 ? (
<NoteList <NoteList
subRequests={subRequests} subRequests={subRequests}
showKinds={selectedSpell.tags.filter((t) => t[0] === 'k').map((t) => parseInt(t[1], 10)).filter((n) => !Number.isNaN(n)) || [1]} showKinds={(() => {
const kinds = selectedSpell.tags
.filter((tag) => tag[0] === 'k')
.map((tag) => parseInt(tag[1], 10))
.filter((n) => !Number.isNaN(n))
// `[] || [1]` is wrong ([] is truthy); default to kind 1 for notes
return kinds.length ? kinds : [1]
})()}
useFilterAsIs useFilterAsIs
/> />
) : spellIsCount(selectedSpell) ? ( ) : spellIsCount(selectedSpell) ? (
<div className="text-muted-foreground py-8 text-center">{t('COUNT spells show a number, not a feed.')}</div> <div className="py-8 text-center text-muted-foreground">
) : !pubkey && (selectedSpell.tags.some((t) => t[0] === 'authors' && (t.includes('$me') || t.includes('$contacts')))) ? ( {t('COUNT spells show a number, not a feed.')}
<div className="text-muted-foreground py-8 text-center">{t('Log in to run this spell (it uses $me or $contacts).')}</div> </div>
) : !pubkey &&
selectedSpell.tags.some(
(tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts'))
) ? (
<div className="py-8 text-center text-muted-foreground">
{t('Log in to run this spell (it uses $me or $contacts).')}
</div>
) : ( ) : (
<div className="text-muted-foreground py-8 text-center"> <div className="py-8 text-center text-muted-foreground">
{t('Could not run this spell. Check that it has a valid REQ/COUNT command, or add read relays in settings.')} {t(
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add read relays in settings.'
)}
</div> </div>
) )
) : ( ) : (
<div className="text-muted-foreground py-8 text-center">{t('Select a spell from the list to view its feed.')}</div> <div className="py-8 text-center text-muted-foreground">
{t('Select a spell to view its feed.')}
</div>
)} )}
</div> </div>
</div> </div>
<CreateSpellDialog <CreateSpellDialog open={createOpen} onOpenChange={setCreateOpen} onSaved={loadSpells} />
open={createOpen}
onOpenChange={setCreateOpen}
onSaved={loadSpells}
/>
<Dialog open={!!definitionSpell} onOpenChange={(open) => !open && setDefinitionSpell(null)}> <Dialog open={!!definitionSpell} onOpenChange={(open) => !open && setDefinitionSpell(null)}>
<DialogContent className="max-h-[85vh] overflow-y-auto max-w-lg"> <DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>{definitionSpell ? getSpellName(definitionSpell) : t('Spell definition')}</DialogTitle> <DialogTitle>
{definitionSpell ? getSpellName(definitionSpell) : t('Spell definition')}
</DialogTitle>
</DialogHeader> </DialogHeader>
{definitionSpell && ( {definitionSpell && (
<div className="space-y-4 text-sm"> <div className="space-y-4 text-sm">
{definitionSpell.content?.trim() && ( {definitionSpell.content?.trim() && (
<div> <div>
<div className="font-medium text-muted-foreground mb-1">{t('Description')}</div> <div className="mb-1 font-medium text-muted-foreground">{t('Description')}</div>
<p className="whitespace-pre-wrap break-words">{definitionSpell.content.trim()}</p> <p className="whitespace-pre-wrap break-words">{definitionSpell.content.trim()}</p>
</div> </div>
)} )}
<div> <div>
<div className="font-medium text-muted-foreground mb-2">{t('Tags')}</div> <div className="mb-2 font-medium text-muted-foreground">{t('Tags')}</div>
<dl className="space-y-1.5 font-mono text-xs"> <dl className="space-y-1.5 font-mono text-xs">
{definitionSpell.tags.map((tag, i) => ( {definitionSpell.tags.map((tag, i) => (
<div key={i} className="flex flex-wrap gap-x-2 gap-y-0.5"> <div key={i} className="flex flex-wrap gap-x-2 gap-y-0.5">
<dt className="text-muted-foreground shrink-0">{tag[0]}:</dt> <dt className="shrink-0 text-muted-foreground">{tag[0]}:</dt>
<dd className="break-all min-w-0"> <dd className="min-w-0 break-all">
{tag.length > 1 ? tag.slice(1).join(', ') : '—'} {tag.length > 1 ? tag.slice(1).join(', ') : '—'}
</dd> </dd>
</div> </div>
))} ))}
</dl> </dl>
</div> </div>
<div className="text-muted-foreground text-xs break-words overflow-wrap-anywhere"> <div className="overflow-wrap-anywhere break-words text-xs text-muted-foreground">
<span className="font-medium">id:</span>{' '} <span className="font-medium">id:</span> <span className="break-all">{definitionSpell.id}</span>
<span className="break-all">{definitionSpell.id}</span>
</div> </div>
</div> </div>
)} )}

36
src/services/spell.service.ts

@ -2,12 +2,12 @@
* NIP-A7 Spells: parse and execute kind 777 events as portable relay query filters. * NIP-A7 Spells: parse and execute kind 777 events as portable relay query filters.
*/ */
import { ExtendedKind } from '@/constants' import { ExtendedKind, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import type { Filter } from 'nostr-tools' import type { Filter } from 'nostr-tools'
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
const RELATIVE_UNIT_SECONDS: Record<string, number> = { const RELATIVE_UNIT_SECONDS: Record<string, number> = {
s: 1, s: 1,
@ -48,17 +48,41 @@ export type SpellExecutionContext = {
relayListRead: string[] relayListRead: string[]
} }
/** Default read relays for spells (deduped); merged after spell/user lists so outages on one relay still leave alternatives. */
function defaultSpellReadFallbackRelays(): string[] {
return dedupeRelayUrls([...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])
}
function dedupeRelayUrls(urls: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const u of urls) {
const key = normalizeUrl(u) || u
if (!key || seen.has(key)) continue
seen.add(key)
out.push(key)
}
return out
}
/** /**
* Get relay URLs for executing a spell: from spell's `relays` tag or fallback to context relay list / fast-read. * Get relay URLs for executing a spell: from spell's `relays` tag or context read list, always merged with
* app default read relays so a single down relay (503, etc.) does not block the feed.
*/ */
export function getRelaysForSpell(spell: Event, context: { relayListRead: string[] }): string[] { export function getRelaysForSpell(spell: Event, context: { relayListRead: string[] }): string[] {
let primary: string[] = []
const relayTag = spell.tags.find(tagNameEquals('relays')) const relayTag = spell.tags.find(tagNameEquals('relays'))
if (relayTag && relayTag.length > 1) { if (relayTag && relayTag.length > 1) {
const urls = relayTag.slice(1).filter((u): u is string => typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://'))) const urls = relayTag.slice(1).filter((u): u is string => typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://')))
if (urls.length) return urls if (urls.length) primary = urls
}
if (!primary.length && context.relayListRead.length) {
primary = [...context.relayListRead]
}
if (!primary.length) {
return defaultSpellReadFallbackRelays()
} }
if (context.relayListRead.length) return context.relayListRead return dedupeRelayUrls([...primary, ...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])
return [...new Set([...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])]
} }
/** /**

Loading…
Cancel
Save