mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 06:57:07 +02:00
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:
332
package-lock.json
generated
332
package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"applesauce-accounts": "^4.1.0",
|
||||
"applesauce-actions": "^4.0.0",
|
||||
"applesauce-content": "^4.0.0",
|
||||
@@ -29,7 +30,7 @@
|
||||
"applesauce-loaders": "^4.2.0",
|
||||
"applesauce-react": "^4.0.0",
|
||||
"applesauce-relay": "latest",
|
||||
"applesauce-wallet-connect": "^4.1.0",
|
||||
"applesauce-signers": "^4.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -44,6 +45,7 @@
|
||||
"lucide-react": "latest",
|
||||
"media-chrome": "^4.17.2",
|
||||
"prismjs": "^1.30.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -4081,7 +4083,6 @@
|
||||
"version": "24.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
@@ -4094,6 +4095,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
@@ -4638,7 +4648,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -4852,23 +4861,6 @@
|
||||
"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",
|
||||
@@ -5073,6 +5065,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@@ -5224,6 +5225,72 @@
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -5253,7 +5320,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -5266,7 +5332,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/comma-separated-tokens": {
|
||||
@@ -5395,6 +5460,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
||||
@@ -5482,6 +5556,12 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
@@ -6117,6 +6197,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
@@ -6460,7 +6549,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -8043,6 +8131,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -8092,7 +8189,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -8186,6 +8282,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -8478,6 +8583,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -8941,6 +9063,21 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -9087,6 +9224,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
@@ -9778,7 +9921,6 @@
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
@@ -10295,6 +10437,12 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
@@ -10417,6 +10565,12 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
@@ -10424,6 +10578,134 @@
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yargs/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"applesauce-accounts": "^4.1.0",
|
||||
"applesauce-actions": "^4.0.0",
|
||||
"applesauce-content": "^4.0.0",
|
||||
@@ -38,7 +39,6 @@
|
||||
"applesauce-react": "^4.0.0",
|
||||
"applesauce-relay": "latest",
|
||||
"applesauce-signers": "^4.1.0",
|
||||
"applesauce-vault": "^4.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -53,6 +53,7 @@
|
||||
"lucide-react": "latest",
|
||||
"media-chrome": "^4.17.2",
|
||||
"prismjs": "^1.30.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
253
src/components/ShareSpellbookDialog.tsx
Normal file
253
src/components/ShareSpellbookDialog.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Button } from "./ui/button";
|
||||
import { Copy, Check, QrCode } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { ParsedSpellbook } from "@/types/spell";
|
||||
import QRCodeLib from "qrcode";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
|
||||
interface ShareFormat {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
getValue: (event: NostrEvent, spellbook: ParsedSpellbook, actor: string) => string;
|
||||
}
|
||||
|
||||
interface ShareSpellbookDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
event: NostrEvent;
|
||||
spellbook: ParsedSpellbook;
|
||||
}
|
||||
|
||||
export function ShareSpellbookDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
event,
|
||||
spellbook,
|
||||
}: ShareSpellbookDialogProps) {
|
||||
const profile = useProfile(event.pubkey);
|
||||
const [copiedFormat, setCopiedFormat] = useState<string | null>(null);
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
|
||||
const [selectedFormat, setSelectedFormat] = useState<string>("web");
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const actor = profile?.nip05 || nip19.npubEncode(event.pubkey);
|
||||
|
||||
const formats: ShareFormat[] = [
|
||||
{
|
||||
id: "web",
|
||||
label: "Web Link",
|
||||
description: "Share as a web URL that anyone can open",
|
||||
getValue: (e, s, a) => `${window.location.origin}/preview/${a}/${s.slug}`,
|
||||
},
|
||||
{
|
||||
id: "naddr",
|
||||
label: "Nostr Address (naddr)",
|
||||
description: "NIP-19 address pointer for Nostr clients",
|
||||
getValue: (e, s) => {
|
||||
const dTag = e.tags.find((t) => t[0] === "d")?.[1];
|
||||
if (!dTag) return "";
|
||||
return nip19.naddrEncode({
|
||||
kind: 30777,
|
||||
pubkey: e.pubkey,
|
||||
identifier: dTag,
|
||||
relays: e.tags
|
||||
.filter((t) => t[0] === "r")
|
||||
.map((t) => t[1])
|
||||
.slice(0, 3),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nevent",
|
||||
label: "Nostr Event (nevent)",
|
||||
description: "NIP-19 event pointer with relay hints",
|
||||
getValue: (e) => {
|
||||
return nip19.neventEncode({
|
||||
id: e.id,
|
||||
kind: 30777,
|
||||
author: e.pubkey,
|
||||
relays: e.tags
|
||||
.filter((t) => t[0] === "r")
|
||||
.map((t) => t[1])
|
||||
.slice(0, 3),
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const selectedFormatData = formats.find((f) => f.id === selectedFormat);
|
||||
const currentValue = selectedFormatData
|
||||
? selectedFormatData.getValue(event, spellbook, actor)
|
||||
: "";
|
||||
|
||||
// Generate QR code when selected format changes
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !currentValue) return;
|
||||
|
||||
QRCodeLib.toCanvas(canvasRef.current, currentValue, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: "#000000",
|
||||
light: "#FFFFFF",
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error("QR code generation failed:", err);
|
||||
});
|
||||
|
||||
// Also generate data URL for potential download
|
||||
QRCodeLib.toDataURL(currentValue, {
|
||||
width: 512,
|
||||
margin: 2,
|
||||
})
|
||||
.then((url) => setQrCodeUrl(url))
|
||||
.catch((err) => {
|
||||
console.error("QR data URL generation failed:", err);
|
||||
});
|
||||
}, [currentValue]);
|
||||
|
||||
const handleCopy = (formatId: string) => {
|
||||
const format = formats.find((f) => f.id === formatId);
|
||||
if (!format) return;
|
||||
|
||||
const value = format.getValue(event, spellbook, actor);
|
||||
if (!value) {
|
||||
toast.error("Failed to generate share link");
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopiedFormat(formatId);
|
||||
toast.success(`${format.label} copied to clipboard`);
|
||||
|
||||
setTimeout(() => setCopiedFormat(null), 2000);
|
||||
};
|
||||
|
||||
const handleDownloadQR = () => {
|
||||
if (!qrCodeUrl) return;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = qrCodeUrl;
|
||||
link.download = `spellbook-${spellbook.slug}-qr.png`;
|
||||
link.click();
|
||||
toast.success("QR code downloaded");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share Spellbook</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share "{spellbook.title}" using any of the formats below
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Format Tabs */}
|
||||
<div className="flex gap-2 border-b border-border">
|
||||
{formats.map((format) => (
|
||||
<button
|
||||
key={format.id}
|
||||
onClick={() => setSelectedFormat(format.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
selectedFormat === format.id
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{format.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Selected Format Content */}
|
||||
{selectedFormatData && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedFormatData.description}
|
||||
</p>
|
||||
|
||||
{/* Value Display with Copy Button */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 rounded-lg border border-border bg-muted/50 p-3 font-mono text-sm break-all">
|
||||
{currentValue}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleCopy(selectedFormat)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{copiedFormat === selectedFormat ? (
|
||||
<Check className="size-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
<div className="flex flex-col items-center gap-4 p-6 rounded-lg border border-border bg-card">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<QrCode className="size-4" />
|
||||
QR Code
|
||||
</div>
|
||||
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="rounded-lg border border-border bg-white p-2"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadQR}
|
||||
className="w-full"
|
||||
>
|
||||
Download QR Code
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Copy All Formats */}
|
||||
<div className="border-t border-border pt-4 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Quick Copy
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formats.map((format) => (
|
||||
<Button
|
||||
key={format.id}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(format.id)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{copiedFormat === format.id ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
{format.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
Archive,
|
||||
Layout,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import db from "@/services/db";
|
||||
@@ -37,12 +39,16 @@ import { useReqTimeline } from "@/hooks/useReqTimeline";
|
||||
import { parseSpellbook } from "@/lib/spellbook-manager";
|
||||
import type { SpellbookEvent, ParsedSpellbook } from "@/types/spell";
|
||||
import { SPELLBOOK_KIND } from "@/constants/kinds";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
|
||||
interface SpellbookCardProps {
|
||||
spellbook: LocalSpellbook;
|
||||
onDelete: (spellbook: LocalSpellbook) => Promise<void>;
|
||||
onPublish: (spellbook: LocalSpellbook) => Promise<void>;
|
||||
onApply: (spellbook: ParsedSpellbook) => void;
|
||||
showAuthor?: boolean;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
function SpellbookCard({
|
||||
@@ -50,6 +56,8 @@ function SpellbookCard({
|
||||
onDelete,
|
||||
onPublish,
|
||||
onApply,
|
||||
showAuthor = false,
|
||||
isOwner = true,
|
||||
}: SpellbookCardProps) {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
@@ -58,6 +66,9 @@ function SpellbookCard({
|
||||
const workspaceCount = Object.keys(spellbook.content.workspaces).length;
|
||||
const windowCount = Object.keys(spellbook.content.windows).length;
|
||||
|
||||
// Get author pubkey from event if available
|
||||
const authorPubkey = spellbook.event?.pubkey;
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
@@ -130,6 +141,12 @@ function SpellbookCard({
|
||||
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
<div className="flex flex-col gap-2">
|
||||
{showAuthor && authorPubkey && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<User className="size-3" />
|
||||
<UserName pubkey={authorPubkey} className="text-xs" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4 mt-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Layout className="size-3" />
|
||||
@@ -145,51 +162,53 @@ function SpellbookCard({
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="p-4 pt-0 flex-wrap gap-2 justify-between">
|
||||
<div className="flex flex-wrap 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 && (
|
||||
{isOwner && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={spellbook.isPublished ? "outline" : "default"}
|
||||
className="h-8"
|
||||
onClick={handlePublish}
|
||||
disabled={isPublishing || isDeleting}
|
||||
variant="destructive"
|
||||
className="h-8 px-2"
|
||||
onClick={handleDelete}
|
||||
disabled={isPublishing || isDeleting || !!spellbook.deletedAt}
|
||||
>
|
||||
{isPublishing ? (
|
||||
{isDeleting ? (
|
||||
<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" />
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
)}
|
||||
{isPublishing
|
||||
? "Publishing..."
|
||||
: spellbook.isPublished
|
||||
? "Rebroadcast"
|
||||
: "Publish"}
|
||||
{spellbook.deletedAt ? "Deleted" : "Delete"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!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"
|
||||
className={cn("h-8", !isOwner && "w-full")}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply Layout
|
||||
@@ -203,7 +222,7 @@ function SpellbookCard({
|
||||
export function SpellbooksViewer() {
|
||||
const { state, loadSpellbook } = useGrimoire();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterType, setFilterType] = useState<"all" | "local" | "published">(
|
||||
const [filterType, setFilterType] = useState<"all" | "local" | "published" | "discover">(
|
||||
"all",
|
||||
);
|
||||
|
||||
@@ -212,8 +231,8 @@ export function SpellbooksViewer() {
|
||||
db.spellbooks.orderBy("createdAt").reverse().toArray(),
|
||||
);
|
||||
|
||||
// Fetch from Nostr
|
||||
const { events: networkEvents, loading: networkLoading } = useReqTimeline(
|
||||
// Fetch user's spellbooks from Nostr
|
||||
const { events: userNetworkEvents, loading: userNetworkLoading } = useReqTimeline(
|
||||
state.activeAccount
|
||||
? `user-spellbooks-${state.activeAccount.pubkey}`
|
||||
: "none",
|
||||
@@ -224,23 +243,45 @@ export function SpellbooksViewer() {
|
||||
{ stream: true },
|
||||
);
|
||||
|
||||
// Fetch discovered spellbooks from network (all authors)
|
||||
const { events: discoveredEvents, loading: discoveredLoading } = useReqTimeline(
|
||||
filterType === "discover" ? "discover-spellbooks" : "none",
|
||||
filterType === "discover" ? { kinds: [SPELLBOOK_KIND], limit: 50 } : [],
|
||||
AGGREGATOR_RELAYS,
|
||||
{ stream: true },
|
||||
);
|
||||
|
||||
const networkLoading = userNetworkLoading || discoveredLoading;
|
||||
|
||||
const loading = localSpellbooks === undefined;
|
||||
|
||||
// Filter and sort
|
||||
const { filteredSpellbooks, totalCount } = useMemo(() => {
|
||||
const allSpellbooksMap = new Map<string, LocalSpellbook>();
|
||||
const currentUserPubkey = state.activeAccount?.pubkey;
|
||||
|
||||
// Add local spellbooks first
|
||||
for (const s of localSpellbooks || []) {
|
||||
allSpellbooksMap.set(s.id, s);
|
||||
}
|
||||
|
||||
for (const event of networkEvents) {
|
||||
// Process network events based on filter type
|
||||
const eventsToProcess = filterType === "discover"
|
||||
? discoveredEvents
|
||||
: userNetworkEvents;
|
||||
|
||||
for (const event of eventsToProcess) {
|
||||
// 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.
|
||||
// For discovered mode, skip user's own spellbooks (they're in userNetworkEvents)
|
||||
if (filterType === "discover" && event.pubkey === currentUserPubkey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look for existing by slug and author
|
||||
const existing = Array.from(allSpellbooksMap.values()).find(
|
||||
(s) => s.slug === slug,
|
||||
(s) => s.slug === slug && s.event?.pubkey === event.pubkey,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
@@ -280,6 +321,9 @@ export function SpellbooksViewer() {
|
||||
filtered = filtered.filter((s) => !s.isPublished || !!s.deletedAt);
|
||||
} else if (filterType === "published") {
|
||||
filtered = filtered.filter((s) => s.isPublished && !s.deletedAt);
|
||||
} else if (filterType === "discover") {
|
||||
// Only show network spellbooks from others
|
||||
filtered = filtered.filter((s) => s.isPublished && s.event?.pubkey !== currentUserPubkey);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
@@ -297,7 +341,7 @@ export function SpellbooksViewer() {
|
||||
});
|
||||
|
||||
return { filteredSpellbooks: filtered, totalCount: total };
|
||||
}, [localSpellbooks, networkEvents, searchQuery, filterType]);
|
||||
}, [localSpellbooks, userNetworkEvents, discoveredEvents, searchQuery, filterType, state.activeAccount?.pubkey]);
|
||||
|
||||
const handleDelete = async (spellbook: LocalSpellbook) => {
|
||||
if (!confirm(`Delete spellbook "${spellbook.title}"?`)) return;
|
||||
@@ -368,7 +412,7 @@ export function SpellbooksViewer() {
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filterType === "all" ? "default" : "outline"}
|
||||
@@ -381,6 +425,7 @@ export function SpellbooksViewer() {
|
||||
variant={filterType === "local" ? "default" : "outline"}
|
||||
onClick={() => setFilterType("local")}
|
||||
>
|
||||
<Lock className="size-3 mr-1" />
|
||||
Local
|
||||
</Button>
|
||||
<Button
|
||||
@@ -388,8 +433,17 @@ export function SpellbooksViewer() {
|
||||
variant={filterType === "published" ? "default" : "outline"}
|
||||
onClick={() => setFilterType("published")}
|
||||
>
|
||||
<Cloud className="size-3 mr-1" />
|
||||
Published
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filterType === "discover" ? "default" : "outline"}
|
||||
onClick={() => setFilterType("discover")}
|
||||
>
|
||||
<Globe className="size-3 mr-1" />
|
||||
Discover
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -405,15 +459,22 @@ export function SpellbooksViewer() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 grid-cols-1">
|
||||
{filteredSpellbooks.map((s) => (
|
||||
<SpellbookCard
|
||||
key={s.id}
|
||||
spellbook={s}
|
||||
onDelete={handleDelete}
|
||||
onPublish={handlePublish}
|
||||
onApply={handleApply}
|
||||
/>
|
||||
))}
|
||||
{filteredSpellbooks.map((s) => {
|
||||
const isOwner = s.event?.pubkey === state.activeAccount?.pubkey || !s.event;
|
||||
const showAuthor = filterType === "discover" || !isOwner;
|
||||
|
||||
return (
|
||||
<SpellbookCard
|
||||
key={s.id}
|
||||
spellbook={s}
|
||||
onDelete={handleDelete}
|
||||
onPublish={handlePublish}
|
||||
onApply={handleApply}
|
||||
showAuthor={showAuthor}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
BaseEventProps,
|
||||
@@ -15,6 +15,7 @@ import { nip19 } from "nostr-tools";
|
||||
import { useNavigate } from "react-router";
|
||||
import { KindBadge } from "@/components/KindBadge";
|
||||
import { WindowInstance } from "@/types/app";
|
||||
import { ShareSpellbookDialog } from "@/components/ShareSpellbookDialog";
|
||||
|
||||
/**
|
||||
* Helper to extract all unique event kinds from a spellbook's windows
|
||||
@@ -283,6 +284,7 @@ export function SpellbookRenderer({ event }: BaseEventProps) {
|
||||
*/
|
||||
export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const profile = useProfile(event.pubkey);
|
||||
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
||||
|
||||
const spellbook = useMemo(() => {
|
||||
try {
|
||||
@@ -300,13 +302,6 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
@@ -327,11 +322,11 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyLink}
|
||||
onClick={() => setShareDialogOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Share2 className="size-4" />
|
||||
Share Link
|
||||
Share
|
||||
</Button>
|
||||
|
||||
<PreviewButton
|
||||
@@ -388,6 +383,14 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share Dialog */}
|
||||
<ShareSpellbookDialog
|
||||
open={shareDialogOpen}
|
||||
onOpenChange={setShareDialogOpen}
|
||||
event={event}
|
||||
spellbook={spellbook}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import pool from "./relay-pool";
|
||||
import { relayListCache } from "./relay-list-cache";
|
||||
import { getSeenRelays } from "applesauce-core/helpers/relays";
|
||||
import type { NostrEvent } from "nostr-tools/core";
|
||||
import accountManager from "./accounts";
|
||||
|
||||
/**
|
||||
* Publishes a Nostr event to relays using the author's outbox relays
|
||||
@@ -25,7 +26,7 @@ async function publishEvent(event: NostrEvent): Promise<void> {
|
||||
// If still no relays, throw error
|
||||
if (relays.length === 0) {
|
||||
throw new Error(
|
||||
"No relays found for publishing. Please configure relay list (kind 10002) or ensure event has relay hints."
|
||||
"No relays found for publishing. Please configure relay list (kind 10002) or ensure event has relay hints.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +37,8 @@ async function publishEvent(event: NostrEvent): Promise<void> {
|
||||
eventStore.add(event);
|
||||
}
|
||||
|
||||
const factory = new EventFactory();
|
||||
|
||||
/**
|
||||
* Global action hub for Grimoire
|
||||
* Used to register and execute actions throughout the application
|
||||
@@ -45,4 +48,10 @@ async function publishEvent(event: NostrEvent): Promise<void> {
|
||||
* - EventFactory: Creates and signs events
|
||||
* - publishEvent: Publishes events to author's outbox relays (with fallback to seen relays)
|
||||
*/
|
||||
export const hub = new ActionHub(eventStore, new EventFactory(), publishEvent);
|
||||
export const hub = new ActionHub(eventStore, factory, publishEvent);
|
||||
|
||||
// Sync factory signer with active account
|
||||
// This ensures the hub can sign events when an account is active
|
||||
accountManager.active$.subscribe((account) => {
|
||||
factory.setSigner(account?.signer || undefined);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user