From 8c77a3355342bad35a2a8b44845d300035cb8950 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 19 Mar 2026 14:42:37 +0100 Subject: [PATCH] bug-fixes and turn spells into a drawer on mobile --- src/i18n/locales/de.ts | 2 + src/i18n/locales/en.ts | 2 + src/pages/primary/SpellsPage/index.tsx | 318 ++++++++++++++++++------- 3 files changed, 241 insertions(+), 81 deletions(-) diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 56580dd8..fa3b40d3 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -620,6 +620,8 @@ export default { 'shortcuts.scrollWhenFocused': 'Den fokussierten scrollbaren Bereich scrollen', 'shortcuts.browserBack': 'Zurück im Browser (Verlauf)', + spellPickerSectionYours: 'Deine Zaubersprüche', + Spells: 'Zaubersprüche', Tags: 'Tags', Close: 'Schließen', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 5f4b277c..bd3fa681 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -783,6 +783,8 @@ export default { 'Filter value': 'Value', 'Add tag filter': 'Add tag filter', + spellPickerSectionYours: 'Your spells', + Spells: 'Spells', 'doublePane.secondaryEmpty': 'Open a note, profile, or settings item to show it here.', diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 1c23c10c..351224b6 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -12,17 +12,25 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Separator } from '@/components/ui/separator' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet' +import UserAvatar from '@/components/UserAvatar' +import Username from '@/components/Username' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import logger from '@/lib/logger' +import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import { ExtendedKind } from '@/constants' +import { formatPubkey } from '@/lib/pubkey' import { buildSpellCatalogAuthors, getRelaysForSpell, @@ -39,11 +47,104 @@ import { TFeedSubRequest } from '@/types' import { Check, ChevronDown, Copy, FileText, MoreVertical, Pencil, Plus, Star, Trash2, Wand2 } from 'lucide-react' import type { Event } from 'nostr-tools' import { verifyEvent } from 'nostr-tools' -import { Fragment, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import CreateSpellDialog from './CreateSpellDialog' import type { TPageRef } from '@/types' +/** Primary + optional subtitle (npub and/or short id). When grouped under an author header, omit npub. */ +function spellPickerPrimaryAndSecondary( + spell: Event, + accountPubkey: string | undefined, + labelFor: (e: Event) => string, + options?: { omitAuthorNpub?: boolean } +) { + const primary = labelFor(spell) + const isOwn = !!(accountPubkey && spell.pubkey === accountPubkey) + const shortTitle = primary.trim().length < 4 + const secondaryParts: string[] = [] + if (!isOwn && !options?.omitAuthorNpub) secondaryParts.push(formatPubkey(spell.pubkey)) + if (shortTitle) secondaryParts.push(`${spell.id.slice(0, 8)}…`) + return { + primary, + secondary: secondaryParts.length > 0 ? secondaryParts.join(' · ') : null + } +} + +function groupSpellsByPubkeySorted(spells: Event[]): { pubkey: string; spells: Event[] }[] { + const map = new Map() + for (const s of spells) { + const list = map.get(s.pubkey) + if (list) list.push(s) + else map.set(s.pubkey, [s]) + } + for (const list of map.values()) { + list.sort((a, b) => + getSpellName(a).localeCompare(getSpellName(b), undefined, { sensitivity: 'base' }) + ) + } + return [...map.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([pubkey, list]) => ({ pubkey, spells: list })) +} + +function SpellSheetAuthorHeader({ userId }: { userId: string }) { + return ( +
+ + +
+ ) +} + +function SpellSheetOptionRow({ + spell, + selected, + accountPubkey, + labelFor, + onPick, + groupedUnderAuthor = false +}: { + spell: Event + selected: boolean + accountPubkey: string | undefined + labelFor: (e: Event) => string + onPick: (e: Event) => void + /** Author shown in a header above this block — hide npub under each row */ + groupedUnderAuthor?: boolean +}) { + const { primary, secondary } = spellPickerPrimaryAndSecondary(spell, accountPubkey, labelFor, { + omitAuthorNpub: groupedUnderAuthor + }) + return ( + + ) +} + const SpellsPage = forwardRef(function SpellsPage(_, ref) { const { t } = useTranslation() const { pubkey, relayList } = useNostr() @@ -59,6 +160,7 @@ const SpellsPage = forwardRef(function SpellsPage(_, ref) { /** True while fetching kind 777 authored by the user from write relays into IndexedDB */ const [spellsCatalogSyncing, setSpellsCatalogSyncing] = useState(false) const spellCatalogCloserRef = useRef<(() => void) | null>(null) + const [spellPickerOpen, setSpellPickerOpen] = useState(false) /** COUNT spells: per-relay breakdown + distinct total */ const [spellCount, setSpellCount] = useState<{ loading: boolean @@ -383,22 +485,18 @@ const SpellsPage = forwardRef(function SpellsPage(_, ref) { } }, [spells, pubkey, contacts]) + const followSpellGroups = useMemo(() => groupSpellsByPubkeySorted(followSpells), [followSpells]) + const otherSpellGroups = useMemo(() => groupSpellsByPubkeySorted(otherSpells), [otherSpells]) + const spellMenuLabel = useCallback( (spell: Event) => (favoriteIds.has(spell.id) ? `★ ${getSpellName(spell)}` : getSpellName(spell)), [favoriteIds] ) - const renderSpellMenuItem = useCallback( - (spell: Event) => ( - setSelectedSpell(spell)} className="gap-2"> - - {selectedSpell?.id === spell.id ? : null} - - {spellMenuLabel(spell)} - - ), - [selectedSpell?.id, spellMenuLabel] - ) + const pickSpell = useCallback((spell: Event | null) => { + setSelectedSpell(spell) + setSpellPickerOpen(false) + }, []) const selectedSpellIsOwn = !!(pubkey && selectedSpell && selectedSpell.pubkey === pubkey) @@ -428,74 +526,132 @@ const SpellsPage = forwardRef(function SpellsPage(_, ref) {
{/* Spell picker + actions above the feed */}
- - - - - + + + + + + {t('Select a spell…')} + + +
+ + + {ownSpells.length > 0 ? ( + <> + +

+ {t('spellPickerSectionYours')} +

+ {ownSpells.map((spell) => ( + + ))} + + ) : null} + + {followSpells.length > 0 ? ( + <> + +

+ {t('Spells from follows', { count: followSpells.length })} +

+ {followSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => ( +
+ +
+ {groupSpells.map((spell) => ( + + ))} +
+
+ ))} + + ) : null} + + {otherSpells.length > 0 ? ( + <> + +

+ {t('Other spells', { count: otherSpells.length })} +

+ {otherSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => ( +
+ +
+ {groupSpells.map((spell) => ( + + ))} +
+
+ ))} + + ) : null} +
+
+
+