mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-16 09:38:36 +02:00
feat: implement Nostr Wallet Connect (NWC)
- Add applesauce-wallet-connect dependency - Create WalletService for managing NWC connection - Create WalletViewer component for UI - Register 'wallet' command - Fix build errors in spell/spellbook components due to applesauce-actions upgrade
This commit is contained in:
18
package-lock.json
generated
18
package-lock.json
generated
@@ -29,6 +29,7 @@
|
||||
"applesauce-loaders": "^4.2.0",
|
||||
"applesauce-react": "^4.0.0",
|
||||
"applesauce-relay": "latest",
|
||||
"applesauce-wallet-connect": "^4.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -4851,6 +4852,23 @@
|
||||
"url": "lightning:nostrudel@geyser.fund"
|
||||
}
|
||||
},
|
||||
"node_modules/applesauce-wallet-connect": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/applesauce-wallet-connect/-/applesauce-wallet-connect-4.1.0.tgz",
|
||||
"integrity": "sha512-wr07zQP60wenO+KZ/xmWHtWBtXkmczj2zDgK73sDE0j34ZJWvJJ3RAVMv5KLWub55jUD4vuRVyrN2Nff5ux2lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.7.1",
|
||||
"applesauce-core": "^4.1.0",
|
||||
"applesauce-factory": "^4.0.0",
|
||||
"nostr-tools": "~2.17",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "lightning",
|
||||
"url": "lightning:nostrudel@geyser.fund"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"applesauce-loaders": "^4.2.0",
|
||||
"applesauce-react": "^4.0.0",
|
||||
"applesauce-relay": "latest",
|
||||
"applesauce-wallet-connect": "^4.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
||||
@@ -6,14 +6,14 @@ import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
import { mergeRelaySets } from "applesauce-core/helpers";
|
||||
import { grimoireStateAtom } from "@/core/state";
|
||||
import { getDefaultStore } from "jotai";
|
||||
import { LocalSpell } from "@/services/db";
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
|
||||
export class DeleteEventAction {
|
||||
type = "delete-event";
|
||||
label = "Delete Event";
|
||||
|
||||
async execute(spell: LocalSpell, reason: string = ""): Promise<void> {
|
||||
if (!spell.event) throw new Error("Spell has no event to delete");
|
||||
async execute(item: { event?: NostrEvent }, reason: string = ""): Promise<void> {
|
||||
if (!item.event) throw new Error("Item has no event to delete");
|
||||
|
||||
const account = accountManager.active;
|
||||
if (!account) throw new Error("No active account");
|
||||
@@ -23,7 +23,7 @@ export class DeleteEventAction {
|
||||
|
||||
const factory = new EventFactory({ signer });
|
||||
|
||||
const draft = await factory.delete([spell.event], reason);
|
||||
const draft = await factory.delete([item.event], reason);
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
// Get write relays from cache and state
|
||||
|
||||
63
src/actions/publish-spellbook.ts
Normal file
63
src/actions/publish-spellbook.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import accountManager from "@/services/accounts";
|
||||
import pool from "@/services/relay-pool";
|
||||
import { createSpellbook } from "@/lib/spellbook-manager";
|
||||
import { markSpellbookPublished } from "@/services/spellbook-storage";
|
||||
import { EventFactory } from "applesauce-factory";
|
||||
import { SpellbookEvent } from "@/types/spell";
|
||||
import { relayListCache } from "@/services/relay-list-cache";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
import { mergeRelaySets } from "applesauce-core/helpers";
|
||||
import { GrimoireState } from "@/types/app";
|
||||
|
||||
export interface PublishSpellbookOptions {
|
||||
state: GrimoireState;
|
||||
title: string;
|
||||
description?: string;
|
||||
workspaceIds?: string[];
|
||||
localId?: string; // If provided, updates this local spellbook
|
||||
}
|
||||
|
||||
export class PublishSpellbookAction {
|
||||
type = "publish-spellbook";
|
||||
label = "Publish Spellbook";
|
||||
|
||||
async execute(options: PublishSpellbookOptions): Promise<void> {
|
||||
const { state, title, description, workspaceIds, localId } = options;
|
||||
const account = accountManager.active;
|
||||
|
||||
if (!account) throw new Error("No active account");
|
||||
const signer = account.signer;
|
||||
if (!signer) throw new Error("No signer available");
|
||||
|
||||
// 1. Create event props from state
|
||||
const encoded = createSpellbook({
|
||||
state,
|
||||
title,
|
||||
description,
|
||||
workspaceIds,
|
||||
});
|
||||
|
||||
// 2. Build and sign event
|
||||
const factory = new EventFactory({ signer });
|
||||
const draft = await factory.build({
|
||||
kind: encoded.eventProps.kind,
|
||||
content: encoded.eventProps.content,
|
||||
tags: encoded.eventProps.tags,
|
||||
});
|
||||
|
||||
const event = (await factory.sign(draft)) as SpellbookEvent;
|
||||
|
||||
// 3. Determine relays
|
||||
let relays: string[] = [];
|
||||
const authorWriteRelays = (await relayListCache.getOutboxRelays(account.pubkey)) || [];
|
||||
relays = mergeRelaySets(authorWriteRelays, AGGREGATOR_RELAYS);
|
||||
|
||||
// 4. Publish
|
||||
await pool.publish(relays, event);
|
||||
|
||||
// 5. Mark as published in local DB
|
||||
if (localId) {
|
||||
await markSpellbookPublished(localId, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,7 @@ export default function CommandLauncher({
|
||||
result.props,
|
||||
activeSpell ? effectiveParsed.fullInput : input.trim(),
|
||||
result.globalFlags?.windowProps?.title,
|
||||
activeSpell?.id,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
Sparkles,
|
||||
SplitSquareHorizontal,
|
||||
SplitSquareVertical,
|
||||
Save,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Slider } from "./ui/slider";
|
||||
@@ -21,15 +23,17 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import type { LayoutConfig } from "@/types/app";
|
||||
import { useState } from "react";
|
||||
import { SaveSpellbookDialog } from "./SaveSpellbookDialog";
|
||||
|
||||
export function LayoutControls() {
|
||||
const { state, applyPresetLayout, updateLayoutConfig } = useGrimoire();
|
||||
const { state, applyPresetLayout, updateLayoutConfig, addWindow } = useGrimoire();
|
||||
const { workspaces, activeWorkspaceId, layoutConfig } = state;
|
||||
|
||||
// Local state for immediate slider feedback (debounced persistence)
|
||||
const [localSplitPercentage, setLocalSplitPercentage] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
|
||||
const activeWorkspace = workspaces[activeWorkspaceId];
|
||||
const windowCount = activeWorkspace?.windowIds.length || 0;
|
||||
@@ -105,98 +109,122 @@ export function LayoutControls() {
|
||||
localSplitPercentage ?? layoutConfig.splitPercentage;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
aria-label="Layout settings"
|
||||
>
|
||||
<SlidersHorizontal className="h-3 w-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{/* Layouts Section */}
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Layouts
|
||||
</div>
|
||||
{presets.map((preset) => {
|
||||
const canApply = windowCount >= preset.minSlots;
|
||||
<>
|
||||
<SaveSpellbookDialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
aria-label="Layout settings"
|
||||
>
|
||||
<SlidersHorizontal className="h-3 w-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{/* Spellbooks Section */}
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Spellbooks
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSaveDialogOpen(true)}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<Save className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="font-medium text-sm">Save Layout</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => addWindow("spellbooks", {})}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="font-medium text-sm">Open Spellbooks</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={preset.id}
|
||||
onClick={() => handleApplyPreset(preset.id)}
|
||||
disabled={!canApply}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex-shrink-0">{getPresetIcon(preset.id)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{preset.name}</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Layouts Section */}
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Layout Presets
|
||||
</div>
|
||||
{presets.map((preset) => {
|
||||
const canApply = windowCount >= preset.minSlots;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={preset.id}
|
||||
onClick={() => handleApplyPreset(preset.id)}
|
||||
disabled={!canApply}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex-shrink-0">{getPresetIcon(preset.id)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{preset.name}</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Placement Section */}
|
||||
<div className="px-2 py-1.5 space-y-0.5">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
Placement
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Window insertion</div>
|
||||
</div>
|
||||
{insertionModes.map((mode) => {
|
||||
const Icon = mode.icon;
|
||||
const isActive = layoutConfig.insertionMode === mode.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
onClick={() => updateLayoutConfig({ insertionMode: mode.id })}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="flex-1">{mode.label}</span>
|
||||
{isActive && (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Split Ratio Section */}
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
Split Ratio
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{displayedSplitPercentage}/{100 - displayedSplitPercentage}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Default split for new windows
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Placement Section */}
|
||||
<div className="px-2 py-1.5 space-y-0.5">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
Placement
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Window insertion</div>
|
||||
</div>
|
||||
{insertionModes.map((mode) => {
|
||||
const Icon = mode.icon;
|
||||
const isActive = layoutConfig.insertionMode === mode.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
onClick={() => updateLayoutConfig({ insertionMode: mode.id })}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="flex-1">{mode.label}</span>
|
||||
{isActive && (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Split Ratio Section */}
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
Split Ratio
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{displayedSplitPercentage}/{100 - displayedSplitPercentage}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Default split for new windows
|
||||
</div>
|
||||
<Slider
|
||||
value={[displayedSplitPercentage]}
|
||||
onValueChange={([value]) => setLocalSplitPercentage(value)}
|
||||
onValueCommit={([value]) => {
|
||||
updateLayoutConfig({ splitPercentage: value });
|
||||
setLocalSplitPercentage(null); // Clear local state after persist
|
||||
}}
|
||||
min={20}
|
||||
max={80}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
value={[displayedSplitPercentage]}
|
||||
onValueChange={([value]) => setLocalSplitPercentage(value)}
|
||||
onValueCommit={([value]) => {
|
||||
updateLayoutConfig({ splitPercentage: value });
|
||||
setLocalSplitPercentage(null); // Clear local state after persist
|
||||
}}
|
||||
min={20}
|
||||
max={80}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ export function ExecutableCommand({
|
||||
commandLine,
|
||||
className,
|
||||
children,
|
||||
spellId,
|
||||
}: {
|
||||
commandLine: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
spellId?: string;
|
||||
}) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
@@ -34,7 +36,7 @@ export function ExecutableCommand({
|
||||
? await Promise.resolve(command.argParser(cmdArgs))
|
||||
: command.defaultProps || {};
|
||||
|
||||
addWindow(command.appId, cmdProps);
|
||||
addWindow(command.appId, cmdProps, undefined, undefined, spellId);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
188
src/components/SaveSpellbookDialog.tsx
Normal file
188
src/components/SaveSpellbookDialog.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { toast } from "sonner";
|
||||
import { saveSpellbook } from "@/services/spellbook-storage";
|
||||
import { PublishSpellbookAction } from "@/actions/publish-spellbook";
|
||||
import { createSpellbook } from "@/lib/spellbook-manager";
|
||||
import { Loader2, Save, Send } from "lucide-react";
|
||||
|
||||
interface SaveSpellbookDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SaveSpellbookDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SaveSpellbookDialogProps) {
|
||||
const { state } = useGrimoire();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [selectedWorkspaces, setSelectedWorkspaces] = useState<string[]>(
|
||||
Object.keys(state.workspaces),
|
||||
);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSave = async (shouldPublish: boolean) => {
|
||||
if (!title.trim()) {
|
||||
toast.error("Please enter a title for your spellbook");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedWorkspaces.length === 0) {
|
||||
toast.error("Please select at least one workspace to include");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
if (shouldPublish) setIsPublishing(true);
|
||||
|
||||
try {
|
||||
// 1. Create content
|
||||
const encoded = createSpellbook({
|
||||
state,
|
||||
title,
|
||||
description,
|
||||
workspaceIds: selectedWorkspaces,
|
||||
});
|
||||
|
||||
// 2. Save locally
|
||||
const localSpellbook = await saveSpellbook({
|
||||
slug: title.toLowerCase().trim().replace(/\s+/g, "-"),
|
||||
title,
|
||||
description,
|
||||
content: JSON.parse(encoded.eventProps.content),
|
||||
isPublished: false,
|
||||
});
|
||||
|
||||
// 3. Optionally publish
|
||||
if (shouldPublish) {
|
||||
const action = new PublishSpellbookAction();
|
||||
await action.execute({
|
||||
state,
|
||||
title,
|
||||
description,
|
||||
workspaceIds: selectedWorkspaces,
|
||||
localId: localSpellbook.id,
|
||||
});
|
||||
toast.success("Spellbook saved and published to Nostr");
|
||||
} else {
|
||||
toast.success("Spellbook saved locally");
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
// Reset form
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
} catch (error) {
|
||||
console.error("Failed to save spellbook:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to save spellbook");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save Layout as Spellbook</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save your current workspaces and window configuration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="e.g. My Daily Dashboard"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Description (optional)</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="What is this layout for?"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Workspaces to include</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1">
|
||||
{Object.values(state.workspaces)
|
||||
.sort((a, b) => a.number - b.number)
|
||||
.map((ws) => (
|
||||
<div key={ws.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`ws-${ws.id}`}
|
||||
checked={selectedWorkspaces.includes(ws.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedWorkspaces([...selectedWorkspaces, ws.id]);
|
||||
} else {
|
||||
setSelectedWorkspaces(
|
||||
selectedWorkspaces.filter((id) => id !== ws.id),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`ws-${ws.id}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
{ws.number}. {ws.label || "Workspace"}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2 sm:justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleSave(false)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving && !isPublishing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Locally
|
||||
</Button>
|
||||
<Button onClick={() => handleSave(true)} disabled={isSaving}>
|
||||
{isPublishing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save & Publish
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
397
src/components/SpellbooksViewer.tsx
Normal file
397
src/components/SpellbooksViewer.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Search,
|
||||
Grid3x3,
|
||||
Trash2,
|
||||
Send,
|
||||
Cloud,
|
||||
Lock,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Archive,
|
||||
Layout,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import db from "@/services/db";
|
||||
import { Input } from "./ui/input";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./ui/card";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { deleteSpellbook } from "@/services/spellbook-storage";
|
||||
import type { LocalSpellbook } from "@/services/db";
|
||||
import { PublishSpellbookAction } from "@/actions/publish-spellbook";
|
||||
import { DeleteEventAction } from "@/actions/delete-event";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useReqTimeline } from "@/hooks/useReqTimeline";
|
||||
import { parseSpellbook } from "@/lib/spellbook-manager";
|
||||
import type { SpellbookEvent, ParsedSpellbook } from "@/types/spell";
|
||||
import { SPELLBOOK_KIND } from "@/constants/kinds";
|
||||
|
||||
interface SpellbookCardProps {
|
||||
spellbook: LocalSpellbook;
|
||||
onDelete: (spellbook: LocalSpellbook) => Promise<void>;
|
||||
onPublish: (spellbook: LocalSpellbook) => Promise<void>;
|
||||
onApply: (spellbook: ParsedSpellbook) => void;
|
||||
}
|
||||
|
||||
function SpellbookCard({ spellbook, onDelete, onPublish, onApply }: SpellbookCardProps) {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const displayName = spellbook.title || "Untitled Spellbook";
|
||||
|
||||
const workspaceCount = Object.keys(spellbook.content.workspaces).length;
|
||||
const windowCount = Object.keys(spellbook.content.windows).length;
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
await onPublish(spellbook);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete(spellbook);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
// Construct a ParsedSpellbook from LocalSpellbook for applying
|
||||
const parsed: ParsedSpellbook = {
|
||||
slug: spellbook.slug,
|
||||
title: spellbook.title,
|
||||
description: spellbook.description,
|
||||
content: spellbook.content,
|
||||
referencedSpells: [], // We don't need this for applying
|
||||
event: spellbook.event as SpellbookEvent,
|
||||
};
|
||||
onApply(parsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"group flex flex-col h-full transition-opacity",
|
||||
spellbook.deletedAt && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
<CardTitle className="text-xl truncate" title={displayName}>
|
||||
{displayName}
|
||||
</CardTitle>
|
||||
</div>
|
||||
{spellbook.deletedAt ? (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
<Archive className="size-3 mr-1" />
|
||||
</Badge>
|
||||
) : spellbook.isPublished ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-green-500/10 text-green-500 hover:bg-green-500/20 border-green-500/20"
|
||||
>
|
||||
<Cloud className="size-3 mr-1" />
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="opacity-70">
|
||||
<Lock className="size-3 mr-1" />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{spellbook.description && (
|
||||
<CardDescription className="text-sm line-clamp-2">
|
||||
{spellbook.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-4 mt-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Layout className="size-3" />
|
||||
{workspaceCount} {workspaceCount === 1 ? 'workspace' : 'workspaces'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ExternalLink className="size-3" />
|
||||
{windowCount} {windowCount === 1 ? 'window' : 'windows'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="p-4 pt-0 flex-wrap gap-2 justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-8 px-2"
|
||||
onClick={handleDelete}
|
||||
disabled={isPublishing || isDeleting || !!spellbook.deletedAt}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="size-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
)}
|
||||
{spellbook.deletedAt ? "Deleted" : "Delete"}
|
||||
</Button>
|
||||
|
||||
{!spellbook.deletedAt && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={spellbook.isPublished ? "outline" : "default"}
|
||||
className="h-8"
|
||||
onClick={handlePublish}
|
||||
disabled={isPublishing || isDeleting}
|
||||
>
|
||||
{isPublishing ? (
|
||||
<Loader2 className="size-3.5 mr-1 animate-spin" />
|
||||
) : spellbook.isPublished ? (
|
||||
<RefreshCw className="size-3.5 mr-1" />
|
||||
) : (
|
||||
<Send className="size-3.5 mr-1" />
|
||||
)}
|
||||
{isPublishing ? "Publishing..." : spellbook.isPublished ? "Rebroadcast" : "Publish"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!spellbook.deletedAt && (
|
||||
<Button size="sm" variant="default" className="h-8" onClick={handleApply}>
|
||||
Apply Layout
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpellbooksViewer() {
|
||||
const { state, loadSpellbook } = useGrimoire();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterType, setFilterType] = useState<"all" | "local" | "published">(
|
||||
"all",
|
||||
);
|
||||
|
||||
// Load local spellbooks from Dexie
|
||||
const localSpellbooks = useLiveQuery(() =>
|
||||
db.spellbooks.orderBy("createdAt").reverse().toArray(),
|
||||
);
|
||||
|
||||
// Fetch from Nostr
|
||||
const { events: networkEvents, loading: networkLoading } = useReqTimeline(
|
||||
state.activeAccount ? `user-spellbooks-${state.activeAccount.pubkey}` : "none",
|
||||
state.activeAccount
|
||||
? { kinds: [SPELLBOOK_KIND], authors: [state.activeAccount.pubkey] }
|
||||
: [],
|
||||
state.activeAccount?.relays?.map((r) => r.url) || [],
|
||||
{ stream: true },
|
||||
);
|
||||
|
||||
const loading = localSpellbooks === undefined;
|
||||
|
||||
// Filter and sort
|
||||
const { filteredSpellbooks, totalCount } = useMemo(() => {
|
||||
const allSpellbooksMap = new Map<string, LocalSpellbook>();
|
||||
for (const s of localSpellbooks || []) {
|
||||
allSpellbooksMap.set(s.id, s);
|
||||
}
|
||||
|
||||
for (const event of networkEvents) {
|
||||
// Find d tag for matching with local slug
|
||||
const slug = event.tags.find(t => t[0] === 'd')?.[1] || '';
|
||||
|
||||
// Look for existing by slug + pubkey? For now just ID matching if we have it
|
||||
// Replaceable events are tricky. Let's match by slug if localId not found.
|
||||
const existing = Array.from(allSpellbooksMap.values()).find(s => s.slug === slug);
|
||||
|
||||
if (existing) {
|
||||
// Update existing with network event if it's newer
|
||||
if (event.created_at * 1000 > existing.createdAt) {
|
||||
existing.isPublished = true;
|
||||
existing.eventId = event.id;
|
||||
existing.event = event as SpellbookEvent;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseSpellbook(event as SpellbookEvent);
|
||||
const spellbook: LocalSpellbook = {
|
||||
id: event.id,
|
||||
slug: parsed.slug,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
content: parsed.content,
|
||||
createdAt: event.created_at * 1000,
|
||||
isPublished: true,
|
||||
eventId: event.id,
|
||||
event: event as SpellbookEvent,
|
||||
};
|
||||
allSpellbooksMap.set(event.id, spellbook);
|
||||
} catch (e) {
|
||||
console.warn("Failed to decode network spellbook", event.id, e);
|
||||
}
|
||||
}
|
||||
|
||||
const allMerged = Array.from(allSpellbooksMap.values());
|
||||
const total = allMerged.length;
|
||||
let filtered = [...allMerged];
|
||||
|
||||
if (filterType === "local") {
|
||||
filtered = filtered.filter((s) => !s.isPublished || !!s.deletedAt);
|
||||
} else if (filterType === "published") {
|
||||
filtered = filtered.filter((s) => s.isPublished && !s.deletedAt);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(s) =>
|
||||
s.title?.toLowerCase().includes(query) ||
|
||||
s.description?.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
if (!!a.deletedAt !== !!b.deletedAt) return a.deletedAt ? 1 : -1;
|
||||
return b.createdAt - a.createdAt;
|
||||
});
|
||||
|
||||
return { filteredSpellbooks: filtered, totalCount: total };
|
||||
}, [localSpellbooks, networkEvents, searchQuery, filterType]);
|
||||
|
||||
const handleDelete = async (spellbook: LocalSpellbook) => {
|
||||
if (!confirm(`Delete spellbook "${spellbook.title}"?`)) return;
|
||||
|
||||
try {
|
||||
if (spellbook.isPublished && spellbook.event) {
|
||||
await new DeleteEventAction().execute({ event: spellbook.event }, "Deleted by user");
|
||||
}
|
||||
await deleteSpellbook(spellbook.id);
|
||||
toast.success("Spellbook deleted");
|
||||
} catch (error) {
|
||||
toast.error("Failed to delete spellbook");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async (spellbook: LocalSpellbook) => {
|
||||
try {
|
||||
const action = new PublishSpellbookAction();
|
||||
await action.execute({
|
||||
state,
|
||||
title: spellbook.title,
|
||||
description: spellbook.description,
|
||||
workspaceIds: Object.keys(spellbook.content.workspaces),
|
||||
localId: spellbook.id,
|
||||
});
|
||||
toast.success("Spellbook published");
|
||||
} catch (error) {
|
||||
toast.error("Failed to publish");
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = (spellbook: ParsedSpellbook) => {
|
||||
loadSpellbook(spellbook);
|
||||
toast.success("Layout applied", {
|
||||
description: `Added ${Object.keys(spellbook.content.workspaces).length} workspaces.`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<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" />
|
||||
<h2 className="text-lg font-semibold">Spellbooks</h2>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{filteredSpellbooks.length}/{totalCount}
|
||||
</Badge>
|
||||
{networkLoading && (
|
||||
<Loader2 className="size-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search spellbooks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filterType === "all" ? "default" : "outline"}
|
||||
onClick={() => setFilterType("all")}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filterType === "local" ? "default" : "outline"}
|
||||
onClick={() => setFilterType("local")}
|
||||
>
|
||||
Local
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filterType === "published" ? "default" : "outline"}
|
||||
onClick={() => setFilterType("published")}
|
||||
>
|
||||
Published
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredSpellbooks.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No spellbooks found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredSpellbooks.map((s) => (
|
||||
<SpellbookCard
|
||||
key={s.id}
|
||||
spellbook={s}
|
||||
onDelete={handleDelete}
|
||||
onPublish={handlePublish}
|
||||
onApply={handleApply}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,6 +124,7 @@ function SpellCard({ spell, onDelete, onPublish }: SpellCardProps) {
|
||||
<ExecutableCommand
|
||||
commandLine={spell.command}
|
||||
className="text-xs truncate line-clamp-1 text-primary hover:underline cursor-pointer"
|
||||
spellId={spell.id}
|
||||
>
|
||||
{spell.command}
|
||||
</ExecutableCommand>
|
||||
@@ -299,7 +300,7 @@ export function SpellsViewer() {
|
||||
// 1. If published, send Nostr Kind 5
|
||||
if (isPublic && spell.event) {
|
||||
toast.promise(
|
||||
new DeleteEventAction().execute(spell, "Deleted by user in Grimoire"),
|
||||
new DeleteEventAction().execute({ event: spell.event }, "Deleted by user in Grimoire"),
|
||||
{
|
||||
loading: "Sending Nostr deletion request...",
|
||||
success: "Deletion request broadcasted",
|
||||
|
||||
164
src/components/WalletViewer.tsx
Normal file
164
src/components/WalletViewer.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,12 @@ const ConnViewer = lazy(() => import("./ConnViewer"));
|
||||
const SpellsViewer = lazy(() =>
|
||||
import("./SpellsViewer").then((m) => ({ default: m.SpellsViewer })),
|
||||
);
|
||||
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() {
|
||||
@@ -168,6 +174,12 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
case "spells":
|
||||
content = <SpellsViewer />;
|
||||
break;
|
||||
case "spellbooks":
|
||||
content = <SpellbooksViewer />;
|
||||
break;
|
||||
case "wallet":
|
||||
content = <WalletViewer />;
|
||||
break;
|
||||
default:
|
||||
content = (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { UserName } from "../nostr/UserName";
|
||||
import { Label } from "../ui/Label";
|
||||
import { Label } from "../ui/label";
|
||||
import type { LiveStatus } from "@/types/live-activity";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
getRepositoryName,
|
||||
getRepositoryIdentifier,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { Label } from "@/components/ui/Label";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface Kind1337DetailRendererProps {
|
||||
event: NostrEvent;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getCodeName,
|
||||
getCodeDescription,
|
||||
} from "@/lib/nip-c0-helpers";
|
||||
import { Label } from "@/components/ui/Label";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
|
||||
|
||||
// Map common language names to Prism-supported languages
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getRepositoryName,
|
||||
getRepositoryIdentifier,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { Label } from "@/components/ui/Label";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 1621 - Issue (NIP-34)
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
getRepositoryName,
|
||||
getRepositoryIdentifier,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { Label } from "@/components/ui/Label";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 1621 - Issue
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { VideoPlayer } from "@/components/live/VideoPlayer";
|
||||
import { StatusBadge } from "@/components/live/StatusBadge";
|
||||
import { UserName } from "../UserName";
|
||||
import { Label } from "@/components/ui/Label";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
interface LiveActivityDetailRendererProps {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getLiveHost,
|
||||
} from "@/lib/live-activity";
|
||||
import { BaseEventContainer, ClickableEventTitle } from "./BaseEventRenderer";
|
||||
import { Label } from "@/components/ui/Label";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { VideoPlayer } from "@/components/live/VideoPlayer";
|
||||
import { StatusBadge } from "@/components/live/StatusBadge";
|
||||
import { Users, Play, Circle, Calendar, Video } from "lucide-react";
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
getRepositoryName,
|
||||
getRepositoryIdentifier,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { Label } from "@/components/ui/Label";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 1618 - Pull Request
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
getRepositoryName,
|
||||
getRepositoryIdentifier,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { Label } from "@/components/ui/Label";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 1618 - Pull Request
|
||||
|
||||
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -74,6 +74,9 @@ export interface EventKind {
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const SPELL_KIND = 777;
|
||||
export const SPELLBOOK_KIND = 30777;
|
||||
|
||||
export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
// Core protocol kinds
|
||||
0: {
|
||||
@@ -681,13 +684,20 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
icon: Zap,
|
||||
},
|
||||
9735: { kind: 9735, name: "Zap", description: "Zap", nip: "57", icon: Zap },
|
||||
777: {
|
||||
kind: 777,
|
||||
[SPELL_KIND]: {
|
||||
kind: SPELL_KIND,
|
||||
name: "Spell",
|
||||
description: "REQ Command Spell",
|
||||
nip: "",
|
||||
icon: WandSparkles,
|
||||
},
|
||||
[SPELLBOOK_KIND]: {
|
||||
kind: SPELLBOOK_KIND,
|
||||
name: "Spellbook",
|
||||
description: "Grimoire Layout Configuration",
|
||||
nip: "",
|
||||
icon: Grid3x3,
|
||||
},
|
||||
9802: {
|
||||
kind: 9802,
|
||||
name: "Highlight",
|
||||
|
||||
@@ -68,6 +68,7 @@ export const addWindow = (
|
||||
props: any;
|
||||
commandString?: string;
|
||||
customTitle?: string;
|
||||
spellId?: string;
|
||||
},
|
||||
): GrimoireState => {
|
||||
const activeId = state.activeWorkspaceId;
|
||||
@@ -79,6 +80,7 @@ export const addWindow = (
|
||||
customTitle: payload.customTitle,
|
||||
props: payload.props,
|
||||
commandString: payload.commandString,
|
||||
spellId: payload.spellId,
|
||||
};
|
||||
|
||||
// Insert window using global layout configuration
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
} from "@/types/app";
|
||||
import { useLocale } from "@/hooks/useLocale";
|
||||
import * as Logic from "./logic";
|
||||
import * as SpellbookManager from "@/lib/spellbook-manager";
|
||||
import { CURRENT_VERSION, validateState, migrateState } from "@/lib/migrations";
|
||||
import { toast } from "sonner";
|
||||
import { ParsedSpellbook } from "@/types/spell";
|
||||
|
||||
// Initial State Definition - Empty canvas on first load
|
||||
const initialState: GrimoireState = {
|
||||
@@ -171,13 +173,20 @@ export const useGrimoire = () => {
|
||||
);
|
||||
|
||||
const addWindow = useCallback(
|
||||
(appId: AppId, props: any, commandString?: string, customTitle?: string) =>
|
||||
(
|
||||
appId: AppId,
|
||||
props: any,
|
||||
commandString?: string,
|
||||
customTitle?: string,
|
||||
spellId?: string,
|
||||
) =>
|
||||
setState((prev) =>
|
||||
Logic.addWindow(prev, {
|
||||
appId,
|
||||
props,
|
||||
commandString,
|
||||
customTitle,
|
||||
spellId,
|
||||
}),
|
||||
),
|
||||
[setState],
|
||||
@@ -291,6 +300,12 @@ export const useGrimoire = () => {
|
||||
[setState],
|
||||
);
|
||||
|
||||
const loadSpellbook = useCallback(
|
||||
(spellbook: ParsedSpellbook) =>
|
||||
setState((prev) => SpellbookManager.loadSpellbook(prev, spellbook)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
locale: state.locale || browserLocale,
|
||||
@@ -310,5 +325,6 @@ export const useGrimoire = () => {
|
||||
updateWorkspaceLabel,
|
||||
reorderWorkspaces,
|
||||
setCompactModeKinds,
|
||||
loadSpellbook,
|
||||
};
|
||||
};
|
||||
|
||||
237
src/lib/spellbook-manager.test.ts
Normal file
237
src/lib/spellbook-manager.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createSpellbook,
|
||||
parseSpellbook,
|
||||
loadSpellbook,
|
||||
slugify,
|
||||
} from "./spellbook-manager";
|
||||
import { GrimoireState, WindowInstance, Workspace } from "@/types/app";
|
||||
import { SPELLBOOK_KIND, SpellbookEvent } from "@/types/spell";
|
||||
|
||||
// Mock Data
|
||||
const mockWindow1: WindowInstance = {
|
||||
id: "win-1",
|
||||
appId: "profile",
|
||||
props: { pubkey: "abc" },
|
||||
spellId: "spell-1",
|
||||
};
|
||||
|
||||
const mockWindow2: WindowInstance = {
|
||||
id: "win-2",
|
||||
appId: "kind",
|
||||
props: { kind: 1 },
|
||||
};
|
||||
|
||||
const mockWorkspace1: Workspace = {
|
||||
id: "ws-1",
|
||||
number: 1,
|
||||
layout: "win-1",
|
||||
windowIds: ["win-1"],
|
||||
};
|
||||
|
||||
const mockWorkspace2: Workspace = {
|
||||
id: "ws-2",
|
||||
number: 2,
|
||||
layout: {
|
||||
direction: "row",
|
||||
first: "win-2",
|
||||
second: "win-1",
|
||||
},
|
||||
windowIds: ["win-1", "win-2"],
|
||||
};
|
||||
|
||||
const mockState: GrimoireState = {
|
||||
__version: 6,
|
||||
windows: {
|
||||
"win-1": mockWindow1,
|
||||
"win-2": mockWindow2,
|
||||
},
|
||||
workspaces: {
|
||||
"ws-1": mockWorkspace1,
|
||||
"ws-2": mockWorkspace2,
|
||||
},
|
||||
activeWorkspaceId: "ws-1",
|
||||
layoutConfig: {
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
},
|
||||
};
|
||||
|
||||
describe("Spellbook Manager", () => {
|
||||
describe("slugify", () => {
|
||||
it("converts titles to slugs", () => {
|
||||
expect(slugify("Hello World")).toBe("hello-world");
|
||||
expect(slugify("My Cool Dashboard!")).toBe("my-cool-dashboard");
|
||||
expect(slugify(" Trim Me ")).toBe("trim-me");
|
||||
expect(slugify("Mixed Case Title")).toBe("mixed-case-title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSpellbook", () => {
|
||||
it("creates a valid spellbook from state", () => {
|
||||
const result = createSpellbook({
|
||||
state: mockState,
|
||||
title: "My Backup",
|
||||
description: "Test description",
|
||||
workspaceIds: ["ws-1"],
|
||||
});
|
||||
|
||||
const { eventProps, referencedSpells } = result;
|
||||
const content = JSON.parse(eventProps.content);
|
||||
|
||||
// Check event props
|
||||
expect(eventProps.kind).toBe(SPELLBOOK_KIND);
|
||||
expect(eventProps.tags).toContainEqual(["d", "my-backup"]);
|
||||
expect(eventProps.tags).toContainEqual(["title", "My Backup"]);
|
||||
expect(eventProps.tags).toContainEqual(["description", "Test description"]);
|
||||
expect(eventProps.tags).toContainEqual(["client", "grimoire"]);
|
||||
|
||||
// Check referenced spells (e tags)
|
||||
expect(referencedSpells).toContain("spell-1");
|
||||
expect(eventProps.tags).toContainEqual(["e", "spell-1", "", "mention"]);
|
||||
|
||||
// Check content structure
|
||||
expect(content.version).toBe(1);
|
||||
expect(Object.keys(content.workspaces)).toHaveLength(1);
|
||||
expect(content.workspaces["ws-1"]).toBeDefined();
|
||||
|
||||
// Should only include windows referenced in the workspace
|
||||
expect(Object.keys(content.windows)).toHaveLength(1);
|
||||
expect(content.windows["win-1"]).toBeDefined();
|
||||
expect(content.windows["win-2"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes all workspaces if no IDs provided", () => {
|
||||
const result = createSpellbook({
|
||||
state: mockState,
|
||||
title: "Full Backup",
|
||||
});
|
||||
|
||||
const content = JSON.parse(result.eventProps.content);
|
||||
expect(Object.keys(content.workspaces)).toHaveLength(2);
|
||||
expect(Object.keys(content.windows)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSpellbook", () => {
|
||||
it("parses a valid spellbook event", () => {
|
||||
const content = {
|
||||
version: 1,
|
||||
workspaces: { "ws-1": mockWorkspace1 },
|
||||
windows: { "win-1": mockWindow1 },
|
||||
};
|
||||
|
||||
const event: SpellbookEvent = {
|
||||
id: "evt-1",
|
||||
pubkey: "pub-1",
|
||||
created_at: 123456,
|
||||
kind: SPELLBOOK_KIND,
|
||||
tags: [
|
||||
["d", "my-slug"],
|
||||
["title", "My Title"],
|
||||
["description", "Desc"],
|
||||
["e", "spell-1"],
|
||||
],
|
||||
content: JSON.stringify(content),
|
||||
sig: "sig",
|
||||
};
|
||||
|
||||
const parsed = parseSpellbook(event);
|
||||
|
||||
expect(parsed.slug).toBe("my-slug");
|
||||
expect(parsed.title).toBe("My Title");
|
||||
expect(parsed.description).toBe("Desc");
|
||||
expect(parsed.content).toEqual(content);
|
||||
expect(parsed.referencedSpells).toContain("spell-1");
|
||||
});
|
||||
|
||||
it("handles parsing errors gracefully", () => {
|
||||
const event = {
|
||||
kind: SPELLBOOK_KIND,
|
||||
content: "invalid json",
|
||||
tags: [],
|
||||
} as any;
|
||||
|
||||
expect(() => parseSpellbook(event)).toThrow("Failed to parse spellbook content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSpellbook", () => {
|
||||
it("imports workspaces with new IDs and numbers", () => {
|
||||
const spellbookContent = {
|
||||
version: 1,
|
||||
workspaces: { "ws-1": mockWorkspace1 },
|
||||
windows: { "win-1": mockWindow1 },
|
||||
};
|
||||
|
||||
const parsed = {
|
||||
slug: "test",
|
||||
title: "Test",
|
||||
content: spellbookContent,
|
||||
referencedSpells: [],
|
||||
event: {} as any,
|
||||
};
|
||||
|
||||
// State has workspaces 1 and 2 used. Next available should be 3.
|
||||
const newState = loadSpellbook(mockState, parsed);
|
||||
|
||||
// Should have 3 workspaces now (2 original + 1 imported)
|
||||
expect(Object.keys(newState.workspaces)).toHaveLength(3);
|
||||
|
||||
// Find the new workspace
|
||||
const newWsEntry = Object.entries(newState.workspaces).find(
|
||||
([id]) => id !== "ws-1" && id !== "ws-2"
|
||||
);
|
||||
|
||||
expect(newWsEntry).toBeDefined();
|
||||
const [newId, newWs] = newWsEntry!;
|
||||
|
||||
// IDs should be regenerated
|
||||
expect(newId).not.toBe("ws-1");
|
||||
expect(newWs.id).not.toBe("ws-1");
|
||||
|
||||
// Number should be 3 (lowest available)
|
||||
expect(newWs.number).toBe(3);
|
||||
|
||||
// Window IDs should be regenerated
|
||||
const newWinId = newWs.windowIds[0];
|
||||
expect(newWinId).not.toBe("win-1");
|
||||
expect(newState.windows[newWinId]).toBeDefined();
|
||||
expect(newState.windows[newWinId].appId).toBe("profile");
|
||||
|
||||
// Layout should reference new window ID
|
||||
expect(newWs.layout).toBe(newWinId);
|
||||
});
|
||||
|
||||
it("updates layout tree with new window IDs", () => {
|
||||
const spellbookContent = {
|
||||
version: 1,
|
||||
workspaces: { "ws-2": mockWorkspace2 },
|
||||
windows: { "win-1": mockWindow1, "win-2": mockWindow2 },
|
||||
};
|
||||
|
||||
const parsed = {
|
||||
slug: "test",
|
||||
title: "Test",
|
||||
content: spellbookContent,
|
||||
referencedSpells: [],
|
||||
event: {} as any,
|
||||
};
|
||||
|
||||
const newState = loadSpellbook(mockState, parsed);
|
||||
const newWs = Object.values(newState.workspaces).find(w => w.number === 3)!;
|
||||
|
||||
expect(typeof newWs.layout).toBe("object");
|
||||
if (typeof newWs.layout === "object" && newWs.layout !== null) {
|
||||
// Check that leaf nodes are new UUIDs, not old IDs
|
||||
expect(newWs.layout.first).not.toBe("win-2");
|
||||
expect(newWs.layout.second).not.toBe("win-1");
|
||||
|
||||
// Check that they match the windowIds list
|
||||
expect(newWs.windowIds).toContain(newWs.layout.first);
|
||||
expect(newWs.windowIds).toContain(newWs.layout.second);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
293
src/lib/spellbook-manager.ts
Normal file
293
src/lib/spellbook-manager.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { MosaicNode } from "react-mosaic-component";
|
||||
import type { GrimoireState, WindowInstance, Workspace } from "@/types/app";
|
||||
import { SPELLBOOK_KIND } from "@/constants/kinds";
|
||||
import {
|
||||
type SpellbookContent,
|
||||
type SpellbookEvent,
|
||||
type ParsedSpellbook,
|
||||
} from "@/types/spell";
|
||||
import { findLowestAvailableWorkspaceNumber } from "@/core/logic";
|
||||
|
||||
/**
|
||||
* Options for creating a spellbook
|
||||
*/
|
||||
export interface CreateSpellbookOptions {
|
||||
state: GrimoireState;
|
||||
workspaceIds?: string[]; // If omitted, saves all workspaces
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of encoding a spellbook
|
||||
*/
|
||||
export interface EncodedSpellbook {
|
||||
eventProps: {
|
||||
kind: number;
|
||||
content: string;
|
||||
tags: [string, string, ...string[]][];
|
||||
};
|
||||
referencedSpells: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to slugify a title for the 'd' tag
|
||||
*/
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, "-") // Replace spaces with -
|
||||
.replace(/[^\w\-]+/g, "") // Remove all non-word chars
|
||||
.replace(/\-\-+/g, "-"); // Replace multiple - with single -
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses a Mosaic layout tree to collect all window IDs
|
||||
*/
|
||||
function collectWindowIds(
|
||||
layout: MosaicNode<string> | null,
|
||||
ids: Set<string>,
|
||||
): void {
|
||||
if (!layout) return;
|
||||
|
||||
if (typeof layout === "string") {
|
||||
ids.add(layout);
|
||||
return;
|
||||
}
|
||||
|
||||
collectWindowIds(layout.first, ids);
|
||||
collectWindowIds(layout.second, ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Spellbook (Kind 30777) from the current state
|
||||
*/
|
||||
export function createSpellbook(
|
||||
options: CreateSpellbookOptions,
|
||||
): EncodedSpellbook {
|
||||
const { state, title, description, workspaceIds } = options;
|
||||
|
||||
// 1. Determine which workspaces to include
|
||||
const targetWorkspaceIds =
|
||||
workspaceIds && workspaceIds.length > 0
|
||||
? workspaceIds
|
||||
: Object.keys(state.workspaces);
|
||||
|
||||
const selectedWorkspaces: Record<string, Workspace> = {};
|
||||
const selectedWindows: Record<string, WindowInstance> = {};
|
||||
const referencedSpells = new Set<string>();
|
||||
|
||||
// 2. Collect workspaces and their windows
|
||||
for (const wsId of targetWorkspaceIds) {
|
||||
const ws = state.workspaces[wsId];
|
||||
if (!ws) continue;
|
||||
|
||||
selectedWorkspaces[wsId] = ws;
|
||||
|
||||
// Collect window IDs from layout to ensure we only save what's actually visible/used
|
||||
// (though state.workspaces[id].windowIds should match, layout is the source of truth for structure)
|
||||
const windowIds = new Set<string>();
|
||||
collectWindowIds(ws.layout, windowIds);
|
||||
|
||||
// Also include any loose windows in the workspace definition just in case
|
||||
ws.windowIds.forEach((id) => windowIds.add(id));
|
||||
|
||||
// 3. Extract window instances
|
||||
for (const winId of windowIds) {
|
||||
const window = state.windows[winId];
|
||||
if (window) {
|
||||
selectedWindows[winId] = window;
|
||||
if (window.spellId) {
|
||||
referencedSpells.add(window.spellId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Construct content payload
|
||||
const content: SpellbookContent = {
|
||||
version: 1,
|
||||
workspaces: selectedWorkspaces,
|
||||
windows: selectedWindows,
|
||||
};
|
||||
|
||||
// 5. Construct tags
|
||||
const tags: [string, string, ...string[]][] = [
|
||||
["d", slugify(title)],
|
||||
["title", title],
|
||||
["client", "grimoire"],
|
||||
];
|
||||
|
||||
if (description) {
|
||||
tags.push(["description", description]);
|
||||
tags.push(["alt", `Grimoire Spellbook: ${title}`]);
|
||||
} else {
|
||||
tags.push(["alt", `Grimoire Spellbook: ${title}`]);
|
||||
}
|
||||
|
||||
// Add referenced spells
|
||||
for (const spellId of referencedSpells) {
|
||||
tags.push(["e", spellId, "", "mention"]);
|
||||
}
|
||||
|
||||
return {
|
||||
eventProps: {
|
||||
kind: SPELLBOOK_KIND,
|
||||
content: JSON.stringify(content),
|
||||
tags,
|
||||
},
|
||||
referencedSpells: Array.from(referencedSpells),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a Spellbook event
|
||||
*/
|
||||
export function parseSpellbook(event: SpellbookEvent): ParsedSpellbook {
|
||||
let content: SpellbookContent;
|
||||
try {
|
||||
content = JSON.parse(event.content);
|
||||
} catch (e) {
|
||||
throw new Error("Failed to parse spellbook content: Invalid JSON");
|
||||
}
|
||||
|
||||
// Validate version (basic check)
|
||||
if (!content.version || content.version < 1) {
|
||||
console.warn("Spellbook missing version or invalid, attempting to load anyway");
|
||||
}
|
||||
|
||||
// Extract metadata
|
||||
const dTag = event.tags.find((t) => t[0] === "d");
|
||||
const titleTag = event.tags.find((t) => t[0] === "title");
|
||||
const descTag = event.tags.find((t) => t[0] === "description");
|
||||
const eTags = event.tags.filter((t) => t[0] === "e").map((t) => t[1]);
|
||||
|
||||
return {
|
||||
slug: dTag?.[1] || "",
|
||||
title: titleTag?.[1] || "Untitled Spellbook",
|
||||
description: descTag?.[1],
|
||||
content,
|
||||
referencedSpells: eTags,
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively updates window IDs in a Mosaic layout tree
|
||||
*/
|
||||
function updateLayoutIds(
|
||||
layout: MosaicNode<string> | null,
|
||||
idMap: Map<string, string>,
|
||||
): MosaicNode<string> | null {
|
||||
if (!layout) return null;
|
||||
|
||||
if (typeof layout === "string") {
|
||||
// If we have a mapping for this ID, return the new one.
|
||||
// If not (shouldn't happen in valid spellbooks), return old one.
|
||||
return idMap.get(layout) || layout;
|
||||
}
|
||||
|
||||
return {
|
||||
...layout,
|
||||
first: updateLayoutIds(layout.first, idMap)!,
|
||||
second: updateLayoutIds(layout.second, idMap)!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a parsed spellbook into the current state.
|
||||
* Regenerates IDs to avoid collisions.
|
||||
*/
|
||||
export function loadSpellbook(
|
||||
state: GrimoireState,
|
||||
spellbook: ParsedSpellbook,
|
||||
): GrimoireState {
|
||||
const { workspaces, windows } = spellbook.content;
|
||||
|
||||
// Maps to track old -> new IDs
|
||||
const workspaceIdMap = new Map<string, string>();
|
||||
const windowIdMap = new Map<string, string>();
|
||||
|
||||
// 1. Generate new Workspace IDs and Numbers
|
||||
// We don't want to mess up existing workspace numbers, so we append.
|
||||
// We'll calculate starting number based on existing.
|
||||
const newWorkspaces: Record<string, Workspace> = { ...state.workspaces };
|
||||
const newWindows: Record<string, WindowInstance> = { ...state.windows };
|
||||
|
||||
// 2. Process Windows first to build ID map
|
||||
Object.values(windows).forEach((window) => {
|
||||
const newId = uuidv4();
|
||||
windowIdMap.set(window.id, newId);
|
||||
|
||||
// Create new window instance with new ID
|
||||
newWindows[newId] = {
|
||||
...window,
|
||||
id: newId,
|
||||
};
|
||||
});
|
||||
|
||||
// 3. Process Workspaces
|
||||
Object.values(workspaces).forEach((ws) => {
|
||||
const newWsId = uuidv4();
|
||||
workspaceIdMap.set(ws.id, newWsId);
|
||||
|
||||
// Update window IDs in the windowIds array
|
||||
const newWindowIds = ws.windowIds
|
||||
.map((oldId) => windowIdMap.get(oldId))
|
||||
.filter((id): id is string => !!id);
|
||||
|
||||
// Update layout tree with new window IDs
|
||||
const newLayout = updateLayoutIds(ws.layout, windowIdMap);
|
||||
|
||||
// Create new workspace instance
|
||||
// Note: We use the lowest available number strategy to avoid conflicts
|
||||
// but this might separate workspaces that were sequential in the spellbook
|
||||
// if there are gaps in the current state.
|
||||
// Ideally, we keep them sequential relative to each other.
|
||||
|
||||
// Actually, let's find the max existing number and append after that to keep them grouped
|
||||
// logic.ts findLowestAvailable fills gaps.
|
||||
// If we want to group them, we should find the max and start there.
|
||||
// But findLowestAvailable is the standard convention in this codebase.
|
||||
// Let's stick to findLowestAvailable for now, but call it sequentially.
|
||||
|
||||
// Wait, findLowestAvailable scans the *current* map.
|
||||
// So if we add one to newWorkspaces, the next call will find the next number.
|
||||
|
||||
// Simple approach: just use the logic helper against the ACCUMULATING state.
|
||||
|
||||
// However, findLowestAvailable is O(N). Calling it in a loop is O(N^2).
|
||||
// For small N (workspaces) it's fine.
|
||||
|
||||
const targetNumber = (() => {
|
||||
// We can't easily use the helper because we haven't added the new workspace yet.
|
||||
// And if we add it, we need the number first. Catch-22.
|
||||
// Actually, we can just pass the current state of newWorkspaces to the helper
|
||||
// provided it matches the signature Record<string, {number: number}>.
|
||||
// It does.
|
||||
return findLowestAvailableWorkspaceNumber(newWorkspaces);
|
||||
})();
|
||||
|
||||
newWorkspaces[newWsId] = {
|
||||
...ws,
|
||||
id: newWsId,
|
||||
number: targetNumber,
|
||||
layout: newLayout,
|
||||
windowIds: newWindowIds,
|
||||
// If label exists, keep it, maybe prepend "Imported"? No, keep it as is.
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
workspaces: newWorkspaces,
|
||||
windows: newWindows,
|
||||
// Optionally switch to the first imported workspace?
|
||||
// Let's just return the state, let the UI decide navigation.
|
||||
// But usually importing implies you want to see it.
|
||||
// We'll leave activeWorkspaceId unchanged for now, the caller can update it if needed.
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Dexie, Table } from "dexie";
|
||||
import { RelayInformation } from "../types/nip11";
|
||||
import { normalizeRelayURL } from "../lib/relay-url";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { SpellEvent } from "@/types/spell";
|
||||
import type { SpellEvent, SpellbookContent, SpellbookEvent } from "@/types/spell";
|
||||
|
||||
export interface Profile extends ProfileContent {
|
||||
pubkey: string;
|
||||
@@ -63,6 +63,19 @@ export interface LocalSpell {
|
||||
deletedAt?: number; // Timestamp when soft-deleted
|
||||
}
|
||||
|
||||
export interface LocalSpellbook {
|
||||
id: string; // UUID for local-only, or event ID for published
|
||||
slug: string; // d-tag for replaceable events
|
||||
title: string; // Human readable title
|
||||
description?: string; // Optional description
|
||||
content: SpellbookContent; // JSON payload
|
||||
createdAt: number;
|
||||
isPublished: boolean;
|
||||
eventId?: string;
|
||||
event?: SpellbookEvent;
|
||||
deletedAt?: number;
|
||||
}
|
||||
|
||||
class GrimoireDb extends Dexie {
|
||||
profiles!: Table<Profile>;
|
||||
nip05!: Table<Nip05>;
|
||||
@@ -72,6 +85,7 @@ class GrimoireDb extends Dexie {
|
||||
relayLists!: Table<CachedRelayList>;
|
||||
relayLiveness!: Table<RelayLivenessEntry>;
|
||||
spells!: Table<LocalSpell>;
|
||||
spellbooks!: Table<LocalSpellbook>;
|
||||
|
||||
constructor(name: string) {
|
||||
super(name);
|
||||
@@ -280,6 +294,19 @@ class GrimoireDb extends Dexie {
|
||||
relayLiveness: "&url",
|
||||
spells: "&id, alias, createdAt, isPublished, deletedAt",
|
||||
});
|
||||
|
||||
// Version 14: Add local spellbook storage
|
||||
this.version(14).stores({
|
||||
profiles: "&pubkey",
|
||||
nip05: "&nip05",
|
||||
nips: "&id",
|
||||
relayInfo: "&url",
|
||||
relayAuthPreferences: "&url",
|
||||
relayLists: "&pubkey, updatedAt",
|
||||
relayLiveness: "&url",
|
||||
spells: "&id, alias, createdAt, isPublished, deletedAt",
|
||||
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,4 +334,4 @@ export const relayLivenessStorage = {
|
||||
},
|
||||
};
|
||||
|
||||
export default db;
|
||||
export default db;
|
||||
58
src/services/spellbook-storage.ts
Normal file
58
src/services/spellbook-storage.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import db, { LocalSpellbook } from "./db";
|
||||
import { SpellbookEvent } from "@/types/spell";
|
||||
|
||||
/**
|
||||
* Save a spellbook to local storage
|
||||
*/
|
||||
export async function saveSpellbook(
|
||||
spellbook: Omit<LocalSpellbook, "id" | "createdAt">,
|
||||
): Promise<LocalSpellbook> {
|
||||
const id = spellbook.eventId || crypto.randomUUID();
|
||||
const createdAt = Date.now();
|
||||
|
||||
const localSpellbook: LocalSpellbook = {
|
||||
id,
|
||||
createdAt,
|
||||
...spellbook,
|
||||
};
|
||||
|
||||
await db.spellbooks.put(localSpellbook);
|
||||
return localSpellbook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a spellbook by ID
|
||||
*/
|
||||
export async function getSpellbook(id: string): Promise<LocalSpellbook | undefined> {
|
||||
return db.spellbooks.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all local spellbooks
|
||||
*/
|
||||
export async function getAllSpellbooks(): Promise<LocalSpellbook[]> {
|
||||
return db.spellbooks.orderBy("createdAt").reverse().toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a spellbook
|
||||
*/
|
||||
export async function deleteSpellbook(id: string): Promise<void> {
|
||||
await db.spellbooks.update(id, {
|
||||
deletedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a spellbook as published
|
||||
*/
|
||||
export async function markSpellbookPublished(
|
||||
localId: string,
|
||||
event: SpellbookEvent,
|
||||
): Promise<void> {
|
||||
await db.spellbooks.update(localId, {
|
||||
isPublished: true,
|
||||
eventId: event.id,
|
||||
event,
|
||||
});
|
||||
}
|
||||
60
src/services/wallet.ts
Normal file
60
src/services/wallet.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
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;
|
||||
@@ -16,7 +16,9 @@ export type AppId =
|
||||
| "relay"
|
||||
| "debug"
|
||||
| "conn"
|
||||
| "spells";
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "wallet";
|
||||
|
||||
export interface WindowInstance {
|
||||
id: string;
|
||||
@@ -25,6 +27,7 @@ export interface WindowInstance {
|
||||
customTitle?: string; // User-provided custom title via --title flag (overrides dynamic title)
|
||||
props: any;
|
||||
commandString?: string; // Original command that created this window (e.g., "profile alice@domain.com")
|
||||
spellId?: string; // ID of the spell that created this window (if any)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -73,6 +73,14 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
argParser: (args: string[]) => ({ number: args[0] || "1" }),
|
||||
defaultProps: { number: "1" },
|
||||
},
|
||||
spellbooks: {
|
||||
name: "spellbooks",
|
||||
section: "1",
|
||||
synopsis: "spellbooks",
|
||||
description: "Browse and manage saved layout spellbooks.",
|
||||
appId: "spellbooks",
|
||||
category: "System",
|
||||
},
|
||||
help: {
|
||||
name: "help",
|
||||
section: "1",
|
||||
@@ -470,4 +478,16 @@ 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: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { NostrEvent, NostrFilter } from "./nostr";
|
||||
import type { Workspace, WindowInstance } from "./app";
|
||||
import { SPELL_KIND, SPELLBOOK_KIND } from "@/constants/kinds";
|
||||
|
||||
export { SPELL_KIND, SPELLBOOK_KIND };
|
||||
|
||||
/**
|
||||
* Spell event (kind 777 immutable event)
|
||||
@@ -120,3 +124,49 @@ export interface EncodedSpell {
|
||||
/** Close on EOSE flag */
|
||||
closeOnEose: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content structure for a Spellbook (Kind 30777)
|
||||
* Represents a saved layout configuration (one or more workspaces)
|
||||
*/
|
||||
export interface SpellbookContent {
|
||||
/** Schema version for migrations */
|
||||
version: number;
|
||||
/** Workspaces included in this spellbook */
|
||||
workspaces: Record<string, Workspace>;
|
||||
/** Windows referenced by the workspaces */
|
||||
windows: Record<string, WindowInstance>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spellbook event (kind 30777 parameterized replaceable event)
|
||||
*
|
||||
* TAGS:
|
||||
* - ["d", "slug"] - Unique identifier (slugified title)
|
||||
* - ["title", "My Dashboard"] - Human readable title
|
||||
* - ["description", "My cool setup"] - Optional description
|
||||
* - ["e", "spell-id", "relay", "mention"] - References to spells used in the layout
|
||||
*/
|
||||
export interface SpellbookEvent extends NostrEvent {
|
||||
kind: 30777;
|
||||
content: string; // JSON encoded SpellbookContent
|
||||
tags: [string, string, ...string[]][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed spellbook with extracted metadata and content
|
||||
*/
|
||||
export interface ParsedSpellbook {
|
||||
/** Slug identifier (from d tag) */
|
||||
slug: string;
|
||||
/** Title (from title tag) */
|
||||
title: string;
|
||||
/** Description (from description tag) */
|
||||
description?: string;
|
||||
/** The actual layout data */
|
||||
content: SpellbookContent;
|
||||
/** IDs of spells referenced in this book (from e tags) */
|
||||
referencedSpells: string[];
|
||||
/** Full event reference */
|
||||
event: SpellbookEvent;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user