mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 23:16:50 +02:00
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:
18
TODO.md
18
TODO.md
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
104
src/components/SpellbookLoader.tsx
Normal file
104
src/components/SpellbookLoader.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,10 @@ const router = createBrowserRouter([
|
||||
path: "/",
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: "/:actor/:identifier",
|
||||
element: <Home />,
|
||||
},
|
||||
]);
|
||||
|
||||
export default function Root() {
|
||||
|
||||
@@ -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;
|
||||
@@ -18,7 +18,7 @@ export type AppId =
|
||||
| "conn"
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "wallet";
|
||||
| "win";
|
||||
|
||||
export interface WindowInstance {
|
||||
id: string;
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user