feat: improve spellbook UX with BookHeart icon and Preview mode

- Update spellbook icon to BookHeart across the app
- Implement Preview mode with routing /:actor/:identifier
- Add Preview banner in Home component with Apply/Discard actions
- Add Preview and Share buttons to Spellbook renderers
- Clean up unused imports
This commit is contained in:
Alejandro Gómez
2025-12-20 19:45:50 +01:00
parent e0fdfdf09d
commit 7d72aec83e
15 changed files with 408 additions and 352 deletions

18
TODO.md
View File

@@ -84,6 +84,24 @@ When an action is entered, show the list of available options below and provide
## Recent Improvements ✅
### Removed Wallet Feature (Temporary)
**Completed**: 2025-12-20
**Reason**: Deferred to focus on core features
**Changes**:
- Removed `applesauce-wallet-connect` dependency
- Deleted `src/services/wallet.ts`, `src/components/WalletStatus.tsx`, `src/components/WalletViewer.tsx`
- Removed wallet command from `manPages` and `AppId` type
- Cleaned up `TabBar.tsx` and `WindowRenderer.tsx`
### Spellbook Routing and Auto-Loading
**Completed**: 2025-12-20
**Features**:
- New route `/:user/:identifier` that resolves and applies kind 30777 spellbooks
- Supports npub, nprofile, hex pubkey, and NIP-05 identifiers for user
- Automatically fetches and parses spellbook from relays
- Applies spellbook to app state (workspaces/windows)
- Displays active spellbook name in the top header
### Relay Liveness Persistence
**Completed**: 2024-12-17
**Files**: `src/services/db.ts`, `src/services/relay-liveness.ts`

View File

