mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 23:47:12 +02:00
feat: favorite spells
This commit is contained in:
149
src/components/FavoriteSpellsDropdown.tsx
Normal file
149
src/components/FavoriteSpellsDropdown.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { WandSparkles, Play, Star } from "lucide-react";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import { useFavoriteSpells } from "@/hooks/useFavoriteSpells";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { useAddWindow } from "@/core/state";
|
||||
import { decodeSpell } from "@/lib/spell-conversion";
|
||||
import { parseSpellCommand } from "@/lib/spell-cast";
|
||||
import type { SpellEvent } from "@/types/spell";
|
||||
import type { EventPointer } from "nostr-tools/nip19";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { FAVORITE_SPELLS_KIND } from "@/constants/kinds";
|
||||
|
||||
/**
|
||||
* A single spell item in the dropdown
|
||||
*/
|
||||
function FavoriteSpellItem({ pointer }: { pointer: EventPointer }) {
|
||||
const spellEvent = useNostrEvent(pointer);
|
||||
const addWindow = useAddWindow();
|
||||
|
||||
if (!spellEvent) {
|
||||
return (
|
||||
<DropdownMenuItem disabled className="opacity-50">
|
||||
<WandSparkles className="size-3.5 mr-2 text-muted-foreground" />
|
||||
<span className="text-sm truncate">Loading...</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
let decoded: ReturnType<typeof decodeSpell> | null = null;
|
||||
try {
|
||||
decoded = decodeSpell(spellEvent as SpellEvent);
|
||||
} catch {
|
||||
// spell couldn't be decoded
|
||||
}
|
||||
|
||||
const handleCast = async () => {
|
||||
if (!decoded) return;
|
||||
const result = await parseSpellCommand(decoded.command);
|
||||
if (result) {
|
||||
addWindow(result.appId, result.props, result.commandString);
|
||||
}
|
||||
};
|
||||
|
||||
if (!decoded) {
|
||||
return (
|
||||
<DropdownMenuItem disabled className="opacity-50">
|
||||
<WandSparkles className="size-3.5 mr-2 text-muted-foreground" />
|
||||
<span className="text-sm truncate">Invalid spell</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={handleCast}
|
||||
className="cursor-pointer py-2 hover:bg-muted focus:bg-muted transition-colors"
|
||||
>
|
||||
<Play className="size-3.5 mr-2 text-muted-foreground" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm truncate">
|
||||
{decoded.name || "Unnamed Spell"}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{decoded.command}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wand dropdown in the header showing favorite spells for quick casting.
|
||||
* Only visible when the user is logged in.
|
||||
*/
|
||||
export function FavoriteSpellsDropdown() {
|
||||
const { isLoggedIn, pubkey } = useAccount();
|
||||
const { favorites, event } = useFavoriteSpells();
|
||||
const addWindow = useAddWindow();
|
||||
|
||||
if (!isLoggedIn) return null;
|
||||
|
||||
const handleManageFavorites = () => {
|
||||
if (event) {
|
||||
// Open the user's kind 10777 event in detail view
|
||||
addWindow("open", {
|
||||
pointer: {
|
||||
kind: FAVORITE_SPELLS_KIND,
|
||||
pubkey: pubkey!,
|
||||
identifier: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="p-1.5 text-muted-foreground hover:text-accent transition-colors cursor-crosshair flex items-center gap-1"
|
||||
title="Favorite Spells"
|
||||
aria-label="Favorite spells"
|
||||
>
|
||||
<WandSparkles className="size-4" />
|
||||
{favorites.length > 0 && (
|
||||
<span className="text-[10px] tabular-nums">{favorites.length}</span>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-64 max-h-[70vh] overflow-y-auto"
|
||||
>
|
||||
<DropdownMenuLabel className="py-1 px-3 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||
Favorite Spells
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{favorites.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground italic">
|
||||
Star a spell to add it here.
|
||||
</div>
|
||||
) : (
|
||||
favorites.map((pointer) => (
|
||||
<FavoriteSpellItem key={pointer.id} pointer={pointer} />
|
||||
))
|
||||
)}
|
||||
|
||||
{event && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleManageFavorites}
|
||||
className="cursor-pointer text-muted-foreground"
|
||||
>
|
||||
<Star className="size-3.5 mr-2" />
|
||||
<span className="text-sm">Manage Favorites</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { TabBar } from "../TabBar";
|
||||
import CommandLauncher from "../CommandLauncher";
|
||||
import { GlobalAuthPrompt } from "../GlobalAuthPrompt";
|
||||
import { SpellbookDropdown } from "../SpellbookDropdown";
|
||||
import { FavoriteSpellsDropdown } from "../FavoriteSpellsDropdown";
|
||||
import UserMenu from "../nostr/user-menu";
|
||||
import { AppShellContext } from "./AppShellContext";
|
||||
|
||||
@@ -74,7 +75,10 @@ export function AppShell({ children, hideBottomBar = false }: AppShellProps) {
|
||||
<SpellbookDropdown />
|
||||
</div>
|
||||
|
||||
<UserMenu />
|
||||
<div className="flex items-center">
|
||||
<FavoriteSpellsDropdown />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
<section className="flex-1 relative overflow-hidden">
|
||||
{children}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Zap,
|
||||
MessageSquare,
|
||||
SmilePlus,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { useAddWindow, useGrimoire } from "@/core/state";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
@@ -45,6 +46,8 @@ import { ReactionBlueprint } from "@/lib/blueprints";
|
||||
import { publishEventToRelays } from "@/services/hub";
|
||||
import { selectRelaysForInteraction } from "@/services/relay-selection";
|
||||
import type { EmojiTag } from "@/lib/emoji-helpers";
|
||||
import { useFavoriteSpells } from "@/hooks/useFavoriteSpells";
|
||||
import { SPELL_KIND } from "@/constants/kinds";
|
||||
|
||||
/**
|
||||
* Universal event properties and utilities shared across all kind renderers
|
||||
@@ -136,6 +139,9 @@ export function EventMenu({
|
||||
const addWindow = useAddWindow();
|
||||
const { copy, copied } = useCopy();
|
||||
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
|
||||
const { isFavorite, toggleFavorite, isUpdating } = useFavoriteSpells();
|
||||
const isSpell = event.kind === SPELL_KIND;
|
||||
const favorited = isSpell && isFavorite(event.id);
|
||||
|
||||
const openEventDetail = () => {
|
||||
let pointer;
|
||||
@@ -264,6 +270,17 @@ export function EventMenu({
|
||||
React
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canSign && isSpell && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => toggleFavorite(event)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<Star
|
||||
className={`size-4 mr-2 ${favorited ? "text-yellow-500 fill-current" : ""}`}
|
||||
/>
|
||||
{favorited ? "Remove from Favorites" : "Add to Favorites"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={copyEventId}>
|
||||
{copied ? (
|
||||
@@ -306,6 +323,9 @@ export function EventContextMenu({
|
||||
const addWindow = useAddWindow();
|
||||
const { copy, copied } = useCopy();
|
||||
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
|
||||
const { isFavorite, toggleFavorite, isUpdating } = useFavoriteSpells();
|
||||
const isSpell = event.kind === SPELL_KIND;
|
||||
const favorited = isSpell && isFavorite(event.id);
|
||||
|
||||
const openEventDetail = () => {
|
||||
let pointer;
|
||||
@@ -430,6 +450,17 @@ export function EventContextMenu({
|
||||
React
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{canSign && isSpell && (
|
||||
<ContextMenuItem
|
||||
onClick={() => toggleFavorite(event)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<Star
|
||||
className={`size-4 mr-2 ${favorited ? "text-yellow-500 fill-current" : ""}`}
|
||||
/>
|
||||
{favorited ? "Remove from Favorites" : "Add to Favorites"}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={copyEventId}>
|
||||
{copied ? (
|
||||
|
||||
161
src/components/nostr/kinds/FavoriteSpellsRenderer.tsx
Normal file
161
src/components/nostr/kinds/FavoriteSpellsRenderer.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { WandSparkles, Play, Star } from "lucide-react";
|
||||
import {
|
||||
BaseEventProps,
|
||||
BaseEventContainer,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { useFavoriteSpells, getSpellPointers } from "@/hooks/useFavoriteSpells";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import { useAddWindow } from "@/core/state";
|
||||
import { decodeSpell } from "@/lib/spell-conversion";
|
||||
import { parseSpellCommand } from "@/lib/spell-cast";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { SpellEvent } from "@/types/spell";
|
||||
import type { EventPointer } from "nostr-tools/nip19";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Kind 10777 Renderer - Favorite Spells (Feed View)
|
||||
*/
|
||||
export function FavoriteSpellsRenderer({ event }: BaseEventProps) {
|
||||
const pointers = getSpellPointers(event);
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="flex items-center gap-1.5 text-sm font-medium"
|
||||
>
|
||||
<WandSparkles className="size-4 text-muted-foreground" />
|
||||
<span>Favorite Spells</span>
|
||||
</ClickableEventTitle>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pointers.length === 0
|
||||
? "No favorite spells"
|
||||
: `${pointers.length} favorite spell${pointers.length !== 1 ? "s" : ""}`}
|
||||
</div>
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual spell reference item for the detail view
|
||||
*/
|
||||
function SpellRefItem({
|
||||
pointer,
|
||||
onUnfavorite,
|
||||
canModify,
|
||||
}: {
|
||||
pointer: EventPointer;
|
||||
onUnfavorite?: (event: NostrEvent) => void;
|
||||
canModify: boolean;
|
||||
}) {
|
||||
const spellEvent = useNostrEvent(pointer);
|
||||
const addWindow = useAddWindow();
|
||||
|
||||
if (!spellEvent) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 border border-border/50 rounded">
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let decoded: ReturnType<typeof decodeSpell> | null = null;
|
||||
try {
|
||||
decoded = decodeSpell(spellEvent as SpellEvent);
|
||||
} catch {
|
||||
// spell couldn't be decoded
|
||||
}
|
||||
|
||||
const handleCast = async () => {
|
||||
if (!decoded) return;
|
||||
const result = await parseSpellCommand(decoded.command);
|
||||
if (result) {
|
||||
addWindow(result.appId, result.props, result.commandString);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 border border-border/50 rounded group hover:bg-muted/30 transition-colors">
|
||||
<WandSparkles className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{decoded?.name || "Unnamed Spell"}
|
||||
</div>
|
||||
{decoded && (
|
||||
<div className="text-xs font-mono text-muted-foreground truncate mt-0.5">
|
||||
{decoded.command}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{decoded && (
|
||||
<button
|
||||
onClick={handleCast}
|
||||
className="p-1.5 text-muted-foreground hover:text-accent transition-colors"
|
||||
title="Cast spell"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canModify && onUnfavorite && (
|
||||
<button
|
||||
onClick={() => onUnfavorite(spellEvent)}
|
||||
className="p-1.5 text-muted-foreground hover:text-yellow-500 transition-colors"
|
||||
title="Remove from favorites"
|
||||
>
|
||||
<Star className="size-3.5 fill-current" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind 10777 Detail Renderer - Favorite Spells (Full View)
|
||||
*/
|
||||
export function FavoriteSpellsDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const { canSign } = useAccount();
|
||||
const { toggleFavorite } = useFavoriteSpells();
|
||||
|
||||
const pointers = getSpellPointers(event);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<WandSparkles className="size-6 text-muted-foreground" />
|
||||
<span className="text-lg font-semibold">Favorite Spells</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({pointers.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pointers.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground italic">
|
||||
No favorite spells yet. Star a spell to add it here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{pointers.map((pointer) => (
|
||||
<SpellRefItem
|
||||
key={pointer.id}
|
||||
pointer={pointer}
|
||||
onUnfavorite={canSign ? toggleFavorite : undefined}
|
||||
canModify={canSign}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,12 +9,14 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { KindBadge } from "@/components/KindBadge";
|
||||
import { SpellEvent } from "@/types/spell";
|
||||
import { CopyableJsonViewer } from "@/components/JsonViewer";
|
||||
import { User, Users } from "lucide-react";
|
||||
import { User, Users, Star } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UserName } from "../UserName";
|
||||
import { useAddWindow, useGrimoire } from "@/core/state";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import { useFavoriteSpells } from "@/hooks/useFavoriteSpells";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
|
||||
/**
|
||||
@@ -149,21 +151,43 @@ function IdentifierList({
|
||||
* Displays spell name, description, and the reconstructed command
|
||||
*/
|
||||
export function SpellRenderer({ event }: BaseEventProps) {
|
||||
const { canSign } = useAccount();
|
||||
const { isFavorite, toggleFavorite, isUpdating } = useFavoriteSpells();
|
||||
const favorited = isFavorite(event.id);
|
||||
|
||||
try {
|
||||
const spell = decodeSpell(event as SpellEvent);
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Title */}
|
||||
{spell.name && (
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-lg font-semibold text-foreground"
|
||||
>
|
||||
{spell.name}
|
||||
</ClickableEventTitle>
|
||||
)}
|
||||
{/* Title + Favorite */}
|
||||
<div className="flex items-center gap-2">
|
||||
{spell.name && (
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-lg font-semibold text-foreground flex-1"
|
||||
>
|
||||
{spell.name}
|
||||
</ClickableEventTitle>
|
||||
)}
|
||||
{canSign && (
|
||||
<button
|
||||
onClick={() => toggleFavorite(event)}
|
||||
disabled={isUpdating}
|
||||
className={cn(
|
||||
"p-1 transition-colors flex-shrink-0",
|
||||
favorited
|
||||
? "text-yellow-500 hover:text-yellow-600"
|
||||
: "text-muted-foreground hover:text-yellow-500",
|
||||
isUpdating && "opacity-50",
|
||||
)}
|
||||
title={favorited ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
<Star className={cn("size-4", favorited && "fill-current")} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{spell.description && (
|
||||
@@ -216,6 +240,9 @@ export function SpellRenderer({ event }: BaseEventProps) {
|
||||
export function SpellDetailRenderer({ event }: BaseEventProps) {
|
||||
const { state } = useGrimoire();
|
||||
const activePubkey = state.activeAccount?.pubkey;
|
||||
const { canSign } = useAccount();
|
||||
const { isFavorite, toggleFavorite, isUpdating } = useFavoriteSpells();
|
||||
const favorited = isFavorite(event.id);
|
||||
|
||||
try {
|
||||
const spell = decodeSpell(event as SpellEvent);
|
||||
@@ -237,14 +264,32 @@ export function SpellDetailRenderer({ event }: BaseEventProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{spell.name && (
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-2xl font-bold hover:underline cursor-pointer"
|
||||
>
|
||||
{spell.name}
|
||||
</ClickableEventTitle>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
{spell.name && (
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-2xl font-bold hover:underline cursor-pointer flex-1"
|
||||
>
|
||||
{spell.name}
|
||||
</ClickableEventTitle>
|
||||
)}
|
||||
{canSign && (
|
||||
<button
|
||||
onClick={() => toggleFavorite(event)}
|
||||
disabled={isUpdating}
|
||||
className={cn(
|
||||
"p-1.5 transition-colors flex-shrink-0",
|
||||
favorited
|
||||
? "text-yellow-500 hover:text-yellow-600"
|
||||
: "text-muted-foreground hover:text-yellow-500",
|
||||
isUpdating && "opacity-50",
|
||||
)}
|
||||
title={favorited ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
<Star className={cn("size-5", favorited && "fill-current")} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{spell.description && (
|
||||
<p className="text-muted-foreground">{spell.description}</p>
|
||||
)}
|
||||
|
||||
@@ -34,6 +34,10 @@ import {
|
||||
} from "./BlossomServerListRenderer";
|
||||
import { Kind10317Renderer } from "./GraspListRenderer";
|
||||
import { Kind10317DetailRenderer } from "./GraspListDetailRenderer";
|
||||
import {
|
||||
FavoriteSpellsRenderer,
|
||||
FavoriteSpellsDetailRenderer,
|
||||
} from "./FavoriteSpellsRenderer";
|
||||
import { Kind30023Renderer } from "./ArticleRenderer";
|
||||
import { Kind30023DetailRenderer } from "./ArticleDetailRenderer";
|
||||
import { CommunityNIPRenderer } from "./CommunityNIPRenderer";
|
||||
@@ -234,6 +238,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
10102: WikiRelaysRenderer, // Good Wiki Relays (NIP-51)
|
||||
10166: MonitorAnnouncementRenderer, // Relay Monitor Announcement (NIP-66)
|
||||
10317: Kind10317Renderer, // User Grasp List (NIP-34)
|
||||
10777: FavoriteSpellsRenderer, // Favorite Spells (Grimoire)
|
||||
13534: RelayMembersRenderer, // Relay Members (NIP-43)
|
||||
30000: FollowSetRenderer, // Follow Sets (NIP-51)
|
||||
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)
|
||||
@@ -349,6 +354,7 @@ const detailRenderers: Record<
|
||||
10102: WikiRelaysDetailRenderer, // Good Wiki Relays Detail (NIP-51)
|
||||
10166: MonitorAnnouncementDetailRenderer, // Relay Monitor Announcement Detail (NIP-66)
|
||||
10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34)
|
||||
10777: FavoriteSpellsDetailRenderer, // Favorite Spells Detail (Grimoire)
|
||||
13534: RelayMembersDetailRenderer, // Relay Members Detail (NIP-43)
|
||||
30000: FollowSetDetailRenderer, // Follow Sets Detail (NIP-51)
|
||||
30003: BookmarkSetDetailRenderer, // Bookmark Sets Detail (NIP-51)
|
||||
|
||||
@@ -93,6 +93,7 @@ export interface EventKind {
|
||||
}
|
||||
|
||||
export const SPELL_KIND = 777;
|
||||
export const FAVORITE_SPELLS_KIND = 10777;
|
||||
export const SPELLBOOK_KIND = 30777;
|
||||
|
||||
export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
@@ -893,6 +894,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
nip: "34",
|
||||
icon: FolderGit2,
|
||||
},
|
||||
10777: {
|
||||
kind: 10777,
|
||||
name: "Favorite Spells",
|
||||
description: "User's favorite spells list",
|
||||
nip: "",
|
||||
icon: WandSparkles,
|
||||
},
|
||||
// 10312: {
|
||||
// kind: 10312,
|
||||
// name: "Room Presence",
|
||||
|
||||
123
src/hooks/useFavoriteSpells.ts
Normal file
123
src/hooks/useFavoriteSpells.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { getEventPointerFromETag } from "applesauce-core/helpers";
|
||||
import { getSeenRelays } from "applesauce-core/helpers/relays";
|
||||
import { EventFactory } from "applesauce-core/event-factory";
|
||||
import eventStore from "@/services/event-store";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { publishEvent } from "@/services/hub";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import { FAVORITE_SPELLS_KIND } from "@/constants/kinds";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { EventPointer } from "nostr-tools/nip19";
|
||||
|
||||
/**
|
||||
* Extract EventPointers from "e" tags of a Nostr event.
|
||||
* Shared by the hook and renderers.
|
||||
*/
|
||||
export function getSpellPointers(event: NostrEvent): EventPointer[] {
|
||||
const pointers: EventPointer[] = [];
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "e" && tag[1]) {
|
||||
const pointer = getEventPointerFromETag(tag);
|
||||
if (pointer) pointers.push(pointer);
|
||||
}
|
||||
}
|
||||
return pointers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to read and manage the logged-in user's favorite spells (kind 10777).
|
||||
*
|
||||
* The kind 10777 event is a replaceable event containing "e" tags
|
||||
* pointing to spell events (kind 777).
|
||||
*/
|
||||
export function useFavoriteSpells() {
|
||||
const { pubkey, canSign } = useAccount();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// Subscribe to the user's kind 10777 replaceable event
|
||||
const event = use$(
|
||||
() =>
|
||||
pubkey
|
||||
? eventStore.replaceable(FAVORITE_SPELLS_KIND, pubkey, "")
|
||||
: undefined,
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
// Extract event pointers from "e" tags
|
||||
const favorites = useMemo(
|
||||
() => (event ? getSpellPointers(event) : []),
|
||||
[event],
|
||||
);
|
||||
|
||||
// Quick lookup set of favorited event IDs
|
||||
const favoriteIds = useMemo(
|
||||
() => new Set(favorites.map((p) => p.id)),
|
||||
[favorites],
|
||||
);
|
||||
|
||||
const isFavorite = useCallback(
|
||||
(eventId: string) => favoriteIds.has(eventId),
|
||||
[favoriteIds],
|
||||
);
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
async (spellEvent: NostrEvent) => {
|
||||
if (!canSign || isUpdating) return;
|
||||
|
||||
const account = accountManager.active;
|
||||
if (!account?.signer) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
// Start from the full existing event tags to preserve everything
|
||||
const currentTags = event ? event.tags.map((t) => [...t]) : [];
|
||||
const currentContent = event?.content ?? "";
|
||||
|
||||
const alreadyFavorited = currentTags.some(
|
||||
(t) => t[0] === "e" && t[1] === spellEvent.id,
|
||||
);
|
||||
|
||||
let newTags: string[][];
|
||||
if (alreadyFavorited) {
|
||||
// Remove only the matching "e" tag
|
||||
newTags = currentTags.filter(
|
||||
(t) => !(t[0] === "e" && t[1] === spellEvent.id),
|
||||
);
|
||||
} else {
|
||||
// Add with relay hint
|
||||
const seenRelays = getSeenRelays(spellEvent);
|
||||
const relayHint = seenRelays ? Array.from(seenRelays)[0] || "" : "";
|
||||
const newTag = relayHint
|
||||
? ["e", spellEvent.id, relayHint]
|
||||
: ["e", spellEvent.id];
|
||||
newTags = [...currentTags, newTag];
|
||||
}
|
||||
|
||||
const factory = new EventFactory({ signer: account.signer });
|
||||
const built = await factory.build({
|
||||
kind: FAVORITE_SPELLS_KIND,
|
||||
content: currentContent,
|
||||
tags: newTags,
|
||||
});
|
||||
const signed = await factory.sign(built);
|
||||
await publishEvent(signed);
|
||||
} catch (err) {
|
||||
console.error("[useFavoriteSpells] Failed to toggle favorite:", err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
},
|
||||
[canSign, isUpdating, event],
|
||||
);
|
||||
|
||||
return {
|
||||
favorites,
|
||||
favoriteIds,
|
||||
isFavorite,
|
||||
toggleFavorite,
|
||||
isUpdating,
|
||||
event,
|
||||
};
|
||||
}
|
||||
25
src/lib/spell-cast.ts
Normal file
25
src/lib/spell-cast.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { manPages } from "@/types/man";
|
||||
import type { AppId } from "@/types/app";
|
||||
|
||||
/**
|
||||
* Parse and execute a spell command string, returning the appId and props
|
||||
* needed to open a window.
|
||||
*
|
||||
* Returns null if the command is not recognized.
|
||||
*/
|
||||
export async function parseSpellCommand(
|
||||
commandLine: string,
|
||||
): Promise<{ appId: AppId; props: any; commandString: string } | null> {
|
||||
const parts = commandLine.trim().split(/\s+/);
|
||||
const commandName = parts[0]?.toLowerCase();
|
||||
const cmdArgs = parts.slice(1);
|
||||
|
||||
const command = manPages[commandName];
|
||||
if (!command) return null;
|
||||
|
||||
const props = command.argParser
|
||||
? await Promise.resolve(command.argParser(cmdArgs))
|
||||
: command.defaultProps || {};
|
||||
|
||||
return { appId: command.appId, props, commandString: commandLine };
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { NostrEvent, NostrFilter } from "./nostr";
|
||||
import type { Workspace, WindowInstance } from "./app";
|
||||
import { SPELL_KIND, SPELLBOOK_KIND } from "@/constants/kinds";
|
||||
import {
|
||||
SPELL_KIND,
|
||||
FAVORITE_SPELLS_KIND,
|
||||
SPELLBOOK_KIND,
|
||||
} from "@/constants/kinds";
|
||||
|
||||
export { SPELL_KIND, SPELLBOOK_KIND };
|
||||
export { SPELL_KIND, FAVORITE_SPELLS_KIND, SPELLBOOK_KIND };
|
||||
|
||||
/**
|
||||
* Spell event (kind 777 immutable event)
|
||||
|
||||
Reference in New Issue
Block a user