feat: complete Phase 2 network features for spellbooks

Sharing Enhancements:
- Install qrcode library for QR code generation
- Create ShareSpellbookDialog with tabbed interface
- Support multiple share formats: Web URL, naddr, nevent
- QR code generation and download for each format
- Quick copy buttons with visual feedback
- Integrated into SpellbookDetailRenderer

Network Discovery:
- Add "Discover" filter to browse spellbooks from other users
- Query AGGREGATOR_RELAYS for network spellbook discovery
- Show author names using UserName component
- Conditional UI: hide owner actions for discovered spellbooks
- Support viewing and applying layouts from the community

Preview Route Polish:
- Loading states with spinner during NIP-05 resolution
- 10-second timeout for NIP-05 resolution
- Error banners for resolution failures
- Author name and creation date in preview banner
- Copy link button in preview mode

Conflict Resolution:
- compareSpellbookVersions() function in spellbook-manager
- ConflictResolutionDialog component for version conflicts
- Side-by-side comparison of local vs network versions
- Show workspace/window counts and timestamps

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gómez
2025-12-21 14:09:52 +01:00
parent 6d89a9d342
commit 0f7f154b80
8 changed files with 725 additions and 106 deletions

View File

@@ -4,6 +4,13 @@ import type { ActionHub } from "applesauce-actions";
import type { GrimoireState } from "@/types/app";
import type { NostrEvent } from "nostr-tools/core";
// Mock accountManager
vi.mock("@/services/accounts", () => ({
default: {
active: null, // Will be set in tests
},
}));
// Mock implementations
const mockSigner = {
getPublicKey: vi.fn(async () => "test-pubkey"),
@@ -29,9 +36,6 @@ const mockFactory = {
};
const mockHub: ActionHub = {
accountManager: {
active: mockAccount,
},
factory: mockFactory,
} as any;
@@ -59,8 +63,12 @@ const mockState: GrimoireState = {
} as any;
describe("PublishSpellbook action", () => {
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
// Set up accountManager mock
const accountManager = await import("@/services/accounts");
(accountManager.default as any).active = mockAccount;
});
describe("validation", () => {
@@ -87,37 +95,38 @@ describe("PublishSpellbook action", () => {
});
it("should throw error if no active account", async () => {
const hubWithoutAccount: ActionHub = {
...mockHub,
accountManager: { active: null } as any,
};
const accountManager = await import("@/services/accounts");
(accountManager.default as any).active = null;
await expect(async () => {
for await (const event of PublishSpellbook(hubWithoutAccount, {
for await (const event of PublishSpellbook(mockHub, {
state: mockState,
title: "Test Spellbook",
})) {
// Should not reach here
}
}).rejects.toThrow("No active account");
// Restore for other tests
(accountManager.default as any).active = mockAccount;
});
it("should throw error if no signer available", async () => {
const hubWithoutSigner: ActionHub = {
...mockHub,
accountManager: {
active: { ...mockAccount, signer: null } as any,
} as any,
};
const accountManager = await import("@/services/accounts");
const accountWithoutSigner = { ...mockAccount, signer: null };
(accountManager.default as any).active = accountWithoutSigner;
await expect(async () => {
for await (const event of PublishSpellbook(hubWithoutSigner, {
for await (const event of PublishSpellbook(mockHub, {
state: mockState,
title: "Test Spellbook",
})) {
// Should not reach here
}
}).rejects.toThrow("No signer available");
// Restore for other tests
(accountManager.default as any).active = mockAccount;
});
});

View File

@@ -5,6 +5,7 @@ import { GrimoireState } from "@/types/app";
import { SpellbookContent } from "@/types/spell";
import { mergeRelaySets } from "applesauce-core/helpers";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import accountManager from "@/services/accounts";
import type { ActionHub } from "applesauce-actions";
import type { NostrEvent } from "nostr-tools/core";
@@ -55,7 +56,7 @@ export async function* PublishSpellbook(
throw new Error("Title is required");
}
const account = hub.accountManager?.active;
const account = accountManager.active;
if (!account) {
throw new Error("No active account. Please log in first.");
}