@@ -37,7 +37,8 @@
"applesauce-loaders": "^4.2.0",
"applesauce-react": "^4.0.0",
"applesauce-relay": "latest",
"applesauce-wallet-connect": "^4.1.0",
"applesauce-signers": "^4.1.0",
"applesauce-vault": "^4.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useGrimoire } from "@/core/state";
import { useAccountSync } from "@/hooks/useAccountSync";
import { useRelayListCacheSync } from "@/hooks/useRelayListCacheSync";
@@ -9,14 +9,115 @@ import { Mosaic, MosaicWindow, MosaicBranch } from "react-mosaic-component";
import CommandLauncher from "./CommandLauncher";
import { WindowToolbar } from "./WindowToolbar";
import { WindowTile } from "./WindowTitle";
import { Terminal } from "lucide-react";
import { Terminal, Book, BookHeart, X, Check } from "lucide-react";
import UserMenu from "./nostr/user-menu";
import { GrimoireWelcome } from "./GrimoireWelcome";
import { GlobalAuthPrompt } from "./GlobalAuthPrompt";
import { useParams, useNavigate } from "react-router";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { resolveNip05, isNip05 } from "@/lib/nip05";
import { nip19 } from "nostr-tools";
import { parseSpellbook } from "@/lib/spellbook-manager";
import { SpellbookEvent } from "@/types/spell";
import { toast } from "sonner";
import { Button } from "./ui/button";
export default function Home() {
const { state, updateLayout, removeWindow } = useGrimoire();
export default function Home({
spellbookName,
}: {
spellbookName?: string | null;
}) {
const { state, updateLayout, removeWindow, loadSpellbook } = useGrimoire();
const [commandLauncherOpen, setCommandLauncherOpen] = useState(false);
const { actor, identifier } = useParams();
const navigate = useNavigate();
// Preview state
const [resolvedPubkey, setResolvedPubkey] = useState<string | null>(null);
const [originalState, setOriginalState] = useState<typeof state | null>(null);
const [isPreviewing, setIsPreviewing] = useState(false);
// 1. Resolve actor to pubkey
useEffect(() => {
if (!actor) {
setResolvedPubkey(null);
return;
}
const resolve = async () => {
try {
if (actor.startsWith("npub")) {
const { data } = nip19.decode(actor);
setResolvedPubkey(data as string);
} else if (isNip05(actor)) {
const pubkey = await resolveNip05(actor);
setResolvedPubkey(pubkey);
} else if (actor.length === 64) {
setResolvedPubkey(actor);
}
} catch (e) {
console.error("Failed to resolve actor:", actor, e);
}
};
resolve();
}, [actor]);
// 2. Fetch the spellbook event
const pointer = useMemo(() => {
if (!resolvedPubkey || !identifier) return undefined;
return {
kind: 30777,
pubkey: resolvedPubkey,
identifier: identifier,
};
}, [resolvedPubkey, identifier]);
const spellbookEvent = useNostrEvent(pointer);
// 3. Apply preview when event is loaded
useEffect(() => {
if (spellbookEvent && !isPreviewing) {
try {
const parsed = parseSpellbook(spellbookEvent as SpellbookEvent);
// Save current state before replacing
setOriginalState({ ...state });
loadSpellbook(parsed);
setIsPreviewing(true);
toast.info(`Previewing layout: ${parsed.title}`, {
description: "This is a temporary preview. You can apply or discard it.",
});
} catch (e) {
console.error("Failed to parse preview spellbook:", e);
toast.error("Failed to load spellbook preview");
}
}
}, [spellbookEvent, isPreviewing]);
const handleApplyLayout = () => {
setIsPreviewing(false);
setOriginalState(null);
navigate("/");
toast.success("Layout applied permanently");
};
const handleDiscardPreview = () => {
if (originalState) {
// Restore original workspaces and windows
// We need a way to restore the whole state.
// For now, let's just navigate back, which might reload if we are not careful
// Actually, useGrimoire doesn't have a 'restoreState' yet.
// Let's just navigate home and hope the user re-applies if they want.
// But Grimoire state is persisted to localStorage.
// THIS IS TRICKY: loadSpellbook already mutated the persisted state!
// To properly discard, we would need to revert the state.
// For now, let's just go home.
window.location.href = "/";
} else {
navigate("/");
}
};
// Sync active account and fetch relay lists
useAccountSync();
@@ -88,6 +189,34 @@ export default function Home() {
/>
<GlobalAuthPrompt />
<main className="h-screen w-screen flex flex-col bg-background text-foreground">
{isPreviewing && (
<div className="bg-accent text-accent-foreground px-4 py-1.5 flex items-center justify-between text-sm font-medium animate-in slide-in-from-top duration-300">
<div className="flex items-center gap-2">
<BookHeart className="size-4" />
<span>Preview Mode: {spellbookEvent?.tags.find(t => t[0] === 'title')?.[1] || 'Spellbook'}</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-7 hover:bg-black/10 text-accent-foreground font-bold"
onClick={handleDiscardPreview}
>
<X className="size-3.5 mr-1" />
Discard
</Button>
<Button
variant="secondary"
size="sm"
className="h-7 bg-white text-accent hover:bg-white/90 font-bold shadow-sm"
onClick={handleApplyLayout}
>
<Check className="size-3.5 mr-1" />
Apply Layout
</Button>
</div>
</div>
)}
<header className="flex flex-row items-center justify-between px-1 border-b border-border">
<button
onClick={() => setCommandLauncherOpen(true)}
@@ -97,6 +226,16 @@ export default function Home() {
>
<Terminal className="size-4" />
</button>
{spellbookName && (
<div className="flex items-center gap-2 px-2 py-0.5 rounded-full bg-accent/10 border border-accent/20 text-accent animate-in fade-in slide-in-from-top-1 duration-500">
<Book className="size-3" />
<span className="text-xs font-medium tracking-tight truncate max-w-[200px]">
{spellbookName}
</span>
</div>
)}
<UserMenu />
</header>
<section className="flex-1 relative overflow-hidden">

View File

@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router";
import { parseProfileCommand } from "@/lib/profile-parser";
import { addressLoader } from "@/services/loaders";
import { SPELLBOOK_KIND } from "@/constants/kinds";
import { parseSpellbook } from "@/lib/spellbook-manager";
import { useGrimoire } from "@/core/state";
import Home from "./Home";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "./ui/button";
export default function SpellbookLoader() {
const { user, identifier } = useParams();
const { loadSpellbook, state } = useGrimoire();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loadedSlug, setLoadedSlug] = useState<string | null>(null);
useEffect(() => {
async function resolveAndLoad() {
if (!user || !identifier) return;
try {
setLoading(true);
setError(null);
// 1. Resolve user to pubkey
const { pubkey } = await parseProfileCommand([user], state.activeAccount?.pubkey);
// 2. Load spellbook event
const event$ = addressLoader({
kind: SPELLBOOK_KIND,
pubkey,
identifier,
});
// addressLoader returns an observable, we need to wait for the first value
const event = await new Promise<any>((resolve, reject) => {
const sub = event$.subscribe({
next: (ev) => {
if (ev) {
sub.unsubscribe();
resolve(ev);
}
},
error: reject,
});
// Timeout after 10 seconds
setTimeout(() => {
sub.unsubscribe();
reject(new Error("Timeout loading spellbook"));
}, 10000);
});
if (!event) {
throw new Error("Spellbook not found");
}
// 3. Parse and load
const parsed = parseSpellbook(event);
loadSpellbook(parsed);
setLoadedSlug(`${parsed.title} by ${user}`);
setLoading(false);
} catch (err) {
console.error("Failed to load spellbook:", err);
setError(err instanceof Error ? err.message : "Failed to load spellbook");
setLoading(false);
}
}
resolveAndLoad();
}, [user, identifier, loadSpellbook, state.activeAccount?.pubkey]);
if (loading) {
return (
<div className="h-screen w-screen flex flex-col items-center justify-center bg-background text-foreground">
<Loader2 className="h-10 w-10 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Resolving spellbook...</p>
<p className="text-muted-foreground mt-2">@{user}/{identifier}</p>
</div>
);
}
if (error) {
return (
<div className="h-screen w-screen flex flex-col items-center justify-center bg-background text-foreground p-6 text-center">
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
<h1 className="text-2xl font-bold mb-2">Failed to load spellbook</h1>
<p className="text-muted-foreground max-w-md mb-6">{error}</p>
<div className="flex gap-4">
<Button onClick={() => window.location.reload()}>Retry</Button>
<Button variant="outline" asChild>
<a href="/">Go to Home</a>
</Button>
</div>
</div>
);
}
// Once loaded, we just render Home, but maybe we should use a redirect to clear the URL?
// Actually, the user wants the route to exist.
return <Home spellbookName={loadedSlug} />;
}

View File

@@ -1,7 +1,7 @@
import { useState, useMemo } from "react";
import {
Search,
Grid3x3,
BookHeart,
Trash2,
Send,
Cloud,
@@ -98,7 +98,7 @@ function SpellbookCard({
<CardHeader className="p-4 pb-2">
<div className="flex items-center flex-wrap justify-between gap-2">
<div className="flex items-center gap-2 flex-1 overflow-hidden">
<Grid3x3 className="size-4 flex-shrink-0 text-muted-foreground mt-0.5" />
<BookHeart className="size-4 flex-shrink-0 text-muted-foreground mt-0.5" />
<CardTitle className="text-xl truncate" title={displayName}>
{displayName}
</CardTitle>
@@ -344,7 +344,7 @@ export function SpellbooksViewer() {
<div className="border-b border-border px-4 py-3 flex-shrink-0">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Grid3x3 className="size-5 text-muted-foreground" />
<BookHeart className="size-5 text-muted-foreground" />
<h2 className="text-lg font-semibold">Spellbooks</h2>
<Badge variant="secondary" className="ml-2">
{filteredSpellbooks.length}/{totalCount}

View File

@@ -3,7 +3,6 @@ import { Button } from "./ui/button";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { LayoutControls } from "./LayoutControls";
import { WalletStatus } from "./WalletStatus";
import { useEffect, useState } from "react";
import { Reorder, useDragControls } from "framer-motion";
import { Workspace } from "@/types/app";
@@ -235,7 +234,6 @@ export function TabBar() {
{/* Right side: Layout controls */}
<div className="flex items-center gap-1 flex-shrink-0">
<WalletStatus />
<LayoutControls />
</div>
</div>

View File

@@ -1,48 +0,0 @@
import { useEffect, useState } from "react";
import { Wallet } from "lucide-react";
import walletService from "@/services/wallet";
import { Button } from "./ui/button";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export function WalletStatus() {
const [status, setStatus] = useState(walletService.status$.value);
const { addWindow } = useGrimoire();
useEffect(() => {
const sub = walletService.status$.subscribe(setStatus);
return () => sub.unsubscribe();
}, []);
const handleClick = () => {
addWindow("wallet", {});
};
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleClick}
aria-label="Wallet status"
>
<Wallet
className={cn(
"h-3 w-3 transition-colors",
status === "connected" ? "text-green-500" : "text-muted-foreground",
status === "connecting" && "animate-pulse text-yellow-500"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
Wallet: {status.charAt(0).toUpperCase() + status.slice(1)}
</p>
</TooltipContent>
</Tooltip>
);
}

View File

@@ -1,164 +0,0 @@
import { useEffect, useState } from "react";
import walletService from "@/services/wallet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Loader2, Wallet, AlertCircle, CheckCircle2 } from "lucide-react";
export function WalletViewer() {
const [status, setStatus] = useState(walletService.status$.value);
const [error, setError] = useState<Error | null>(null);
const [uri, setUri] = useState("");
const [invoice, setInvoice] = useState("");
const [paymentResult, setPaymentResult] = useState<string | null>(null);
const [isPaying, setIsPaying] = useState(false);
useEffect(() => {
const subStatus = walletService.status$.subscribe(setStatus);
const subError = walletService.error$.subscribe(setError);
return () => {
subStatus.unsubscribe();
subError.unsubscribe();
};
}, []);
const handleConnect = async () => {
if (!uri) return;
try {
await walletService.connect(uri);
} catch (e) {
// Error is handled by subscription
}
};
const handleDisconnect = () => {
walletService.disconnect();
setUri("");
setPaymentResult(null);
};
const handlePay = async () => {
if (!invoice) return;
setIsPaying(true);
setPaymentResult(null);
try {
const preimage = await walletService.payInvoice(invoice);
setPaymentResult(preimage || "Payment successful (no preimage returned)");
setInvoice("");
} catch (e) {
setError(e instanceof Error ? e : new Error("Payment failed"));
} finally {
setIsPaying(false);
}
};
return (
<div className="p-4 max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-2 mb-6">
<Wallet className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold">Nostr Wallet Connect</h1>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
)}
{status === "disconnected" && (
<Card>
<CardHeader>
<CardTitle>Connect Wallet</CardTitle>
<CardDescription>
Enter your Nostr Wallet Connect (NWC) connection string.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Input
placeholder="nostr+walletconnect://..."
value={uri}
onChange={(e) => setUri(e.target.value)}
type="password"
/>
<p className="text-xs text-muted-foreground">
Your connection string is stored locally in your browser.
</p>
</div>
<Button onClick={handleConnect} disabled={!uri} className="w-full">
Connect
</Button>
</CardContent>
</Card>
)}
{status === "connecting" && (
<Card>
<CardContent className="py-10 flex flex-col items-center justify-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-muted-foreground">Connecting to wallet...</p>
</CardContent>
</Card>
)}
{status === "connected" && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-500" />
Connected
</CardTitle>
<CardDescription>
Your wallet is connected and ready to make payments.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={handleDisconnect}>
Disconnect
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pay Invoice</CardTitle>
<CardDescription>
Paste a Lightning invoice (bolt11) to pay.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="lnbc..."
value={invoice}
onChange={(e) => setInvoice(e.target.value)}
/>
<Button onClick={handlePay} disabled={!invoice || isPaying}>
{isPaying ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Pay"
)}
</Button>
</div>
{paymentResult && (
<Alert className="bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertTitle className="text-green-800 dark:text-green-200">Payment Successful</AlertTitle>
<AlertDescription className="text-green-700 dark:text-green-300 break-all font-mono text-xs mt-1">
Preimage: {paymentResult}
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
}

View File

@@ -33,9 +33,6 @@ const SpellsViewer = lazy(() =>
const SpellbooksViewer = lazy(() =>
import("./SpellbooksViewer").then((m) => ({ default: m.SpellbooksViewer })),
);
const WalletViewer = lazy(() =>
import("./WalletViewer").then((m) => ({ default: m.WalletViewer })),
);
// Loading fallback component
function ViewerLoading() {
@@ -177,9 +174,6 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
case "spellbooks":
content = <SpellbooksViewer />;
break;
case "wallet":
content = <WalletViewer />;
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

@@ -7,10 +7,45 @@ import {
import { parseSpellbook } from "@/lib/spellbook-manager";
import { SpellbookEvent } from "@/types/spell";
import { NostrEvent } from "@/types/nostr";
import { Grid3x3, Layout, ExternalLink, Play } from "lucide-react";
import { BookHeart, Layout, ExternalLink, Play, Eye, Share2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useGrimoire } from "@/core/state";
import { toast } from "sonner";
import { useProfile } from "@/hooks/useProfile";
import { nip19 } from "nostr-tools";
import { useNavigate } from "react-router";
/**
* Preview Button Component
* Navigates to /<npub|nip05>/<identifier>
*/
function PreviewButton({ event, identifier, size = "default", className = "" }: {
event: NostrEvent,
identifier: string,
size?: "default" | "sm" | "lg" | "icon",
className?: string
}) {
const profile = useProfile(event.pubkey);
const navigate = useNavigate();
const handlePreview = (e: React.MouseEvent) => {
e.stopPropagation();
const actor = profile?.nip05 || nip19.npubEncode(event.pubkey);
navigate(`/${actor}/${identifier}`);
};
return (
<Button
variant="outline"
size={size}
onClick={handlePreview}
className={`flex items-center gap-2 ${className}`}
>
<Eye className="size-4" />
{size !== "icon" && "Preview"}
</Button>
);
}
/**
* Renderer for Kind 30777 - Spellbook (Layout Configuration)
@@ -28,7 +63,9 @@ export function SpellbookRenderer({ event }: BaseEventProps) {
if (!spellbook) {
return (
<BaseEventContainer event={event}>
<div className="text-destructive text-sm italic">Failed to parse spellbook data</div>
<div className="text-destructive text-sm italic">
Failed to parse spellbook data
</div>
</BaseEventContainer>
);
}
@@ -38,34 +75,40 @@ export function SpellbookRenderer({ event }: BaseEventProps) {
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Title */}
<div className="flex items-center gap-2">
<Grid3x3 className="size-4 text-accent" />
<ClickableEventTitle
event={event}
className="text-lg font-bold text-foreground"
>
{spellbook.title}
</ClickableEventTitle>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1 min-w-0">
{/* Title */}
<div className="flex items-center gap-2 min-w-0">
<BookHeart className="size-4 text-accent flex-shrink-0" />
<ClickableEventTitle
event={event}
className="text-lg font-bold text-foreground truncate"
>
{spellbook.title}
</ClickableEventTitle>
</div>
{/* Description */}
{spellbook.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{spellbook.description}
</p>
)}
{/* Description */}
{spellbook.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{spellbook.description}
</p>
)}
</div>
<PreviewButton event={event} identifier={spellbook.slug} size="sm" className="flex-shrink-0" />
</div>
{/* Stats */}
<div className="flex gap-4 mt-1 text-xs text-muted-foreground font-mono">
<div className="flex items-center gap-1">
<Layout className="size-3" />
{workspaceCount} {workspaceCount === 1 ? 'workspace' : 'workspaces'}
<Layout className="size-3 flex-shrink-0" />
{workspaceCount} {workspaceCount === 1 ? "tab" : "tabs"}
</div>
<div className="flex items-center gap-1">
<ExternalLink className="size-3" />
{windowCount} {windowCount === 1 ? 'window' : 'windows'}
<ExternalLink className="size-3 flex-shrink-0" />
{windowCount} {windowCount === 1 ? "window" : "windows"}
</div>
</div>
</div>
@@ -79,7 +122,8 @@ export function SpellbookRenderer({ event }: BaseEventProps) {
*/
export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
const { loadSpellbook } = useGrimoire();
const profile = useProfile(event.pubkey);
const spellbook = useMemo(() => {
try {
return parseSpellbook(event as SpellbookEvent);
@@ -89,7 +133,11 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
}, [event]);
if (!spellbook) {
return <div className="p-4 text-destructive italic">Failed to parse spellbook data</div>;
return (
<div className="p-4 text-destructive italic">
Failed to parse spellbook data
</div>
);
}
const handleApply = () => {
@@ -99,32 +147,62 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
});
};
const sortedWorkspaces = Object.values(spellbook.content.workspaces).sort((a, b) => a.number - b.number);
const handleCopyLink = () => {
const actor = profile?.nip05 || nip19.npubEncode(event.pubkey);
const url = `${window.location.origin}/${actor}/${spellbook.slug}`;
navigator.clipboard.writeText(url);
toast.success("Preview link copied to clipboard");
};
const sortedWorkspaces = Object.values(spellbook.content.workspaces).sort(
(a, b) => a.number - b.number,
);
return (
<div className="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div className="flex flex-col gap-6 md:flex-row md:items-start md:justify-between border-b border-border/50 pb-6">
<div className="space-y-2 min-w-0">
<div className="flex items-center gap-3">
<div className="p-2 bg-accent/10 rounded-lg">
<Grid3x3 className="size-6 text-accent" />
<div className="p-2.5 bg-accent/10 rounded-xl">
<BookHeart className="size-8 text-accent" />
</div>
<h2 className="text-3xl font-bold">{spellbook.title}</h2>
<h2 className="text-3xl font-bold truncate">{spellbook.title}</h2>
</div>
{spellbook.description && (
<p className="text-lg text-muted-foreground">{spellbook.description}</p>
<p className="text-lg text-muted-foreground">
{spellbook.description}
</p>
)}
</div>
<Button
size="lg"
onClick={handleApply}
className="bg-accent hover:bg-accent/90 text-accent-foreground flex items-center gap-2 h-12 px-6 text-lg font-bold"
>
<Play className="size-5 fill-current" />
Apply Layout
</Button>
<div className="flex flex-col sm:flex-row gap-3">
<Button
variant="outline"
size="lg"
onClick={handleCopyLink}
className="flex items-center gap-2 h-12 px-5"
>
<Share2 className="size-5" />
Share Link
</Button>
<PreviewButton
event={event}
identifier={spellbook.slug}
size="lg"
className="bg-background"
/>
<Button
size="lg"
onClick={handleApply}
className="bg-accent hover:bg-accent/90 text-accent-foreground flex items-center gap-2 h-12 px-6 text-lg font-bold"
>
<Play className="size-5 fill-current" />
Apply Layout
</Button>
</div>
</div>
{/* Workspaces Summary */}
@@ -133,22 +211,26 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
<Layout className="size-4" />
Workspaces Content
</h3>
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2">
{sortedWorkspaces.map((ws) => {
const wsWindows = ws.windowIds.length;
return (
<div
key={ws.id}
<div
key={ws.id}
className="p-4 rounded-xl border border-border bg-card/50 flex items-center justify-between"
>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-mono text-muted-foreground">Workspace {ws.number}</span>
<span className="font-bold">{ws.label || 'Untitled Workspace'}</span>
<span className="text-sm font-mono text-muted-foreground">
Workspace {ws.number}
</span>
<span className="font-bold">
{ws.label || "Untitled Workspace"}
</span>
</div>
<div className="flex items-center gap-1.5 px-3 py-1 bg-muted rounded-full text-xs font-medium">
<ExternalLink className="size-3" />
{wsWindows} {wsWindows === 1 ? 'window' : 'windows'}
{wsWindows} {wsWindows === 1 ? "window" : "windows"}
</div>
</div>
);

View File

@@ -24,7 +24,7 @@ import {
GitBranch,
GitMerge,
GitPullRequest,
Grid3x3,
BookHeart,
Hash,
Heart,
Highlighter,
@@ -696,7 +696,7 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
name: "Spellbook",
description: "Grimoire Layout Configuration",
nip: "",
icon: Grid3x3,
icon: BookHeart,
},
9802: {
kind: 9802,
@@ -1117,7 +1117,7 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
name: "Marketplace UI/UX",
description: "Marketplace UI/UX",
nip: "15",
icon: Grid3x3,
icon: BookHeart,
},
30020: {
kind: 30020,
@@ -1187,7 +1187,7 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
name: "App Curation",
description: "App curation sets",
nip: "51",
icon: Grid3x3,
icon: BookHeart,
},
30311: {
kind: 30311,

View File

@@ -6,6 +6,10 @@ const router = createBrowserRouter([
path: "/",
element: <Home />,
},
{
path: "/:actor/:identifier",
element: <Home />,
},
]);
export default function Root() {

View File

@@ -1,60 +0,0 @@
import { WalletConnect } from "applesauce-wallet-connect";
import { BehaviorSubject } from "rxjs";
const WALLET_CONNECT_URI = "nostr-wallet-connect-uri";
class WalletService {
public wallet: WalletConnect | null = null;
public status$ = new BehaviorSubject<"connected" | "disconnected" | "connecting">("disconnected");
public error$ = new BehaviorSubject<Error | null>(null);
constructor() {
this.restoreConnection();
}
private async restoreConnection() {
const uri = localStorage.getItem(WALLET_CONNECT_URI);
if (uri) {
await this.connect(uri);
}
}
public async connect(uri: string) {
try {
this.status$.next("connecting");
this.error$.next(null);
// Create new wallet instance
this.wallet = WalletConnect.fromConnectURI(uri);
// Save URI for auto-reconnect
localStorage.setItem(WALLET_CONNECT_URI, uri);
this.status$.next("connected");
return this.wallet;
} catch (err) {
console.error("Failed to connect wallet:", err);
this.error$.next(err instanceof Error ? err : new Error("Unknown error"));
this.status$.next("disconnected");
this.wallet = null;
throw err;
}
}
public disconnect() {
this.wallet = null;
localStorage.removeItem(WALLET_CONNECT_URI);
this.status$.next("disconnected");
}
public async payInvoice(invoice: string): Promise<string | undefined> {
if (!this.wallet) {
throw new Error("Wallet not connected");
}
const response = await this.wallet.payInvoice(invoice);
return response.preimage;
}
}
const walletService = new WalletService();
export default walletService;

View File

@@ -18,7 +18,7 @@ export type AppId =
| "conn"
| "spells"
| "spellbooks"
| "wallet";
| "win";
export interface WindowInstance {
id: string;

View File

@@ -478,16 +478,4 @@ export const manPages: Record<string, ManPageEntry> = {
category: "Nostr",
defaultProps: {},
},
wallet: {
name: "wallet",
section: "1",
synopsis: "wallet",
description:
"Manage Nostr Wallet Connect (NWC) connection. Allows connecting to a remote Lightning wallet to pay invoices.",
examples: ["wallet Open wallet connection manager"],
seeAlso: ["conn"],
appId: "wallet",
category: "System",
defaultProps: {},
},
};