Browse Source

favorite spells

imwald
Silberengel 1 month ago
parent
commit
a6011fd90c
  1. 3
      src/i18n/locales/de.ts
  2. 3
      src/i18n/locales/en.ts
  3. 16
      src/pages/primary/SpellsPage/CreateSpellDialog.tsx
  4. 175
      src/pages/primary/SpellsPage/index.tsx

3
src/i18n/locales/de.ts

@ -1438,6 +1438,9 @@ export default { @@ -1438,6 +1438,9 @@ export default {
'Subscribing...': 'Subscribing...',
Summary: 'Summary',
'Unknown note reference tags': 'Referenz-Tags (e, p, q, a)',
'Starred spells': 'Markierte Sprüche',
'Spell star add title': 'Spruch markieren (wird zu deinen Nostr-Lesezeichen hinzugefügt)',
'Spell star remove title': 'Markierung aufheben (aus Nostr-Lesezeichen entfernen)',
'Supported Event Types': 'Supported Event Types',
'Take a note': 'Take a note',
'The full prompt conversation (optional)': 'The full prompt conversation (optional)',

3
src/i18n/locales/en.ts

@ -413,6 +413,9 @@ export default { @@ -413,6 +413,9 @@ export default {
'Unknown note tagged pubkey': 'Tagged pubkey',
'Unknown note tagged content': 'Content',
'Unknown note reference tags': 'Reference tags (e, p, q, a)',
'Starred spells': 'Starred spells',
'Spell star add title': 'Star spell (adds to your Nostr bookmarks)',
'Spell star remove title': 'Unstar spell (removes from your Nostr bookmarks)',
'Copy JSON': 'Copy JSON',
Verse: 'Verse',
'Notification reaction summary': 'reacted to this note.',

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

@ -18,6 +18,7 @@ import { @@ -18,6 +18,7 @@ import {
dedupeAppendIds,
resolveSpellListATags
} from '@/lib/spell-list-import'
import { useBookmarks } from '@/providers/BookmarksProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback'
@ -290,6 +291,7 @@ export default function CreateSpellDialog({ @@ -290,6 +291,7 @@ export default function CreateSpellDialog({
}) {
const { t } = useTranslation()
const { pubkey, publish, checkLogin, relayList } = useNostr()
const { addBookmark, removeBookmark } = useBookmarks()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [form, setForm] = useState<TSpellDraftParams>(DEFAULT_PARAMS)
const [saving, setSaving] = useState(false)
@ -402,8 +404,20 @@ export default function CreateSpellDialog({ @@ -402,8 +404,20 @@ export default function CreateSpellDialog({
if (replaceSpellId) {
await indexedDb.deleteSpellEvent(replaceSpellId)
const favs = await indexedDb.getSpellFavoriteIds()
const ridLower = replaceSpellId.toLowerCase()
const wasStarred = favs.some((id) => id.toLowerCase() === ridLower)
if (favs.length) {
await indexedDb.setSpellFavoriteIds(favs.map((id) => (id === replaceSpellId ? event.id : id)))
await indexedDb.setSpellFavoriteIds(
favs.map((id) => (id.toLowerCase() === ridLower ? event.id : id))
)
}
if (wasStarred && spellToEdit) {
try {
await removeBookmark(spellToEdit)
await addBookmark(event)
} catch (e) {
logger.warn('[CreateSpellDialog] Bookmark migrate after spell edit failed', e)
}
}
}
await indexedDb.putSpellEvent(event)

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

@ -27,6 +27,7 @@ import { cn } from '@/lib/utils' @@ -27,6 +27,7 @@ import { cn } from '@/lib/utils'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useBookmarks } from '@/providers/BookmarksProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/contexts/user-trust-context'
@ -169,7 +170,11 @@ function SpellSheetOptionRow({ @@ -169,7 +170,11 @@ function SpellSheetOptionRow({
accountPubkey,
labelFor,
onPick,
groupedUnderAuthor = false
groupedUnderAuthor = false,
starred = false,
onToggleStar,
starTitleAdd,
starTitleRemove
}: {
spell: Event
selected: boolean
@ -178,19 +183,30 @@ function SpellSheetOptionRow({ @@ -178,19 +183,30 @@ function SpellSheetOptionRow({
onPick: (e: Event) => void
/** Author shown in a header above this block — hide npub under each row */
groupedUnderAuthor?: boolean
starred?: boolean
onToggleStar?: (spell: Event) => void
starTitleAdd?: string
starTitleRemove?: string
}) {
const { t } = useTranslation()
const { primary, secondary } = spellPickerPrimaryAndSecondary(spell, accountPubkey, labelFor, {
omitAuthorNpub: groupedUnderAuthor
})
return (
<div
className={cn(
'flex w-full items-stretch gap-0.5 rounded-lg transition-colors',
selected && 'bg-accent/50'
)}
>
<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'
'flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors',
'hover:bg-accent/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'rounded-lg'
)}
onClick={() => onPick(spell)}
>
@ -198,12 +214,31 @@ function SpellSheetOptionRow({ @@ -198,12 +214,31 @@ function SpellSheetOptionRow({
{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>
<span className="truncate text-left font-medium leading-tight">{primary}</span>
{secondary ? (
<span className="truncate text-left text-xs text-muted-foreground">{secondary}</span>
) : null}
</div>
</button>
{onToggleStar ? (
<button
type="button"
className="flex shrink-0 items-center justify-center rounded-lg px-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={starred ? starTitleRemove ?? t('Remove from favorites') : starTitleAdd ?? t('Add to favorites')}
aria-label={starred ? starTitleRemove ?? t('Remove from favorites') : starTitleAdd ?? t('Add to favorites')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onToggleStar(spell)
}}
>
<Star
className={cn('size-4', starred && 'fill-amber-400 text-amber-500')}
aria-hidden
/>
</button>
) : null}
</div>
)
}
@ -276,6 +311,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -276,6 +311,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const { t } = useTranslation()
const { navigate: navigatePrimary } = usePrimaryPage()
const { pubkey, relayList, attemptDelete, bookmarkListEvent, interestListEvent } = useNostr()
const { addBookmark, removeBookmark } = useBookmarks()
const { hideUntrustedNotifications } = useUserTrust()
const { isSmallScreen } = useScreenSize()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
@ -287,7 +323,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -287,7 +323,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
} = useKindFilter()
const hideRepliesFollowing = useNoteListHideReplies()
const [spells, setSpells] = useState<Event[]>([])
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set())
/** Ordered spell event ids (newest star first). Drives picker order + bookmark list sync when logged in. */
const [favoriteSpellIds, setFavoriteSpellIds] = useState<string[]>([])
const [selectedSpell, setSelectedSpell] = useState<Event | null>(null)
const [selectedFauxSpell, setSelectedFauxSpell] = useState<string | null>(null)
const [followSetListEvents, setFollowSetListEvents] = useState<Event[]>([])
@ -361,7 +398,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -361,7 +398,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
indexedDb.getSpellFavoriteIds()
])
setSpells(events)
setFavoriteIds(new Set(ids))
setFavoriteSpellIds(ids)
}, [])
const refreshSpellsFeedAndCatalog = useCallback(() => {
@ -891,14 +928,47 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -891,14 +928,47 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return () => removeRelayUrls(urls)
}, [spellBrowseRelayUrlsKey, addRelayUrls, removeRelayUrls])
const toggleFavorite = useCallback(async (spellId: string) => {
const favoriteSpellSet = useMemo(
() => new Set(favoriteSpellIds.map((id) => id.toLowerCase())),
[favoriteSpellIds]
)
const toggleFavoriteSpell = useCallback(
async (spell: Event) => {
const sid = spell.id
const sidLower = sid.toLowerCase()
const ids = await indexedDb.getSpellFavoriteIds()
const set = new Set(ids)
if (set.has(spellId)) set.delete(spellId)
else set.add(spellId)
await indexedDb.setSpellFavoriteIds([...set])
setFavoriteIds(set)
}, [])
const exists = ids.some((id) => id.toLowerCase() === sidLower)
if (!exists) {
if (pubkey) {
try {
await addBookmark(spell)
} catch (e) {
logger.error('[SpellsPage] addBookmark for starred spell failed', e)
showPublishingError(e instanceof Error ? e : new Error(String(e)))
return
}
}
const next = [sid, ...ids.filter((id) => id.toLowerCase() !== sidLower)]
await indexedDb.setSpellFavoriteIds(next)
setFavoriteSpellIds(next)
return
}
if (pubkey) {
try {
await removeBookmark(spell)
} catch (e) {
logger.error('[SpellsPage] removeBookmark for starred spell failed', e)
showPublishingError(e instanceof Error ? e : new Error(String(e)))
return
}
}
const next = ids.filter((id) => id.toLowerCase() !== sidLower)
await indexedDb.setSpellFavoriteIds(next)
setFavoriteSpellIds(next)
},
[pubkey, addBookmark, removeBookmark]
)
const handleDeleteSpell = useCallback(
async (spell: Event) => {
@ -912,7 +982,15 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -912,7 +982,15 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
try {
await indexedDb.deleteSpellEvent(spell.id)
const ids = await indexedDb.getSpellFavoriteIds()
await indexedDb.setSpellFavoriteIds(ids.filter((id) => id !== spell.id))
const wasStarred = ids.some((id) => id.toLowerCase() === spell.id.toLowerCase())
if (pubkey && wasStarred) {
try {
await removeBookmark(spell)
} catch (e) {
logger.warn('[SpellsPage] removeBookmark after spell delete failed', e)
}
}
await indexedDb.setSpellFavoriteIds(ids.filter((id) => id.toLowerCase() !== spell.id.toLowerCase()))
if (selectedSpell?.id === spell.id) setSelectedSpell(null)
await loadSpells()
} catch (e) {
@ -922,9 +1000,19 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -922,9 +1000,19 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
)
}
},
[attemptDelete, loadSpells, selectedSpell?.id, t]
[attemptDelete, loadSpells, pubkey, removeBookmark, selectedSpell?.id, t]
)
const starredSpellsForPicker = useMemo(() => {
const byId = new Map<string, Event>()
for (const s of spells) {
byId.set(s.id.toLowerCase(), s)
}
return favoriteSpellIds
.map((id) => byId.get(id.toLowerCase()))
.filter((s): s is Event => s != null)
}, [spells, favoriteSpellIds])
const { ownSpells, followSpells, otherSpells, spellsForSelect } = useMemo(() => {
const byName = (a: Event, b: Event) =>
getSpellName(a).localeCompare(getSpellName(b), undefined, { sensitivity: 'base' })
@ -935,6 +1023,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -935,6 +1023,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const other: Event[] = []
for (const s of spells) {
if (favoriteSpellSet.has(s.id.toLowerCase())) continue
if (pubkey && s.pubkey === pubkey) own.push(s)
else if (followSet.has(s.pubkey)) follow.push(s)
else other.push(s)
@ -950,7 +1039,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -950,7 +1039,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
otherSpells: other,
spellsForSelect: [...own, ...follow, ...other]
}
}, [spells, pubkey, contacts])
}, [spells, pubkey, contacts, favoriteSpellSet])
const followSpellGroups = useMemo(() => groupSpellsByPubkeySorted(followSpells), [followSpells])
const otherSpellGroups = useMemo(() => groupSpellsByPubkeySorted(otherSpells), [otherSpells])
@ -1011,8 +1100,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1011,8 +1100,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
}, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey])
const spellMenuLabel = useCallback(
(spell: Event) => (favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)),
[favoriteIds]
(spell: Event) =>
favoriteSpellSet.has(spell.id.toLowerCase()) ? `${getSpellName(spell)}` : getSpellName(spell),
[favoriteSpellSet]
)
const selectedFauxSpellDisplayLabel = useMemo(() => {
@ -1133,8 +1223,33 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1133,8 +1223,33 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
(isFollowSetSpellId(selectedFauxSpell) && followSetCatalogLoading))
)
const spellStarAddTitle = t('Spell star add title')
const spellStarRemoveTitle = t('Spell star remove title')
const spellPickerList = (
<>
{starredSpellsForPicker.length > 0 ? (
<>
<p className="px-3 pb-1 pt-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('Starred spells')}
</p>
{starredSpellsForPicker.map((spell) => (
<SpellSheetOptionRow
key={spell.id}
spell={spell}
selected={selectedSpell?.id === spell.id}
accountPubkey={pubkey ?? undefined}
labelFor={(e) => getSpellName(e)}
onPick={pickSpell}
starred
onToggleStar={(s) => void toggleFavoriteSpell(s)}
starTitleAdd={spellStarAddTitle}
starTitleRemove={spellStarRemoveTitle}
/>
))}
<Separator className="my-2" />
</>
) : null}
{FAUX_SPELL_ORDER.flatMap((name) => {
if (
(name === 'notifications' ||
@ -1235,6 +1350,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1235,6 +1350,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
accountPubkey={pubkey ?? undefined}
labelFor={spellMenuLabel}
onPick={pickSpell}
starred={favoriteSpellSet.has(spell.id.toLowerCase())}
onToggleStar={(s) => void toggleFavoriteSpell(s)}
starTitleAdd={spellStarAddTitle}
starTitleRemove={spellStarRemoveTitle}
/>
))}
</>
@ -1259,6 +1378,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1259,6 +1378,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
labelFor={spellMenuLabel}
onPick={pickSpell}
groupedUnderAuthor
starred={favoriteSpellSet.has(spell.id.toLowerCase())}
onToggleStar={(s) => void toggleFavoriteSpell(s)}
starTitleAdd={spellStarAddTitle}
starTitleRemove={spellStarRemoveTitle}
/>
))}
</div>
@ -1286,6 +1409,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1286,6 +1409,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
labelFor={spellMenuLabel}
onPick={pickSpell}
groupedUnderAuthor
starred={favoriteSpellSet.has(spell.id.toLowerCase())}
onToggleStar={(s) => void toggleFavoriteSpell(s)}
starTitleAdd={spellStarAddTitle}
starTitleRemove={spellStarRemoveTitle}
/>
))}
</div>
@ -1444,14 +1571,14 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1444,14 +1571,14 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
size="icon"
className="shrink-0"
title={
favoriteIds.has(selectedSpell.id)
? t('Remove from favorites')
: t('Add to favorites')
favoriteSpellSet.has(selectedSpell.id.toLowerCase())
? t('Spell star remove title')
: t('Spell star add title')
}
onClick={() => toggleFavorite(selectedSpell.id)}
onClick={() => void toggleFavoriteSpell(selectedSpell)}
>
<Star
className={`size-4 ${favoriteIds.has(selectedSpell.id) ? 'fill-amber-400 text-amber-500' : ''}`}
className={`size-4 ${favoriteSpellSet.has(selectedSpell.id.toLowerCase()) ? 'fill-amber-400 text-amber-500' : ''}`}
/>
</Button>
<DropdownMenu>

Loading…
Cancel
Save