From f492532fee9c6d37bf2a8541cf26ed95921d566f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 19 Mar 2026 08:45:49 +0100 Subject: [PATCH] fix double-pane mode make spells page vertically-aligned --- src/PageManager.tsx | 60 ++- src/components/CacheRelaysSetting/index.tsx | 58 +-- .../MailboxSetting/NewMailboxRelayInput.tsx | 11 +- src/i18n/locales/de.ts | 75 +++- src/i18n/locales/en.ts | 23 +- src/layouts/PrimaryPageLayout/index.tsx | 12 +- src/layouts/SecondaryPageLayout/index.tsx | 4 +- src/lib/draft-event.ts | 19 +- .../primary/SpellsPage/CreateSpellDialog.tsx | 410 +++++++++++------- src/pages/primary/SpellsPage/index.tsx | 212 +++++---- src/services/spell.service.ts | 36 +- 11 files changed, 594 insertions(+), 326 deletions(-) diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 5c03eeae..05441c9b 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -31,8 +31,10 @@ import { cloneElement, createContext, createRef, + lazy, ReactNode, RefObject, + Suspense, useCallback, useContext, useEffect, @@ -40,6 +42,7 @@ import { useState } from 'react' import BottomNavigationBar from './components/BottomNavigationBar' +import { useTranslation } from 'react-i18next' import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog' import { normalizeUrl } from './lib/url' import ExplorePage from './pages/primary/ExplorePage' @@ -49,8 +52,10 @@ import ProfilePage from './pages/primary/ProfilePage' import RelayPage from './pages/primary/RelayPage' import SearchPage from './pages/primary/SearchPage' import DiscussionsPage from './pages/primary/DiscussionsPage' -import SpellsPage from './pages/primary/SpellsPage' 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 modalManager from './services/modal-manager.service' import CreateWalletGuideToast from './components/CreateWalletGuideToast' @@ -100,7 +105,17 @@ const getPrimaryPageMap = () => ({ relay: , search: , discussions: , - spells: + spells: ( + + Loading… + + } + > + + + ) }) // Type for primary page names - use the return type of getPrimaryPageMap @@ -493,13 +508,13 @@ function MainContentArea({ 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 ( -
-
+
+
{primaryNoteView ? ( // Show note view with back button -
+
Imwald @@ -539,10 +554,10 @@ function MainContentArea({ return (
{(() => { try { @@ -564,6 +579,7 @@ function MainContentArea({ } export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { + const { t } = useTranslation() const { isSmallScreen } = useScreenSize() // DEPRECATED: showRecommendedRelaysPanel removed - double-panel functionality disabled const [currentPrimaryPage, setCurrentPrimaryPage] = useState('home') @@ -1574,10 +1590,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { if (panelMode === 'double') { // Double-pane mode: show feed on left (flexible, maintains width), secondary stack on right (1042px, same as drawer) return ( -
- {/* Left panel: Feed (flexible, takes remaining space after 1042px) */} -
- + {/* Left: primary column — must be a flex column so MainContentArea flex-1 gets height */} +
+
- {/* Right panel: Secondary stack (1042px fixed width, same as drawer) */} -
+ {/* Right: secondary stack — max width so left pane keeps space on small desktops */} +
{secondaryStack.length > 0 ? ( secondaryStack.map((item, index) => { const isLast = index === secondaryStack.length - 1 return (
{item.component}
) }) ) : ( -
- {/* Empty state - no secondary content */} +
+

{t('doublePane.secondaryEmpty')}

+

{t('doublePane.secondaryEmptyHint')}

)}
diff --git a/src/components/CacheRelaysSetting/index.tsx b/src/components/CacheRelaysSetting/index.tsx index af82915a..a415a300 100644 --- a/src/components/CacheRelaysSetting/index.tsx +++ b/src/components/CacheRelaysSetting/index.tsx @@ -848,45 +848,25 @@ export default function CacheRelaysSetting() {
{t('Clear cached data stored in your browser, including IndexedDB events, localStorage settings, and service worker caches.')}
-
- - - - -
@@ -1285,15 +1265,15 @@ export default function CacheRelaysSetting() {
-
-
+
+
setConsoleLogSearch(e.target.value)} - className="flex-1" + className="min-w-0 flex-1 basis-[min(100%,12rem)]" /> -
+
-
-
+
+
setConsoleLogSearch(e.target.value)} - className="flex-1" + className="min-w-0 flex-1 basis-[min(100%,12rem)]" /> -
+
+
{newRelayUrlError &&
{newRelayUrlError}
}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index a84ab479..7ca577e1 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -618,6 +618,79 @@ export default { 'shortcuts.activate': 'Schaltflächen und viele Steuerelemente auslösen', 'shortcuts.closeOverlays': 'Dialoge, Menüs und Such-Dropdown schließen', '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.' } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 38d13042..b3d75077 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -699,6 +699,27 @@ export default { 'shortcuts.activate': 'Activate buttons and many controls', 'shortcuts.closeOverlays': 'Close dialogs, menus, and the search dropdown', '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.' } } diff --git a/src/layouts/PrimaryPageLayout/index.tsx b/src/layouts/PrimaryPageLayout/index.tsx index b01dc908..093f35e1 100644 --- a/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/layouts/PrimaryPageLayout/index.tsx @@ -117,15 +117,21 @@ const PrimaryPageLayout = forwardRef( return ( -
+
{titlebar} - {subHeader &&
{subHeader}
} + {subHeader && ( +
{subHeader}
+ )}
{children}
diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index 3b030449..e6c3e5dc 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -113,7 +113,7 @@ const SecondaryPageLayout = forwardRef( return ( -
+
{title && ( <>
@@ -133,7 +133,7 @@ const SecondaryPageLayout = forwardRef(
{children}
diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 445890c9..55cdcaaf 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -612,9 +612,14 @@ export function createSpellDraftEvent(params: TSpellDraftParams): TDraftEvent { const tags: string[][] = [['cmd', params.cmd]] if (params.name?.trim()) tags.push(['name', params.name.trim()]) if (params.alt?.trim()) tags.push(['alt', params.alt.trim()]) - params.kinds.filter((k) => k.trim()).forEach((k) => tags.push(['k', k.trim()])) - if (params.authors.length) tags.push(['authors', ...params.authors]) - if (params.ids.length) tags.push(['ids', ...params.ids]) + params.kinds + .map((k) => k.trim()) + .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 }) => { if (letter?.trim() && values.some((v) => v?.trim())) { 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.until.trim()) tags.push(['until', params.until.trim()]) if (params.search.trim()) tags.push(['search', params.search.trim()]) - if (params.relays.length) tags.push(['relays', ...params.relays]) - params.topics.filter((t) => t?.trim()).forEach((t) => tags.push(['t', t.trim()])) + const relays = params.relays.map((r) => r.trim()).filter(Boolean) + 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']) return { kind: ExtendedKind.SPELL, diff --git a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx index c26c37df..7136c6d7 100644 --- a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx +++ b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx @@ -12,11 +12,25 @@ import { createSpellDraftEvent, type TSpellDraftParams } from '@/lib/draft-event import { useNostr } from '@/providers/NostrProvider' import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback' 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 { useState } from 'react' +import { useCallback, useRef, useState } from 'react' 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 = { cmd: 'REQ', content: '', @@ -35,6 +49,82 @@ const DEFAULT_PARAMS: TSpellDraftParams = { 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 ( +
+ +
+ {rows.map((v, i) => ( +
+ updateAt(i, e.target.value)} + placeholder={placeholder} + className="min-w-0 flex-1" + /> + +
+ ))} +
+ + {hint ?

{hint}

: null} +
+ ) +} + export default function CreateSpellDialog({ open, onOpenChange, @@ -48,6 +138,31 @@ export default function CreateSpellDialog({ const { pubkey, publish, checkLogin } = useNostr() const [form, setForm] = useState(DEFAULT_PARAMS) const [saving, setSaving] = useState(false) + const scrollBodyRef = useRef(null) + + const handleScrollBodyKeyDown = useCallback((e: React.KeyboardEvent) => { + 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 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 ( - - - {t('Create a Spell')} + + {/* Fixed top: not inside overflow-y-auto so title/intro never scroll away or clip */} +
- -

- {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.')} -

+ + {t('Create a Spell')} + +

+ {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.' + )} +

+
-
-
- - -

{t('REQ returns a feed; COUNT returns a number.')}

-
+
+
+
+ + +

{t('REQ returns a feed; COUNT returns a number.')}

+
-
- - setForm((f) => ({ ...f, name: e.target.value }))} - placeholder={t('Human-readable spell name')} - /> -
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + placeholder={t('Human-readable spell name')} + /> +
-
- -