Browse Source

bug-fixes and turn spells into a drawer on mobile

imwald
Silberengel 1 month ago
parent
commit
8c77a33553
  1. 2
      src/i18n/locales/de.ts
  2. 2
      src/i18n/locales/en.ts
  3. 318
      src/pages/primary/SpellsPage/index.tsx

2
src/i18n/locales/de.ts

@ -620,6 +620,8 @@ export default {
'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)',
spellPickerSectionYours: 'Deine Zaubersprüche',
Spells: 'Zaubersprüche', Spells: 'Zaubersprüche',
Tags: 'Tags', Tags: 'Tags',
Close: 'Schließen', Close: 'Schließen',

2
src/i18n/locales/en.ts

@ -783,6 +783,8 @@ export default {
'Filter value': 'Value', 'Filter value': 'Value',
'Add tag filter': 'Add tag filter', 'Add tag filter': 'Add tag filter',
spellPickerSectionYours: 'Your spells',
Spells: 'Spells', Spells: 'Spells',
'doublePane.secondaryEmpty': 'Open a note, profile, or settings item to show it here.', 'doublePane.secondaryEmpty': 'Open a note, profile, or settings item to show it here.',

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

@ -12,17 +12,25 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } 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 PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { cn } from '@/lib/utils'
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'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { formatPubkey } from '@/lib/pubkey'
import { import {
buildSpellCatalogAuthors, buildSpellCatalogAuthors,
getRelaysForSpell, getRelaysForSpell,
@ -39,11 +47,104 @@ import { TFeedSubRequest } from '@/types'
import { Check, ChevronDown, Copy, FileText, MoreVertical, Pencil, Plus, Star, Trash2, Wand2 } from 'lucide-react' import { Check, ChevronDown, Copy, FileText, MoreVertical, Pencil, Plus, Star, Trash2, Wand2 } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { verifyEvent } 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 { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog' import CreateSpellDialog from './CreateSpellDialog'
import type { TPageRef } from '@/types' 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<string, Event[]>()
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 (
<div className="flex items-center gap-2 border-b border-border/60 bg-muted/40 px-3 py-2">
<UserAvatar userId={userId} size="small" className="shrink-0" />
<Username
userId={userId}
className="min-w-0 text-sm font-semibold"
skeletonClassName="h-4 w-28"
/>
</div>
)
}
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 (
<button
type="button"
role="option"
aria-selected={selected}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selected && 'bg-accent/50'
)}
onClick={() => onPick(spell)}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{selected ? <Check className="size-4" aria-hidden /> : null}
</span>
<div className="flex min-w-0 flex-1 flex-col items-stretch gap-0.5">
<span className="truncate text-left text-sm font-medium leading-tight">{primary}</span>
{secondary ? (
<span className="truncate text-left text-xs text-muted-foreground">{secondary}</span>
) : null}
</div>
</button>
)
}
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()
@ -59,6 +160,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
/** True while fetching kind 777 authored by the user from write relays into IndexedDB */ /** True while fetching kind 777 authored by the user from write relays into IndexedDB */
const [spellsCatalogSyncing, setSpellsCatalogSyncing] = useState(false) const [spellsCatalogSyncing, setSpellsCatalogSyncing] = useState(false)
const spellCatalogCloserRef = useRef<(() => void) | null>(null) const spellCatalogCloserRef = useRef<(() => void) | null>(null)
const [spellPickerOpen, setSpellPickerOpen] = useState(false)
/** COUNT spells: per-relay breakdown + distinct total */ /** COUNT spells: per-relay breakdown + distinct total */
const [spellCount, setSpellCount] = useState<{ const [spellCount, setSpellCount] = useState<{
loading: boolean loading: boolean
@ -383,22 +485,18 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
} }
}, [spells, pubkey, contacts]) }, [spells, pubkey, contacts])
const followSpellGroups = useMemo(() => groupSpellsByPubkeySorted(followSpells), [followSpells])
const otherSpellGroups = useMemo(() => groupSpellsByPubkeySorted(otherSpells), [otherSpells])
const spellMenuLabel = useCallback( const spellMenuLabel = useCallback(
(spell: Event) => (favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)), (spell: Event) => (favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)),
[favoriteIds] [favoriteIds]
) )
const renderSpellMenuItem = useCallback( const pickSpell = useCallback((spell: Event | null) => {
(spell: Event) => ( setSelectedSpell(spell)
<DropdownMenuItem onSelect={() => setSelectedSpell(spell)} className="gap-2"> setSpellPickerOpen(false)
<span className="flex size-4 shrink-0 items-center justify-center"> }, [])
{selectedSpell?.id === spell.id ? <Check className="size-4" aria-hidden /> : null}
</span>
<span className="min-w-0 truncate">{spellMenuLabel(spell)}</span>
</DropdownMenuItem>
),
[selectedSpell?.id, spellMenuLabel]
)
const selectedSpellIsOwn = !!(pubkey && selectedSpell && selectedSpell.pubkey === pubkey) const selectedSpellIsOwn = !!(pubkey && selectedSpell && selectedSpell.pubkey === pubkey)
@ -428,74 +526,132 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4"> <div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
{/* Spell picker + actions above the feed */} {/* Spell picker + actions above the feed */}
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3"> <div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<DropdownMenu> <>
<DropdownMenuTrigger asChild> <Button
<Button type="button"
variant="outline" variant="outline"
disabled={spellsForSelect.length === 0} disabled={spellsForSelect.length === 0}
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md" className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
title={selectedSpell ? spellMenuLabel(selectedSpell) : undefined} title={selectedSpell ? spellMenuLabel(selectedSpell) : undefined}
> aria-haspopup="dialog"
<span className="truncate"> aria-expanded={spellPickerOpen}
{selectedSpell ? spellMenuLabel(selectedSpell) : t('Select a spell…')} onClick={() => setSpellPickerOpen(true)}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-[min(24rem,70vh)] w-[var(--radix-dropdown-menu-trigger-width)] min-w-[12rem] overflow-y-auto sm:max-w-md"
> >
<DropdownMenuItem onSelect={() => setSelectedSpell(null)} className="gap-2"> <span className="truncate">
<span className="flex size-4 shrink-0 items-center justify-center"> {selectedSpell ? spellMenuLabel(selectedSpell) : t('Select a spell…')}
{!selectedSpell ? <Check className="size-4" aria-hidden /> : null} </span>
</span> <ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden />
<span className="truncate">{t('Select a spell…')}</span> </Button>
</DropdownMenuItem>
{(ownSpells.length > 0 || followSpells.length > 0 || otherSpells.length > 0) && ( <Sheet open={spellPickerOpen} onOpenChange={setSpellPickerOpen}>
<DropdownMenuSeparator /> <SheetContent
)} side="bottom"
{ownSpells.map((spell) => ( className="flex max-h-[min(92dvh,40rem)] flex-col gap-0 rounded-t-2xl p-0 sm:max-h-[75vh]"
<Fragment key={spell.id}>{renderSpellMenuItem(spell)}</Fragment> >
))} <SheetHeader className="shrink-0 space-y-0 border-b px-4 py-3 text-left">
{ownSpells.length > 0 && (followSpells.length > 0 || otherSpells.length > 0) && ( <SheetTitle className="text-base">{t('Select a spell…')}</SheetTitle>
<DropdownMenuSeparator /> </SheetHeader>
)}
{followSpells.length > 0 ? ( <div
<DropdownMenuSub> className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-2"
<DropdownMenuSubTrigger className="cursor-default"> role="listbox"
{t('Spells from follows', { count: followSpells.length })} aria-label={t('Select a spell…')}
</DropdownMenuSubTrigger> >
<DropdownMenuSubContent <button
className="max-h-[50vh] min-w-[12rem] overflow-y-auto sm:min-w-[16rem]" type="button"
showScrollButtons role="option"
> aria-selected={!selectedSpell}
{followSpells.map((spell) => ( className={cn(
<Fragment key={spell.id}>{renderSpellMenuItem(spell)}</Fragment> 'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
))} 'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
</DropdownMenuSubContent> !selectedSpell && 'bg-accent/50'
</DropdownMenuSub> )}
) : null} onClick={() => pickSpell(null)}
{otherSpells.length > 0 && (ownSpells.length > 0 || followSpells.length > 0) ? (
<DropdownMenuSeparator />
) : null}
{otherSpells.length > 0 ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-default">
{t('Other spells', { count: otherSpells.length })}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[50vh] min-w-[12rem] overflow-y-auto sm:min-w-[16rem]"
showScrollButtons
> >
{otherSpells.map((spell) => ( <span className="flex size-4 shrink-0 items-center justify-center">
<Fragment key={spell.id}>{renderSpellMenuItem(spell)}</Fragment> {!selectedSpell ? <Check className="size-4" aria-hidden /> : null}
))} </span>
</DropdownMenuSubContent> <span className="min-w-0 flex-1 truncate text-left font-normal text-muted-foreground">
</DropdownMenuSub> {t('Select a spell…')}
) : null} </span>
</DropdownMenuContent> </button>
</DropdownMenu>
{ownSpells.length > 0 ? (
<>
<Separator className="my-2" />
<p className="px-3 pb-1 pt-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('spellPickerSectionYours')}
</p>
{ownSpells.map((spell) => (
<SpellSheetOptionRow
key={spell.id}
spell={spell}
selected={selectedSpell?.id === spell.id}
accountPubkey={pubkey ?? undefined}
labelFor={spellMenuLabel}
onPick={pickSpell}
/>
))}
</>
) : null}
{followSpells.length > 0 ? (
<>
<Separator className="my-2" />
<p className="px-3 pb-1 pt-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('Spells from follows', { count: followSpells.length })}
</p>
{followSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => (
<div key={authorPk} className="mt-2 overflow-hidden rounded-lg border border-border/60">
<SpellSheetAuthorHeader userId={authorPk} />
<div className="px-0.5 py-0.5">
{groupSpells.map((spell) => (
<SpellSheetOptionRow
key={spell.id}
spell={spell}
selected={selectedSpell?.id === spell.id}
accountPubkey={pubkey ?? undefined}
labelFor={spellMenuLabel}
onPick={pickSpell}
groupedUnderAuthor
/>
))}
</div>
</div>
))}
</>
) : null}
{otherSpells.length > 0 ? (
<>
<Separator className="my-2" />
<p className="px-3 pb-1 pt-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('Other spells', { count: otherSpells.length })}
</p>
{otherSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => (
<div key={authorPk} className="mt-2 overflow-hidden rounded-lg border border-border/60">
<SpellSheetAuthorHeader userId={authorPk} />
<div className="px-0.5 py-0.5">
{groupSpells.map((spell) => (
<SpellSheetOptionRow
key={spell.id}
spell={spell}
selected={selectedSpell?.id === spell.id}
accountPubkey={pubkey ?? undefined}
labelFor={spellMenuLabel}
onPick={pickSpell}
groupedUnderAuthor
/>
))}
</div>
</div>
))}
</>
) : null}
</div>
</SheetContent>
</Sheet>
</>
<div className="flex shrink-0 flex-wrap items-center gap-2"> <div className="flex shrink-0 flex-wrap items-center gap-2">
<Button <Button

Loading…
Cancel
Save