Browse Source

Fix errors surfaced by Svelte check

master
buttercat1791 3 months ago
parent
commit
e935daf08a
  1. 186
      deno.lock
  2. 153
      src/lib/a/cards/AEventPreview.svelte
  3. 120
      src/lib/components/EventDetails.svelte
  4. 108
      src/lib/components/EventKindFilter.svelte
  5. 8
      src/lib/components/Navigation.svelte
  6. 53
      src/lib/components/Preview.svelte
  7. 737
      src/lib/components/ZettelEditor.svelte
  8. 328
      src/lib/components/publications/HighlightLayer.svelte
  9. 83
      src/lib/components/publications/HighlightSelectionHandler.svelte
  10. 319
      src/lib/components/publications/Publication.svelte
  11. 70
      src/lib/components/publications/PublicationSection.svelte
  12. 85
      src/lib/components/util/CardActions.svelte
  13. 20
      src/lib/components/util/Interactions.svelte
  14. 37
      src/routes/new/edit/+page.svelte
  15. 581
      tests/unit/commentButton.test.ts
  16. 77
      tests/unit/deletion.test.ts
  17. 52
      tests/unit/fetchPublicationHighlights.test.ts
  18. 36
      tests/unit/highlightSelection.test.ts

186
deno.lock

@ -62,6 +62,7 @@
"npm:typescript@^5.8.3": "5.9.2", "npm:typescript@^5.8.3": "5.9.2",
"npm:vite@^6.3.5": "6.3.5_@types+node@24.3.0_yaml@2.8.1_picomatch@4.0.3", "npm:vite@^6.3.5": "6.3.5_@types+node@24.3.0_yaml@2.8.1_picomatch@4.0.3",
"npm:vitest@^3.1.3": "3.2.4_@types+node@24.3.0_vite@6.3.5__@types+node@24.3.0__yaml@2.8.1__picomatch@4.0.3_yaml@2.8.1", "npm:vitest@^3.1.3": "3.2.4_@types+node@24.3.0_vite@6.3.5__@types+node@24.3.0__yaml@2.8.1__picomatch@4.0.3_yaml@2.8.1",
"npm:ws@^8.18.3": "8.18.3",
"npm:yaml@^2.5.0": "2.8.1" "npm:yaml@^2.5.0": "2.8.1"
}, },
"jsr": { "jsr": {
@ -326,261 +327,131 @@
"tslib" "tslib"
] ]
}, },
"@esbuild/aix-ppc64@0.25.7": {
"integrity": "sha512-uD0kKFHh6ETr8TqEtaAcV+dn/2qnYbH/+8wGEdY70Qf7l1l/jmBUbrmQqwiPKAQE6cOQ7dTj6Xr0HzQDGHyceQ==",
"os": ["aix"],
"cpu": ["ppc64"]
},
"@esbuild/aix-ppc64@0.25.9": { "@esbuild/aix-ppc64@0.25.9": {
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
"os": ["aix"], "os": ["aix"],
"cpu": ["ppc64"] "cpu": ["ppc64"]
}, },
"@esbuild/android-arm64@0.25.7": {
"integrity": "sha512-p0ohDnwyIbAtztHTNUTzN5EGD/HJLs1bwysrOPgSdlIA6NDnReoVfoCyxG6W1d85jr2X80Uq5KHftyYgaK9LPQ==",
"os": ["android"],
"cpu": ["arm64"]
},
"@esbuild/android-arm64@0.25.9": { "@esbuild/android-arm64@0.25.9": {
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
"os": ["android"], "os": ["android"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/android-arm@0.25.7": {
"integrity": "sha512-Jhuet0g1k9rAJHrXGIh7sFknFuT4sfytYZpZpuZl7YKDhnPByVAm5oy2LEBmMbuYf3ejWVYCc2seX81Mk+madA==",
"os": ["android"],
"cpu": ["arm"]
},
"@esbuild/android-arm@0.25.9": { "@esbuild/android-arm@0.25.9": {
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
"os": ["android"], "os": ["android"],
"cpu": ["arm"] "cpu": ["arm"]
}, },
"@esbuild/android-x64@0.25.7": {
"integrity": "sha512-mMxIJFlSgVK23HSsII3ZX9T2xKrBCDGyk0qiZnIW10LLFFtZLkFD6imZHu7gUo2wkNZwS9Yj3mOtZD3ZPcjCcw==",
"os": ["android"],
"cpu": ["x64"]
},
"@esbuild/android-x64@0.25.9": { "@esbuild/android-x64@0.25.9": {
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
"os": ["android"], "os": ["android"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@esbuild/darwin-arm64@0.25.7": {
"integrity": "sha512-jyOFLGP2WwRwxM8F1VpP6gcdIJc8jq2CUrURbbTouJoRO7XCkU8GdnTDFIHdcifVBT45cJlOYsZ1kSlfbKjYUQ==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@esbuild/darwin-arm64@0.25.9": { "@esbuild/darwin-arm64@0.25.9": {
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
"os": ["darwin"], "os": ["darwin"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/darwin-x64@0.25.7": {
"integrity": "sha512-m9bVWqZCwQ1BthruifvG64hG03zzz9gE2r/vYAhztBna1/+qXiHyP9WgnyZqHgGeXoimJPhAmxfbeU+nMng6ZA==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@esbuild/darwin-x64@0.25.9": { "@esbuild/darwin-x64@0.25.9": {
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
"os": ["darwin"], "os": ["darwin"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@esbuild/freebsd-arm64@0.25.7": {
"integrity": "sha512-Bss7P4r6uhr3kDzRjPNEnTm/oIBdTPRNQuwaEFWT/uvt6A1YzK/yn5kcx5ZxZ9swOga7LqeYlu7bDIpDoS01bA==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
"@esbuild/freebsd-arm64@0.25.9": { "@esbuild/freebsd-arm64@0.25.9": {
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
"os": ["freebsd"], "os": ["freebsd"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/freebsd-x64@0.25.7": {
"integrity": "sha512-S3BFyjW81LXG7Vqmr37ddbThrm3A84yE7ey/ERBlK9dIiaWgrjRlre3pbG7txh1Uaxz8N7wGGQXmC9zV+LIpBQ==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@esbuild/freebsd-x64@0.25.9": { "@esbuild/freebsd-x64@0.25.9": {
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
"os": ["freebsd"], "os": ["freebsd"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@esbuild/linux-arm64@0.25.7": {
"integrity": "sha512-HfQZQqrNOfS1Okn7PcsGUqHymL1cWGBslf78dGvtrj8q7cN3FkapFgNA4l/a5lXDwr7BqP2BSO6mz9UremNPbg==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@esbuild/linux-arm64@0.25.9": { "@esbuild/linux-arm64@0.25.9": {
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
"os": ["linux"], "os": ["linux"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/linux-arm@0.25.7": {
"integrity": "sha512-JZMIci/1m5vfQuhKoFXogCKVYVfYQmoZJg8vSIMR4TUXbF+0aNlfXH3DGFEFMElT8hOTUF5hisdZhnrZO/bkDw==",
"os": ["linux"],
"cpu": ["arm"]
},
"@esbuild/linux-arm@0.25.9": { "@esbuild/linux-arm@0.25.9": {
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
"os": ["linux"], "os": ["linux"],
"cpu": ["arm"] "cpu": ["arm"]
}, },
"@esbuild/linux-ia32@0.25.7": {
"integrity": "sha512-9Jex4uVpdeofiDxnwHRgen+j6398JlX4/6SCbbEFEXN7oMO2p0ueLN+e+9DdsdPLUdqns607HmzEFnxwr7+5wQ==",
"os": ["linux"],
"cpu": ["ia32"]
},
"@esbuild/linux-ia32@0.25.9": { "@esbuild/linux-ia32@0.25.9": {
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
"os": ["linux"], "os": ["linux"],
"cpu": ["ia32"] "cpu": ["ia32"]
}, },
"@esbuild/linux-loong64@0.25.7": {
"integrity": "sha512-TG1KJqjBlN9IHQjKVUYDB0/mUGgokfhhatlay8aZ/MSORMubEvj/J1CL8YGY4EBcln4z7rKFbsH+HeAv0d471w==",
"os": ["linux"],
"cpu": ["loong64"]
},
"@esbuild/linux-loong64@0.25.9": { "@esbuild/linux-loong64@0.25.9": {
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
"os": ["linux"], "os": ["linux"],
"cpu": ["loong64"] "cpu": ["loong64"]
}, },
"@esbuild/linux-mips64el@0.25.7": {
"integrity": "sha512-Ty9Hj/lx7ikTnhOfaP7ipEm/ICcBv94i/6/WDg0OZ3BPBHhChsUbQancoWYSO0WNkEiSW5Do4febTTy4x1qYQQ==",
"os": ["linux"],
"cpu": ["mips64el"]
},
"@esbuild/linux-mips64el@0.25.9": { "@esbuild/linux-mips64el@0.25.9": {
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
"os": ["linux"], "os": ["linux"],
"cpu": ["mips64el"] "cpu": ["mips64el"]
}, },
"@esbuild/linux-ppc64@0.25.7": {
"integrity": "sha512-MrOjirGQWGReJl3BNQ58BLhUBPpWABnKrnq8Q/vZWWwAB1wuLXOIxS2JQ1LT3+5T+3jfPh0tyf5CpbyQHqnWIQ==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@esbuild/linux-ppc64@0.25.9": { "@esbuild/linux-ppc64@0.25.9": {
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
"os": ["linux"], "os": ["linux"],
"cpu": ["ppc64"] "cpu": ["ppc64"]
}, },
"@esbuild/linux-riscv64@0.25.7": {
"integrity": "sha512-9pr23/pqzyqIZEZmQXnFyqp3vpa+KBk5TotfkzGMqpw089PGm0AIowkUppHB9derQzqniGn3wVXgck19+oqiOw==",
"os": ["linux"],
"cpu": ["riscv64"]
},
"@esbuild/linux-riscv64@0.25.9": { "@esbuild/linux-riscv64@0.25.9": {
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
"os": ["linux"], "os": ["linux"],
"cpu": ["riscv64"] "cpu": ["riscv64"]
}, },
"@esbuild/linux-s390x@0.25.7": {
"integrity": "sha512-4dP11UVGh9O6Y47m8YvW8eoA3r8qL2toVZUbBKyGta8j6zdw1cn9F/Rt59/Mhv0OgY68pHIMjGXWOUaykCnx+w==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@esbuild/linux-s390x@0.25.9": { "@esbuild/linux-s390x@0.25.9": {
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
"os": ["linux"], "os": ["linux"],
"cpu": ["s390x"] "cpu": ["s390x"]
}, },
"@esbuild/linux-x64@0.25.7": {
"integrity": "sha512-ghJMAJTdw/0uhz7e7YnpdX1xVn7VqA0GrWrAO2qKMuqbvgHT2VZiBv1BQ//VcHsPir4wsL3P2oPggfKPzTKoCA==",
"os": ["linux"],
"cpu": ["x64"]
},
"@esbuild/linux-x64@0.25.9": { "@esbuild/linux-x64@0.25.9": {
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
"os": ["linux"], "os": ["linux"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@esbuild/netbsd-arm64@0.25.7": {
"integrity": "sha512-bwXGEU4ua45+u5Ci/a55B85KWaDSRS8NPOHtxy2e3etDjbz23wlry37Ffzapz69JAGGc4089TBo+dGzydQmydg==",
"os": ["netbsd"],
"cpu": ["arm64"]
},
"@esbuild/netbsd-arm64@0.25.9": { "@esbuild/netbsd-arm64@0.25.9": {
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
"os": ["netbsd"], "os": ["netbsd"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/netbsd-x64@0.25.7": {
"integrity": "sha512-tUZRvLtgLE5OyN46sPSYlgmHoBS5bx2URSrgZdW1L1teWPYVmXh+QN/sKDqkzBo/IHGcKcHLKDhBeVVkO7teEA==",
"os": ["netbsd"],
"cpu": ["x64"]
},
"@esbuild/netbsd-x64@0.25.9": { "@esbuild/netbsd-x64@0.25.9": {
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
"os": ["netbsd"], "os": ["netbsd"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@esbuild/openbsd-arm64@0.25.7": {
"integrity": "sha512-bTJ50aoC+WDlDGBReWYiObpYvQfMjBNlKztqoNUL0iUkYtwLkBQQeEsTq/I1KyjsKA5tyov6VZaPb8UdD6ci6Q==",
"os": ["openbsd"],
"cpu": ["arm64"]
},
"@esbuild/openbsd-arm64@0.25.9": { "@esbuild/openbsd-arm64@0.25.9": {
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
"os": ["openbsd"], "os": ["openbsd"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/openbsd-x64@0.25.7": {
"integrity": "sha512-TA9XfJrgzAipFUU895jd9j2SyDh9bbNkK2I0gHcvqb/o84UeQkBpi/XmYX3cO1q/9hZokdcDqQxIi6uLVrikxg==",
"os": ["openbsd"],
"cpu": ["x64"]
},
"@esbuild/openbsd-x64@0.25.9": { "@esbuild/openbsd-x64@0.25.9": {
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
"os": ["openbsd"], "os": ["openbsd"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@esbuild/openharmony-arm64@0.25.7": {
"integrity": "sha512-5VTtExUrWwHHEUZ/N+rPlHDwVFQ5aME7vRJES8+iQ0xC/bMYckfJ0l2n3yGIfRoXcK/wq4oXSItZAz5wslTKGw==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
"@esbuild/openharmony-arm64@0.25.9": { "@esbuild/openharmony-arm64@0.25.9": {
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
"os": ["openharmony"], "os": ["openharmony"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/sunos-x64@0.25.7": {
"integrity": "sha512-umkbn7KTxsexhv2vuuJmj9kggd4AEtL32KodkJgfhNOHMPtQ55RexsaSrMb+0+jp9XL4I4o2y91PZauVN4cH3A==",
"os": ["sunos"],
"cpu": ["x64"]
},
"@esbuild/sunos-x64@0.25.9": { "@esbuild/sunos-x64@0.25.9": {
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
"os": ["sunos"], "os": ["sunos"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@esbuild/win32-arm64@0.25.7": {
"integrity": "sha512-j20JQGP/gz8QDgzl5No5Gr4F6hurAZvtkFxAKhiv2X49yi/ih8ECK4Y35YnjlMogSKJk931iNMcd35BtZ4ghfw==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@esbuild/win32-arm64@0.25.9": { "@esbuild/win32-arm64@0.25.9": {
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
"os": ["win32"], "os": ["win32"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/win32-ia32@0.25.7": {
"integrity": "sha512-4qZ6NUfoiiKZfLAXRsvFkA0hoWVM+1y2bSHXHkpdLAs/+r0LgwqYohmfZCi985c6JWHhiXP30mgZawn/XrqAkQ==",
"os": ["win32"],
"cpu": ["ia32"]
},
"@esbuild/win32-ia32@0.25.9": { "@esbuild/win32-ia32@0.25.9": {
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
"os": ["win32"], "os": ["win32"],
"cpu": ["ia32"] "cpu": ["ia32"]
}, },
"@esbuild/win32-x64@0.25.7": {
"integrity": "sha512-FaPsAHTwm+1Gfvn37Eg3E5HIpfR3i6x1AIcla/MkqAIupD4BW3MrSeUqfoTzwwJhk3WE2/KqUn4/eenEJC76VA==",
"os": ["win32"],
"cpu": ["x64"]
},
"@esbuild/win32-x64@0.25.9": { "@esbuild/win32-x64@0.25.9": {
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
"os": ["win32"], "os": ["win32"],
@ -2128,32 +1999,32 @@
"esbuild@0.25.9": { "esbuild@0.25.9": {
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
"optionalDependencies": [ "optionalDependencies": [
"@esbuild/aix-ppc64@0.25.9", "@esbuild/aix-ppc64",
"@esbuild/android-arm@0.25.9", "@esbuild/android-arm",
"@esbuild/android-arm64@0.25.9", "@esbuild/android-arm64",
"@esbuild/android-x64@0.25.9", "@esbuild/android-x64",
"@esbuild/darwin-arm64@0.25.9", "@esbuild/darwin-arm64",
"@esbuild/darwin-x64@0.25.9", "@esbuild/darwin-x64",
"@esbuild/freebsd-arm64@0.25.9", "@esbuild/freebsd-arm64",
"@esbuild/freebsd-x64@0.25.9", "@esbuild/freebsd-x64",
"@esbuild/linux-arm@0.25.9", "@esbuild/linux-arm",
"@esbuild/linux-arm64@0.25.9", "@esbuild/linux-arm64",
"@esbuild/linux-ia32@0.25.9", "@esbuild/linux-ia32",
"@esbuild/linux-loong64@0.25.9", "@esbuild/linux-loong64",
"@esbuild/linux-mips64el@0.25.9", "@esbuild/linux-mips64el",
"@esbuild/linux-ppc64@0.25.9", "@esbuild/linux-ppc64",
"@esbuild/linux-riscv64@0.25.9", "@esbuild/linux-riscv64",
"@esbuild/linux-s390x@0.25.9", "@esbuild/linux-s390x",
"@esbuild/linux-x64@0.25.9", "@esbuild/linux-x64",
"@esbuild/netbsd-arm64@0.25.9", "@esbuild/netbsd-arm64",
"@esbuild/netbsd-x64@0.25.9", "@esbuild/netbsd-x64",
"@esbuild/openbsd-arm64@0.25.9", "@esbuild/openbsd-arm64",
"@esbuild/openbsd-x64@0.25.9", "@esbuild/openbsd-x64",
"@esbuild/openharmony-arm64@0.25.9", "@esbuild/openharmony-arm64",
"@esbuild/sunos-x64@0.25.9", "@esbuild/sunos-x64",
"@esbuild/win32-arm64@0.25.9", "@esbuild/win32-arm64",
"@esbuild/win32-ia32@0.25.9", "@esbuild/win32-ia32",
"@esbuild/win32-x64@0.25.9" "@esbuild/win32-x64"
], ],
"scripts": true, "scripts": true,
"bin": true "bin": true
@ -3650,6 +3521,9 @@
"wrappy@1.0.2": { "wrappy@1.0.2": {
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
}, },
"ws@8.18.3": {
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="
},
"y18n@4.0.3": { "y18n@4.0.3": {
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
}, },

153
src/lib/a/cards/AEventPreview.svelte

@ -1,77 +1,77 @@
<script lang="ts"> <script lang="ts">
/** /**
* @fileoverview AEventPreview Component - Alexandria * @fileoverview AEventPreview Component - Alexandria
* *
* A card component for displaying nostr event previews with configurable display options. * A card component for displaying nostr event previews with configurable display options.
* Shows event metadata, content, author information, and action buttons. * Shows event metadata, content, author information, and action buttons.
* *
* @component * @component
* @category Cards * @category Cards
* *
* @prop {NDKEvent} event - The nostr event to display (required) * @prop {NDKEvent} event - The nostr event to display (required)
* @prop {string} [label=""] - Optional label/category for the event * @prop {string} [label=""] - Optional label/category for the event
* @prop {boolean} [community=false] - Whether this is a community event * @prop {boolean} [community=false] - Whether this is a community event
* @prop {number} [truncateContentAt=200] - Character limit for content truncation * @prop {number} [truncateContentAt=200] - Character limit for content truncation
* @prop {boolean} [showKind=true] - Whether to show event kind * @prop {boolean} [showKind=true] - Whether to show event kind
* @prop {boolean} [showSummary=true] - Whether to show event summary * @prop {boolean} [showSummary=true] - Whether to show event summary
* @prop {boolean} [showDeferralNaddr=true] - Whether to show deferral naddr * @prop {boolean} [showDeferralNaddr=true] - Whether to show deferral naddr
* @prop {boolean} [showPublicationLink=true] - Whether to show publication link * @prop {boolean} [showPublicationLink=true] - Whether to show publication link
* @prop {boolean} [showContent=true] - Whether to show event content * @prop {boolean} [showContent=true] - Whether to show event content
* @prop {Array<{label: string, onClick: (ev: NDKEvent) => void, variant?: string}>} [actions] - Action buttons * @prop {Array<{label: string, onClick: (ev: NDKEvent) => void, variant?: string}>} [actions] - Action buttons
* @prop {(ev: NDKEvent) => void} [onSelect] - Callback when event is selected * @prop {(ev: NDKEvent) => void} [onSelect] - Callback when event is selected
* @prop {(naddr: string, ev: NDKEvent) => void} [onDeferralClick] - Callback for deferral clicks * @prop {(naddr: string, ev: NDKEvent) => void} [onDeferralClick] - Callback for deferral clicks
* *
* @example * @example
* ```svelte * ```svelte
* <AEventPreview * <AEventPreview
* {event} * {event}
* label="Article" * label="Article"
* showContent={true} * showContent={true}
* actions={[{label: "View", onClick: handleView}]} * actions={[{label: "View", onClick: handleView}]}
* /> * />
* ``` * ```
* *
* @example Basic event preview * @example Basic event preview
* ```svelte * ```svelte
* <AEventPreview {event} /> * <AEventPreview {event} />
* ``` * ```
* *
* @example Community event with actions * @example Community event with actions
* ```svelte * ```svelte
* <AEventPreview * <AEventPreview
* {event} * {event}
* community={true} * community={true}
* actions={[ * actions={[
* {label: "Reply", onClick: handleReply}, * {label: "Reply", onClick: handleReply},
* {label: "Share", onClick: handleShare, variant: "light"} * {label: "Share", onClick: handleShare, variant: "light"}
* ]} * ]}
* /> * />
* ``` * ```
* *
* @example Minimal preview without content * @example Minimal preview without content
* ```svelte * ```svelte
* <AEventPreview * <AEventPreview
* {event} * {event}
* showContent={false} * showContent={false}
* showSummary={false} * showSummary={false}
* truncateContentAt={100} * truncateContentAt={100}
* /> * />
* ``` * ```
* *
* @features * @features
* - Responsive card layout with author badges * - Responsive card layout with author badges
* - Content truncation with "show more" functionality * - Content truncation with "show more" functionality
* - Publication links and metadata display * - Publication links and metadata display
* - Configurable action buttons * - Configurable action buttons
* - Community event highlighting * - Community event highlighting
* - Event kind and summary display * - Event kind and summary display
* *
* @accessibility * @accessibility
* - Semantic card structure * - Semantic card structure
* - Keyboard accessible action buttons * - Keyboard accessible action buttons
* - Screen reader friendly metadata * - Screen reader friendly metadata
* - Proper heading hierarchy * - Proper heading hierarchy
*/ */
import { Card, Button } from "flowbite-svelte"; import { Card, Button } from "flowbite-svelte";
import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte"; import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte";
@ -198,7 +198,7 @@
<Card <Card
class="event-preview-card" class="event-preview-card"
role="group" role="group"
tabindex="0" tabindex={0}
aria-label="Event preview" aria-label="Event preview"
onclick={handleSelect} onclick={handleSelect}
onkeydown={handleKeydown} onkeydown={handleKeydown}
@ -219,10 +219,7 @@
</span> </span>
{/if} {/if}
{#if community} {#if community}
<span <span class="community-badge" title="Has posted to the community">
class="community-badge"
title="Has posted to the community"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path <path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"

120
src/lib/components/EventDetails.svelte

@ -41,7 +41,10 @@
let authorDisplayName = $state<string | undefined>(undefined); let authorDisplayName = $state<string | undefined>(undefined);
let showFullContent = $state(false); let showFullContent = $state(false);
let shouldTruncate = $derived(event.content.length > 250 && !showFullContent); let shouldTruncate = $derived(event.content.length > 250 && !showFullContent);
let isRepost = $derived(repostKinds.includes(event.kind) || (event.kind === 1 && event.getMatchingTags("q").length > 0)); let isRepost = $derived(
repostKinds.includes(event.kind) ||
(event.kind === 1 && event.getMatchingTags("q").length > 0),
);
function getEventTitle(event: NDKEvent): string { function getEventTitle(event: NDKEvent): string {
// First try to get title from title tag // First try to get title from title tag
@ -253,13 +256,15 @@
return; return;
} }
getUserMetadata(toNpub(event.pubkey) as string, undefined).then((profile) => { getUserMetadata(toNpub(event.pubkey) as string, undefined).then(
authorDisplayName = (profile) => {
profile.displayName || authorDisplayName =
(profile as any).display_name || profile.displayName ||
profile.name || (profile as any).display_name ||
event.pubkey; profile.name ||
}); event.pubkey;
},
);
}); });
// --- Identifier helpers --- // --- Identifier helpers ---
@ -300,7 +305,11 @@
ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` }); ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` });
} catch {} } catch {}
// hex id - make it a clickable link to search for the event ID // hex id - make it a clickable link to search for the event ID
ids.push({ label: "id", value: event.id, link: `/events?id=${event.id}` }); ids.push({
label: "id",
value: event.id,
link: `/events?id=${event.id}`,
});
} }
return ids; return ids;
} }
@ -333,17 +342,17 @@
<div class="flex items-center space-x-2 min-w-0"> <div class="flex items-center space-x-2 min-w-0">
{#if toNpub(event.pubkey)} {#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400 min-w-0" <span class="text-gray-600 dark:text-gray-400 min-w-0"
>Author: {@render userBadge( >Author: {@render userBadge(
toNpub(event.pubkey) || '', toNpub(event.pubkey) || "",
profile?.display_name || undefined, profile?.display_name || undefined,
ndk, ndk,
)}</span )}</span
> >
{:else} {:else}
<span class="text-gray-600 dark:text-gray-400 min-w-0 break-words" <span class="text-gray-600 dark:text-gray-400 min-w-0 break-words"
>Author: {profile?.display_name || event.pubkey}</span >Author: {profile?.display_name || event.pubkey}</span
> >
{/if} {/if}
</div> </div>
@ -351,13 +360,15 @@
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span> <span class="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span>
<span class="font-mono flex-shrink-0">{event.kind}</span> <span class="font-mono flex-shrink-0">{event.kind}</span>
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0" <span class="text-gray-700 dark:text-gray-300 flex-shrink-0"
>({getEventTypeDisplay(event)})</span >({getEventTypeDisplay(event)})</span
> >
</div> </div>
<div class="flex flex-col space-y-1 min-w-0"> <div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300">Summary:</span> <span class="text-gray-700 dark:text-gray-300">Summary:</span>
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0"> <div
class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0"
>
{@render basicMarkup(getEventSummary(event), ndk)} {@render basicMarkup(getEventSummary(event), ndk)}
</div> </div>
</div> </div>
@ -370,29 +381,41 @@
{#if event.kind !== 0} {#if event.kind !== 0}
{@const kind = event.kind} {@const kind = event.kind}
{@const content = event.content.trim()} {@const content = event.content.trim()}
<div class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border max-w-full overflow-hidden"> <div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border max-w-full overflow-hidden"
>
<div class="flex flex-col space-y-1 min-w-0"> <div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300 font-semibold">Content:</span> <span class="text-gray-700 dark:text-gray-300 font-semibold"
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}> >Content:</span
>
<div class={shouldTruncate ? "max-h-32 overflow-hidden" : ""}>
{#if isRepost} {#if isRepost}
<!-- Repost content handling --> <!-- Repost content handling -->
{#if repostKinds.includes(event.kind)} {#if repostKinds.includes(event.kind)}
<!-- Kind 6 and 16 reposts - stringified JSON content --> <!-- Kind 6 and 16 reposts - stringified JSON content -->
<div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2"> <div
class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2"
>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> <div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{event.kind === 6 ? 'Reposted content:' : 'Generic reposted content:'} {event.kind === 6
? "Reposted content:"
: "Generic reposted content:"}
</div> </div>
{@render repostContent(event.content)} {@render repostContent(event.content)}
</div> </div>
{:else if event.kind === 1 && event.getMatchingTags("q").length > 0} {:else if event.kind === 1 && event.getMatchingTags("q").length > 0}
<!-- Quote repost - kind 1 with q tag --> <!-- Quote repost - kind 1 with q tag -->
<div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2"> <div
class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2"
>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> <div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Quote repost: Quote repost:
</div> </div>
{@render quotedContent(event, [], ndk)} {@render quotedContent(event, [], ndk)}
{#if content} {#if content}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"> <div
class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> <div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Added comment: Added comment:
</div> </div>
@ -407,7 +430,7 @@
{/if} {/if}
{:else} {:else}
<!-- Regular content --> <!-- Regular content -->
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}> <div class={shouldTruncate ? "max-h-32 overflow-hidden" : ""}>
{#if repostKinds.includes(kind)} {#if repostKinds.includes(kind)}
{@html content} {@html content}
{:else} {:else}
@ -428,35 +451,46 @@
<!-- If event is profile --> <!-- If event is profile -->
{#if event.kind === 0} {#if event.kind === 0}
<AProfilePreview event={event} profile={profile} communityStatusMap={communityStatusMap} /> <AProfilePreview {event} {profile} {communityStatusMap} />
{/if} {/if}
<ATechBlock> <ATechBlock>
{#snippet content()} {#snippet content()}
<Heading tag="h3" class="h-leather my-6"> <Heading tag="h3" class="h-leather my-6">Technical details</Heading>
Technical details
</Heading>
<Accordion flush class="w-full"> <Accordion flush class="w-full">
<AccordionItem open={false} > <AccordionItem open={false}>
{#snippet header()}Identifiers{/snippet} {#snippet header()}Identifiers{/snippet}
{#if event} {#if event}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each getIdentifiers(event, profile) as identifier} {#each getIdentifiers(event, profile) as identifier}
<div class="grid grid-cols-[max-content_minmax(0,1fr)_max-content] items-start gap-2 min-w-0"> <div
<span class="min-w-24 text-gray-600 dark:text-gray-400">{identifier.label}:</span> class="grid grid-cols-[max-content_minmax(0,1fr)_max-content] items-start gap-2 min-w-0"
>
<span class="min-w-24 text-gray-600 dark:text-gray-400"
>{identifier.label}:</span
>
<div class="min-w-0"> <div class="min-w-0">
{#if identifier.link} {#if identifier.link}
<button class="font-mono text-sm text-primary-700 dark:text-primary-300 hover:text-primary-900 dark:hover:text-primary-100 break-all cursor-pointer bg-transparent border-none p-0 text-left" <button
onclick={() => navigateToIdentifier(identifier.link)}> class="font-mono text-sm text-primary-700 dark:text-primary-300 hover:text-primary-900 dark:hover:text-primary-100 break-all cursor-pointer bg-transparent border-none p-0 text-left"
onclick={() =>
navigateToIdentifier(identifier.link ?? "")}
>
{identifier.value} {identifier.value}
</button> </button>
{:else} {:else}
<span class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all">{identifier.value}</span> <span
class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all"
>{identifier.value}</span
>
{/if} {/if}
</div> </div>
<div class="justify-self-end"> <div class="justify-self-end">
<CopyToClipboard displayText="" copyText={identifier.value} /> <CopyToClipboard
displayText=""
copyText={identifier.value}
/>
</div> </div>
</div> </div>
{/each} {/each}
@ -494,8 +528,10 @@
/> />
</div> </div>
{#if event} {#if event}
<pre class="p-4 wrap-break-word bg-highlight dark:bg-primary-900"> <pre class="p-4 wrap-break-word bg-highlight dark:bg-primary-900">
<code class="text-wrap">{JSON.stringify(event.rawEvent(), null, 2)}</code> <code class="text-wrap"
>{JSON.stringify(event.rawEvent(), null, 2)}</code
>
</pre> </pre>
{/if} {/if}
</AccordionItem> </AccordionItem>

108
src/lib/components/EventKindFilter.svelte

@ -1,41 +1,41 @@
<script lang="ts"> <script lang="ts">
import { visualizationConfig, enabledEventKinds } from '$lib/stores/visualizationConfig'; import {
import { Button, Badge } from 'flowbite-svelte'; visualizationConfig,
import { CloseCircleOutline } from 'flowbite-svelte-icons'; enabledEventKinds,
} from "$lib/stores/visualizationConfig";
import { Button, Badge } from "flowbite-svelte";
import { CloseCircleOutline } from "flowbite-svelte-icons";
import type { EventCounts } from "$lib/types"; import type { EventCounts } from "$lib/types";
import { NostrKind } from '$lib/types'; import { NostrKind } from "$lib/types";
let { let { onReload = () => {}, eventCounts = {} } = $props<{
onReload = () => {},
eventCounts = {}
} = $props<{
onReload?: () => void; onReload?: () => void;
eventCounts?: EventCounts; eventCounts?: EventCounts;
}>(); }>();
let newKind = $state(''); let newKind = $state("");
let showAddInput = $state(false); let showAddInput = $state(false);
let inputError = $state(''); let inputError = $state("");
function validateKind(value: string): number | null { function validateKind(value: string): number | null {
if (!value || value.trim() === '') { if (!value || value.trim() === "") {
inputError = ''; inputError = "";
return null; return null;
} }
const kind = parseInt(value.trim()); const kind = parseInt(value.trim());
if (isNaN(kind)) { if (isNaN(kind)) {
inputError = 'Must be a number'; inputError = "Must be a number";
return null; return null;
} }
if (kind < 0) { if (kind < 0) {
inputError = 'Must be positive'; inputError = "Must be positive";
return null; return null;
} }
if ($visualizationConfig.eventConfigs.some(ec => ec.kind === kind)) { if ($visualizationConfig.eventConfigs.some((ec) => ec.kind === kind)) {
inputError = 'Already added'; inputError = "Already added";
return null; return null;
} }
inputError = ''; inputError = "";
return kind; return kind;
} }
@ -43,19 +43,19 @@
const kind = validateKind(newKind); const kind = validateKind(newKind);
if (kind != null) { if (kind != null) {
visualizationConfig.addEventKind(kind); visualizationConfig.addEventKind(kind);
newKind = ''; newKind = "";
showAddInput = false; showAddInput = false;
inputError = ''; inputError = "";
} }
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') { if (e.key === "Enter") {
handleAddKind(); handleAddKind();
} else if (e.key === 'Escape') { } else if (e.key === "Escape") {
showAddInput = false; showAddInput = false;
newKind = ''; newKind = "";
inputError = ''; inputError = "";
} }
} }
@ -69,12 +69,18 @@
function getKindName(kind: number): string { function getKindName(kind: number): string {
switch (kind) { switch (kind) {
case NostrKind.PublicationIndex: return 'Publication Index'; case NostrKind.PublicationIndex:
case NostrKind.PublicationContent: return 'Publication Content'; return "Publication Index";
case NostrKind.Wiki: return 'Wiki'; case NostrKind.PublicationContent:
case NostrKind.TextNote: return 'Text Note'; return "Publication Content";
case NostrKind.UserMetadata: return 'Metadata'; case NostrKind.Wiki:
default: return `Kind ${kind}`; return "Wiki";
case NostrKind.TextNote:
return "Text Note";
case NostrKind.UserMetadata:
return "Metadata";
default:
return `Kind ${kind}`;
} }
} }
</script> </script>
@ -84,15 +90,21 @@
{#each $visualizationConfig.eventConfigs as ec} {#each $visualizationConfig.eventConfigs as ec}
{@const isEnabled = ec.enabled !== false} {@const isEnabled = ec.enabled !== false}
{@const isLoaded = (eventCounts[ec.kind] || 0) > 0} {@const isLoaded = (eventCounts[ec.kind] || 0) > 0}
{@const borderColor = isLoaded ? 'border-green-500' : 'border-red-500'} {@const borderColor = isLoaded ? "border-green-500" : "border-red-500"}
<button <button
class="badge-container {isEnabled ? '' : 'disabled'} {isLoaded ? 'loaded' : 'not-loaded'}" class="badge-container {isEnabled ? '' : 'disabled'} {isLoaded
? 'loaded'
: 'not-loaded'}"
onclick={() => toggleKind(ec.kind)} onclick={() => toggleKind(ec.kind)}
title={isEnabled ? `Click to disable ${getKindName(ec.kind)}` : `Click to enable ${getKindName(ec.kind)}`} title={isEnabled
? `Click to disable ${getKindName(ec.kind)}`
: `Click to enable ${getKindName(ec.kind)}`}
> >
<Badge <Badge
color="dark" color="primary"
class="flex items-center gap-1 px-2 py-1 {isEnabled ? '' : 'opacity-40'} border-2 {borderColor}" class="flex items-center gap-1 px-2 py-1 {isEnabled
? ''
: 'opacity-40'} border-2 {borderColor}"
> >
<span class="text-xs">{ec.kind}</span> <span class="text-xs">{ec.kind}</span>
{#if isLoaded} {#if isLoaded}
@ -116,7 +128,7 @@
<Button <Button
size="xs" size="xs"
color="light" color="light"
onclick={() => showAddInput = true} onclick={() => (showAddInput = true)}
class="gap-1" class="gap-1"
> >
<span>+</span> <span>+</span>
@ -131,8 +143,19 @@
class="gap-1" class="gap-1"
title="Reload graph with current event type filters" title="Reload graph with current event type filters"
> >
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path> class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
></path>
</svg> </svg>
<span>Reload</span> <span>Reload</span>
</Button> </Button>
@ -151,16 +174,14 @@
validateKind(value); validateKind(value);
}} }}
/> />
<Button size="xs" onclick={handleAddKind} disabled={!newKind}> <Button size="xs" onclick={handleAddKind} disabled={!newKind}>Add</Button>
Add
</Button>
<Button <Button
size="xs" size="xs"
color="light" color="light"
onclick={() => { onclick={() => {
showAddInput = false; showAddInput = false;
newKind = ''; newKind = "";
inputError = ''; inputError = "";
}} }}
> >
Cancel Cancel
@ -175,7 +196,8 @@
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-1"> <div class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p class="flex items-center gap-2"> <p class="flex items-center gap-2">
<span class="inline-block w-3 h-3 border-2 border-green-500 rounded"></span> <span class="inline-block w-3 h-3 border-2 border-green-500 rounded"
></span>
<span>Green border = Events loaded</span> <span>Green border = Events loaded</span>
</p> </p>
<p class="flex items-center gap-2"> <p class="flex items-center gap-2">

8
src/lib/components/Navigation.svelte

@ -20,12 +20,14 @@
<NavBrand href="/"> <NavBrand href="/">
<div class="flex flex-col"> <div class="flex flex-col">
<h1 class="text-2xl font-bold">Alexandria</h1> <h1 class="text-2xl font-bold">Alexandria</h1>
<p class="text-xs font-semibold tracking-wide max-sm:max-w-[11rem]">READ THE ORIGINAL. MAKE CONNECTIONS. CULTIVATE KNOWLEDGE.</p> <p class="text-xs font-semibold tracking-wide max-sm:max-w-[11rem]">
READ THE ORIGINAL. MAKE CONNECTIONS. CULTIVATE KNOWLEDGE.
</p>
</div> </div>
</NavBrand> </NavBrand>
</div> </div>
<div class="flex md:order-2"> <div class="flex md:order-2">
<Profile isNav={true} /> <Profile />
<NavHamburger class="btn-leather" /> <NavHamburger class="btn-leather" />
</div> </div>
<NavUl class="ul-leather"> <NavUl class="ul-leather">
@ -40,7 +42,7 @@
<NavLi href="/about">About</NavLi> <NavLi href="/about">About</NavLi>
<NavLi href="/contact">Contact</NavLi> <NavLi href="/contact">Contact</NavLi>
<NavLi> <NavLi>
<DarkMode btnClass="btn-leather p-0" /> <DarkMode class="btn-leather p-0" />
</NavLi> </NavLi>
</NavUl> </NavUl>
</Navbar> </Navbar>

53
src/lib/components/Preview.svelte

@ -274,15 +274,15 @@
{#snippet contentParagraph(content: string, publicationType: string)} {#snippet contentParagraph(content: string, publicationType: string)}
{#if publicationType === "novel"} {#if publicationType === "novel"}
<P class="whitespace-normal" firstupper={isSectionStart}> <P class="whitespace-normal" firstUpper={isSectionStart}>
{@html content} {@html content}
</P> </P>
{:else if publicationType === "blog"} {:else if publicationType === "blog"}
<P class="whitespace-normal" firstupper={false}> <P class="whitespace-normal" firstUpper={false}>
{@html content} {@html content}
</P> </P>
{:else} {:else}
<P class="whitespace-normal" firstupper={false}> <P class="whitespace-normal" firstUpper={false}>
{@html content} {@html content}
</P> </P>
{/if} {/if}
@ -305,25 +305,27 @@
class="textarea-leather w-full whitespace-normal" class="textarea-leather w-full whitespace-normal"
bind:value={currentContent} bind:value={currentContent}
> >
<div slot="footer" class="flex space-x-2 justify-end"> {#snippet footer()}
<Button <div class="flex space-x-2 justify-end">
type="reset" <Button
class="btn-leather min-w-fit" type="reset"
size="sm" class="btn-leather min-w-fit"
outline size="sm"
onclick={() => toggleEditing(rootId, false)} outline
> onclick={() => toggleEditing(rootId, false)}
Cancel >
</Button> Cancel
<Button </Button>
type="submit" <Button
class="btn-leather min-w-fit" type="submit"
size="sm" class="btn-leather min-w-fit"
onclick={() => toggleEditing(rootId, true)} size="sm"
> onclick={() => toggleEditing(rootId, true)}
Save >
</Button> Save
</div> </Button>
</div>
{/snippet}
</Textarea> </Textarea>
</form> </form>
{:else} {:else}
@ -335,10 +337,9 @@
{#if isEditing} {#if isEditing}
<ButtonGroup class="w-full"> <ButtonGroup class="w-full">
<Input type="text" class="input-leather" size="lg" bind:value={title}> <Input type="text" class="input-leather" size="lg" bind:value={title}>
<CloseButton {#snippet right()}
slot="right" <CloseButton onclick={() => toggleEditing(rootId, false)} />
onclick={() => toggleEditing(rootId, false)} {/snippet}
/>
</Input> </Input>
<Button <Button
class="btn-leather" class="btn-leather"

737
src/lib/components/ZettelEditor.svelte

@ -22,8 +22,11 @@
exportEventsFromTree, exportEventsFromTree,
} from "$lib/utils/asciidoc_publication_parser"; } from "$lib/utils/asciidoc_publication_parser";
import { getNdkContext } from "$lib/ndk"; import { getNdkContext } from "$lib/ndk";
import Asciidoctor from "asciidoctor"; import Asciidoctor, { Document } from "asciidoctor";
import { extractWikiLinks, renderWikiLinksToHtml } from "$lib/utils/wiki_links"; import {
extractWikiLinks,
renderWikiLinksToHtml,
} from "$lib/utils/wiki_links";
// Initialize Asciidoctor processor // Initialize Asciidoctor processor
const asciidoctor = Asciidoctor(); const asciidoctor = Asciidoctor();
@ -159,13 +162,6 @@
keys: Object.keys(publicationResult), keys: Object.keys(publicationResult),
}); });
console.log("Event structure details:", JSON.stringify(publicationResult.metadata.eventStructure, null, 2));
console.log("Content events details:", publicationResult.contentEvents?.map(e => ({
dTag: e.tags?.find(t => t[0] === 'd')?.[1],
title: e.tags?.find(t => t[0] === 'title')?.[1],
content: e.content?.substring(0, 100) + '...'
})));
// Helper to get d-tag from event (works with both NDK events and serialized events) // Helper to get d-tag from event (works with both NDK events and serialized events)
const getEventDTag = (event: any) => { const getEventDTag = (event: any) => {
if (event?.tagValue) { if (event?.tagValue) {
@ -179,11 +175,16 @@
}; };
// Helper to find event by dTag and kind // Helper to find event by dTag and kind
const findEventByDTag = (events: any[], dTag: string, eventKind?: number) => { const findEventByDTag = (
events: any[],
dTag: string,
eventKind?: number,
) => {
return events.find((event) => { return events.find((event) => {
const matchesDTag = getEventDTag(event) === dTag; const matchesDTag = getEventDTag(event) === dTag;
if (eventKind !== undefined) { if (eventKind !== undefined) {
const eventKindValue = event?.kind || (event?.tagValue ? event.tagValue("k") : null); const eventKindValue =
event?.kind || (event?.tagValue ? event.tagValue("k") : null);
return matchesDTag && eventKindValue === eventKind; return matchesDTag && eventKindValue === eventKind;
} }
return matchesDTag; return matchesDTag;
@ -218,7 +219,11 @@
} else { } else {
// contentEvents can contain both 30040 and 30041 events at parse level 3+ // contentEvents can contain both 30040 and 30041 events at parse level 3+
// Use eventKind to find the correct event type // Use eventKind to find the correct event type
event = findEventByDTag(publicationResult.contentEvents, node.dTag, node.eventKind); event = findEventByDTag(
publicationResult.contentEvents,
node.dTag,
node.eventKind,
);
} }
// Extract all tags (t for hashtags, w for wiki links) // Extract all tags (t for hashtags, w for wiki links)
@ -228,7 +233,6 @@
const titleTag = event?.tags.find((t: string[]) => t[0] === "title"); const titleTag = event?.tags.find((t: string[]) => t[0] === "title");
const eventTitle = titleTag ? titleTag[1] : node.title; const eventTitle = titleTag ? titleTag[1] : node.title;
// For content events, remove the first heading from content since we'll use the title tag // For content events, remove the first heading from content since we'll use the title tag
let processedContent = event?.content || ""; let processedContent = event?.content || "";
if (event && node.eventType === "content") { if (event && node.eventType === "content") {
@ -237,8 +241,8 @@
// since the title is displayed separately from the "title" tag // since the title is displayed separately from the "title" tag
const lines = processedContent.split("\n"); const lines = processedContent.split("\n");
const expectedHeading = `${"=".repeat(node.level)} ${node.title}`; const expectedHeading = `${"=".repeat(node.level)} ${node.title}`;
const titleHeadingIndex = lines.findIndex((line: string) => const titleHeadingIndex = lines.findIndex(
line.trim() === expectedHeading.trim(), (line: string) => line.trim() === expectedHeading.trim(),
); );
if (titleHeadingIndex !== -1) { if (titleHeadingIndex !== -1) {
// Remove only the specific title heading line // Remove only the specific title heading line
@ -247,7 +251,6 @@
} }
} }
return { return {
title: eventTitle, title: eventTitle,
content: processedContent, content: processedContent,
@ -378,11 +381,11 @@
for (const link of wikiLinks) { for (const link of wikiLinks) {
const className = const className =
link.type === 'auto' link.type === "auto"
? 'cm-wiki-link-auto' ? "cm-wiki-link-auto"
: link.type === 'w' : link.type === "w"
? 'cm-wiki-link-ref' ? "cm-wiki-link-ref"
: 'cm-wiki-link-def'; : "cm-wiki-link-def";
ranges.push({ ranges.push({
from: link.startIndex, from: link.startIndex,
@ -730,14 +733,16 @@
".cm-wiki-link-auto": { ".cm-wiki-link-auto": {
color: "var(--color-primary-700)", // [[term]] (auto) - medium leather color: "var(--color-primary-700)", // [[term]] (auto) - medium leather
fontWeight: "500", fontWeight: "500",
backgroundColor: "color-mix(in srgb, var(--color-primary-700) 10%, transparent)", backgroundColor:
"color-mix(in srgb, var(--color-primary-700) 10%, transparent)",
padding: "2px 4px", padding: "2px 4px",
borderRadius: "3px", borderRadius: "3px",
}, },
".cm-wiki-link-ref": { ".cm-wiki-link-ref": {
color: "var(--color-primary-800)", // [[w:term]] (reference) - darker leather color: "var(--color-primary-800)", // [[w:term]] (reference) - darker leather
fontWeight: "500", fontWeight: "500",
backgroundColor: "color-mix(in srgb, var(--color-primary-800) 10%, transparent)", backgroundColor:
"color-mix(in srgb, var(--color-primary-800) 10%, transparent)",
padding: "2px 4px", padding: "2px 4px",
borderRadius: "3px", borderRadius: "3px",
}, },
@ -790,35 +795,43 @@
}, },
}), }),
// Override background and text to match preview (gray-800 bg, gray-100 text) // Override background and text to match preview (gray-800 bg, gray-100 text)
...(isDarkMode ? [EditorView.theme({ ...(isDarkMode
"&": { ? [
backgroundColor: "#1f2937", EditorView.theme(
color: "#f3f4f6", {
}, "&": {
".cm-content": { backgroundColor: "#1f2937",
color: "#f3f4f6", color: "#f3f4f6",
}, },
".cm-line": { ".cm-content": {
color: "#f3f4f6", color: "#f3f4f6",
}, },
".cm-gutters": { ".cm-line": {
backgroundColor: "#1f2937", color: "#f3f4f6",
borderColor: "#374151", },
color: "#9ca3af", ".cm-gutters": {
}, backgroundColor: "#1f2937",
".cm-activeLineGutter": { borderColor: "#374151",
backgroundColor: "#374151", color: "#9ca3af",
}, },
".cm-cursor": { ".cm-activeLineGutter": {
borderLeftColor: "#f3f4f6", backgroundColor: "#374151",
}, },
".cm-selectionBackground, ::selection": { ".cm-cursor": {
backgroundColor: "#374151 !important", borderLeftColor: "#f3f4f6",
}, },
"&.cm-focused .cm-selectionBackground, &.cm-focused ::selection": { ".cm-selectionBackground, ::selection": {
backgroundColor: "#4b5563 !important", backgroundColor: "#374151 !important",
}, },
}, { dark: true })] : []), "&.cm-focused .cm-selectionBackground, &.cm-focused ::selection":
{
backgroundColor: "#4b5563 !important",
},
},
{ dark: true },
),
]
: []),
], ],
}); });
@ -847,12 +860,12 @@
// Mount CodeMirror when component mounts // Mount CodeMirror when component mounts
onMount(() => { onMount(() => {
// Initialize dark mode state // Initialize dark mode state
isDarkMode = document.documentElement.classList.contains('dark'); isDarkMode = document.documentElement.classList.contains("dark");
createEditor(); createEditor();
// Watch for dark mode changes // Watch for dark mode changes
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
const newDarkMode = document.documentElement.classList.contains('dark'); const newDarkMode = document.documentElement.classList.contains("dark");
if (newDarkMode !== isDarkMode) { if (newDarkMode !== isDarkMode) {
isDarkMode = newDarkMode; isDarkMode = newDarkMode;
// Recreate editor with new theme // Recreate editor with new theme
@ -876,7 +889,7 @@
observer.observe(document.documentElement, { observer.observe(document.documentElement, {
attributes: true, attributes: true,
attributeFilter: ['class'], attributeFilter: ["class"],
}); });
return () => { return () => {
@ -1042,298 +1055,343 @@
</h3> </h3>
</div> </div>
<div class="flex-1 overflow-y-auto p-6 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"> <div
class="flex-1 overflow-y-auto p-6 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
{#if !content.trim()} {#if !content.trim()}
<div <div
class="text-gray-500 dark:text-gray-400 text-sm text-center py-8" class="text-gray-500 dark:text-gray-400 text-sm text-center py-8"
> >
Start typing to see the preview... Start typing to see the preview...
</div> </div>
{:else} {:else}
<div class="prose prose-sm dark:prose-invert max-w-none"> <div class="prose prose-sm dark:prose-invert max-w-none">
<!-- Render full document with title if it's an article --> <!-- Render full document with title if it's an article -->
{#if contentType === "article" && publicationResult?.metadata.title} {#if contentType === "article" && publicationResult?.metadata.title}
{@const documentHeader = content.split(/\n==\s+/)[0]} {@const documentHeader = content.split(/\n==\s+/)[0]}
<div <div
class="mb-6 border-b border-gray-200 dark:border-gray-700 pb-4" class="mb-6 border-b border-gray-200 dark:border-gray-700 pb-4"
> >
<div class="asciidoc-content"> <div class="asciidoc-content">
{@html asciidoctor.convert(documentHeader, { {@html asciidoctor.convert(documentHeader, {
standalone: false, standalone: false,
attributes: { attributes: {
showtitle: true, showtitle: true,
sectids: false, sectids: false,
}, },
})} })}
</div>
</div> </div>
</div> {/if}
{/if}
{#each parsedSections as section, index}
<div
class="mb-6 pb-6 border-b border-gray-200 dark:border-gray-700 last:border-0"
>
{#if section.isIndex}
<!-- Index event: show title and tags -->
<div class="space-y-3">
<!-- Event type indicator -->
<div
class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider"
>
Index Event (30040)
</div>
<!-- Title --> {#each parsedSections as section, index}
<h2 <div
class="text-lg font-bold text-gray-900 dark:text-gray-100" class="mb-6 pb-6 border-b border-gray-200 dark:border-gray-700 last:border-0"
> >
{section.title} {#if section.isIndex}
</h2> <!-- Index event: show title and tags -->
<div class="space-y-3">
<!-- Tags and wiki links --> <!-- Event type indicator -->
{#if section.tags && section.tags.length > 0} <div
{@const tTags = section.tags.filter((tag) => tag[0] === 't')} class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider"
{@const wTags = section.tags.filter((tag) => tag[0] === 'w')} >
Index Event (30040)
{#if tTags.length > 0 || wTags.length > 0} </div>
<div class="space-y-2">
<!-- Hashtags (t-tags) -->
{#if tTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each tTags as tag}
<span
class="bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
</div>
{/if}
<!-- Wiki links (w-tags) --> <!-- Title -->
{#if wTags.length > 0} <h2
<div class="flex flex-wrap gap-2"> class="text-lg font-bold text-gray-900 dark:text-gray-100"
{#each wTags as tag} >
<span {section.title}
class="bg-primary-50 text-primary-800 dark:bg-primary-950/40 dark:text-primary-200 px-2 py-1 rounded-full text-xs font-medium" </h2>
title="Wiki reference: {tag[1]}"
> <!-- Tags and wiki links -->
🔗 {tag[2] || tag[1]} {#if section.tags && section.tags.length > 0}
</span> {@const tTags = section.tags.filter(
{/each} (tag: any) => tag[0] === "t",
)}
{@const wTags = section.tags.filter(
(tag: any) => tag[0] === "w",
)}
{#if tTags.length > 0 || wTags.length > 0}
<div class="space-y-2">
<!-- Hashtags (t-tags) -->
{#if tTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each tTags as tag}
<span
class="bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
</div>
{/if}
<!-- Wiki links (w-tags) -->
{#if wTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each wTags as tag}
<span
class="bg-primary-50 text-primary-800 dark:bg-primary-950/40 dark:text-primary-200 px-2 py-1 rounded-full text-xs font-medium"
title="Wiki reference: {tag[1]}"
>
🔗 {tag[2] || tag[1]}
</span>
{/each}
</div>
{/if}
</div> </div>
{/if} {/if}
</div>
{/if} {/if}
{/if}
</div>
{:else}
<!-- Content event: show title, tags, then content -->
<div class="space-y-3">
<!-- Event type indicator -->
<div
class="text-xs font-semibold text-green-600 dark:text-green-400 uppercase tracking-wider"
>
Content Event (30041)
</div> </div>
{:else}
<!-- Content event: show title, tags, then content -->
<div class="space-y-3">
<!-- Event type indicator -->
<div
class="text-xs font-semibold text-green-600 dark:text-green-400 uppercase tracking-wider"
>
Content Event (30041)
</div>
<!-- Title at correct heading level --> <!-- Title at correct heading level -->
<div <div
class="prose prose-sm dark:prose-invert max-w-none" class="prose prose-sm dark:prose-invert max-w-none"
> >
{@html asciidoctor.convert( {@html asciidoctor.convert(
`${"=".repeat(section.level)} ${section.title}`, `${"=".repeat(section.level)} ${section.title}`,
{ {
standalone: false, standalone: false,
attributes: { attributes: {
showtitle: false, showtitle: false,
sectids: false, sectids: false,
},
}, },
}, )}
)} </div>
</div>
<!-- Tags and wiki links (green for content events) -->
{#if section.tags && section.tags.length > 0}
{@const tTags = section.tags.filter((tag) => tag[0] === 't')}
{@const wTags = section.tags.filter((tag) => tag[0] === 'w')}
{#if tTags.length > 0 || wTags.length > 0}
<div class="space-y-2">
<!-- Hashtags (t-tags) -->
{#if tTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each tTags as tag}
<span
class="bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
</div>
{/if}
<!-- Wiki links (w-tags) --> <!-- Tags and wiki links (green for content events) -->
{#if wTags.length > 0} {#if section.tags && section.tags.length > 0}
<div class="flex flex-wrap gap-2"> {@const tTags = section.tags.filter(
{#each wTags as tag} (tag: any) => tag[0] === "t",
<span )}
class="bg-primary-50 text-primary-800 dark:bg-primary-950/40 dark:text-primary-200 px-2 py-1 rounded-full text-xs font-medium" {@const wTags = section.tags.filter(
title="Wiki reference: {tag[1]}" (tag: any) => tag[0] === "w",
> )}
🔗 {tag[2] || tag[1]}
</span> {#if tTags.length > 0 || wTags.length > 0}
{/each} <div class="space-y-2">
<!-- Hashtags (t-tags) -->
{#if tTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each tTags as tag}
<span
class="bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
</div>
{/if}
<!-- Wiki links (w-tags) -->
{#if wTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each wTags as tag}
<span
class="bg-primary-50 text-primary-800 dark:bg-primary-950/40 dark:text-primary-200 px-2 py-1 rounded-full text-xs font-medium"
title="Wiki reference: {tag[1]}"
>
🔗 {tag[2] || tag[1]}
</span>
{/each}
</div>
{/if}
</div> </div>
{/if} {/if}
</div>
{/if} {/if}
{/if}
<!-- Content rendered as AsciiDoc --> <!-- Content rendered as AsciiDoc -->
{#if section.content} {#if section.content}
<div <div
class="prose prose-sm dark:prose-invert max-w-none mt-4" class="prose prose-sm dark:prose-invert max-w-none mt-4"
> >
{@html (() => { {@html (() => {
// Extract wiki links and replace with placeholders BEFORE Asciidoctor // Extract wiki links and replace with placeholders BEFORE Asciidoctor
const wikiLinks = extractWikiLinks(section.content); const wikiLinks = extractWikiLinks(
let contentWithPlaceholders = section.content; section.content,
const placeholders = new Map(); );
let contentWithPlaceholders = section.content;
wikiLinks.forEach((link, index) => { const placeholders = new Map();
// Use a placeholder inside a passthrough macro - Asciidoctor will strip pass:[...] and leave the inner text
const innerPlaceholder = `WIKILINK${index}PLACEHOLDER`; wikiLinks.forEach((link, index) => {
const placeholder = `pass:[${innerPlaceholder}]`; // Use a placeholder inside a passthrough macro - Asciidoctor will strip pass:[...] and leave the inner text
placeholders.set(innerPlaceholder, link); // Store by inner placeholder (what will remain after Asciidoctor) const innerPlaceholder = `WIKILINK${index}PLACEHOLDER`;
contentWithPlaceholders = contentWithPlaceholders.replace(link.fullMatch, placeholder); const placeholder = `pass:[${innerPlaceholder}]`;
}); placeholders.set(innerPlaceholder, link); // Store by inner placeholder (what will remain after Asciidoctor)
contentWithPlaceholders =
// Check if content contains nested headers contentWithPlaceholders.replace(
const hasNestedHeaders = contentWithPlaceholders.includes('\n===') || contentWithPlaceholders.includes('\n===='); link.fullMatch,
placeholder,
let rendered; );
if (hasNestedHeaders) {
// For proper nested header parsing, we need full document context
// Create a complete AsciiDoc document structure
// Important: Ensure proper level sequence for nested headers
const fullDoc = `= Temporary Document\n\n${"=".repeat(section.level)} ${section.title}\n\n${contentWithPlaceholders}`;
rendered = asciidoctor.convert(fullDoc, {
standalone: false,
attributes: {
showtitle: false,
sectids: false,
},
}); });
// Extract just the content we want (remove the temporary structure) // Check if content contains nested headers
// Find the section we care about const hasNestedHeaders =
const sectionStart = rendered.indexOf(`<h${section.level}`); contentWithPlaceholders.includes("\n===") ||
if (sectionStart !== -1) { contentWithPlaceholders.includes("\n====");
const nextSectionStart = rendered.indexOf(`</h${section.level}>`, sectionStart);
if (nextSectionStart !== -1) { let rendered: string | Document;
// Get everything after our section header if (hasNestedHeaders) {
const afterHeader = rendered.substring(nextSectionStart + `</h${section.level}>`.length); // For proper nested header parsing, we need full document context
// Find where the section ends (at the closing div) // Create a complete AsciiDoc document structure
const sectionEnd = afterHeader.lastIndexOf('</div>'); // Important: Ensure proper level sequence for nested headers
if (sectionEnd !== -1) { const fullDoc = `= Temporary Document\n\n${"=".repeat(section.level)} ${section.title}\n\n${contentWithPlaceholders}`;
rendered = afterHeader.substring(0, sectionEnd);
rendered = asciidoctor.convert(fullDoc, {
standalone: false,
attributes: {
showtitle: false,
sectids: false,
},
});
// Extract just the content we want (remove the temporary structure)
// Find the section we care about
const sectionStart = rendered
.toString()
.indexOf(`<h${section.level}`);
if (sectionStart !== -1) {
const nextSectionStart = rendered
.toString()
.indexOf(
`</h${section.level}>`,
sectionStart,
);
if (nextSectionStart !== -1) {
// Get everything after our section header
const afterHeader = rendered
.toString()
.substring(
nextSectionStart +
`</h${section.level}>`.length,
);
// Find where the section ends (at the closing div)
const sectionEnd =
afterHeader.lastIndexOf("</div>");
if (sectionEnd !== -1) {
rendered = afterHeader.substring(
0,
sectionEnd,
);
}
} }
} }
} else {
// Simple content without nested headers
rendered = asciidoctor.convert(
contentWithPlaceholders,
{
standalone: false,
attributes: {
showtitle: false,
sectids: false,
},
},
);
} }
} else {
// Simple content without nested headers // Replace placeholders with actual wiki link HTML
rendered = asciidoctor.convert(contentWithPlaceholders, { // Use a global regex to catch all occurrences (Asciidoctor might have duplicated them)
standalone: false, placeholders.forEach((link, placeholder) => {
attributes: { const className =
showtitle: false, link.type === "auto"
sectids: false, ? "wiki-link wiki-link-auto"
}, : link.type === "w"
? "wiki-link wiki-link-ref"
: "wiki-link wiki-link-def";
const title =
link.type === "w"
? "Wiki reference (mentions this concept)"
: link.type === "d"
? "Wiki definition (defines this concept)"
: "Wiki link (searches both references and definitions)";
const html = `<a class="${className}" href="#wiki/${link.type}/${encodeURIComponent(link.term)}" title="${title}" data-wiki-type="${link.type}" data-wiki-term="${link.term}">${link.displayText}</a>`;
// Use global replace to handle all occurrences
const regex = new RegExp(
placeholder.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&",
),
"g",
);
rendered = rendered
.toString()
.replace(regex, html);
}); });
}
// Replace placeholders with actual wiki link HTML
// Use a global regex to catch all occurrences (Asciidoctor might have duplicated them)
placeholders.forEach((link, placeholder) => {
const className =
link.type === 'auto'
? 'wiki-link wiki-link-auto'
: link.type === 'w'
? 'wiki-link wiki-link-ref'
: 'wiki-link wiki-link-def';
const title =
link.type === 'w'
? 'Wiki reference (mentions this concept)'
: link.type === 'd'
? 'Wiki definition (defines this concept)'
: 'Wiki link (searches both references and definitions)';
const html = `<a class="${className}" href="#wiki/${link.type}/${encodeURIComponent(link.term)}" title="${title}" data-wiki-type="${link.type}" data-wiki-term="${link.term}">${link.displayText}</a>`;
// Use global replace to handle all occurrences
const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
rendered = rendered.replace(regex, html);
});
return rendered;
})()}
</div>
{/if}
</div>
{/if}
<!-- Event boundary indicator --> return rendered;
{#if index < parsedSections.length - 1} })()}
<div class="mt-6 relative"> </div>
<div class="absolute inset-0 flex items-center"> {/if}
<div
class="w-full border-t-2 border-dashed border-gray-300 dark:border-gray-600"
></div>
</div> </div>
<div class="relative flex justify-center"> {/if}
<span
class="bg-white dark:bg-gray-800 px-3 text-xs text-gray-500 dark:text-gray-400" <!-- Event boundary indicator -->
> {#if index < parsedSections.length - 1}
Event Boundary <div class="mt-6 relative">
</span> <div class="absolute inset-0 flex items-center">
<div
class="w-full border-t-2 border-dashed border-gray-300 dark:border-gray-600"
></div>
</div>
<div class="relative flex justify-center">
<span
class="bg-white dark:bg-gray-800 px-3 text-xs text-gray-500 dark:text-gray-400"
>
Event Boundary
</span>
</div>
</div> </div>
</div> {/if}
{/if} </div>
</div> {/each}
{/each} </div>
</div>
<div <div
class="mt-4 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 p-2 rounded border" class="mt-4 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 p-2 rounded border"
> >
<strong>Event Count:</strong> <strong>Event Count:</strong>
{#if generatedEvents} {#if generatedEvents}
{@const indexEvents = generatedEvents.contentEvents.filter( {@const indexEvents = generatedEvents.contentEvents.filter(
(e: any) => e.kind === 30040, (e: any) => e.kind === 30040,
)}
{@const contentOnlyEvents =
generatedEvents.contentEvents.filter(
(e: any) => e.kind === 30041,
)} )}
{@const totalIndexEvents = {@const contentOnlyEvents =
indexEvents.length + (generatedEvents.indexEvent ? 1 : 0)} generatedEvents.contentEvents.filter(
{@const totalEvents = (e: any) => e.kind === 30041,
totalIndexEvents + contentOnlyEvents.length} )}
{totalEvents} event{totalEvents !== 1 ? "s" : ""} {@const totalIndexEvents =
({totalIndexEvents} index{totalIndexEvents !== 1 indexEvents.length + (generatedEvents.indexEvent ? 1 : 0)}
? " events" {@const totalEvents =
: ""} + {contentOnlyEvents.length} content{contentOnlyEvents.length !== totalIndexEvents + contentOnlyEvents.length}
1 {totalEvents} event{totalEvents !== 1 ? "s" : ""}
? " events" ({totalIndexEvents} index{totalIndexEvents !== 1
: ""}) ? " events"
{:else} : ""} + {contentOnlyEvents.length} content{contentOnlyEvents.length !==
0 events 1
{/if} ? " events"
</div> : ""})
{/if} {:else}
0 events
{/if}
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>
@ -1497,23 +1555,42 @@ Understanding the nature of knowledge...
</p> </p>
<ul class="space-y-2 text-xs"> <ul class="space-y-2 text-xs">
<li> <li>
<code class="bg-violet-100 dark:bg-violet-900/30 px-1 py-0.5 rounded">[[term]]</code> <code
<span class="text-gray-600 dark:text-gray-400">- Auto link (queries both w and d tags)</span> class="bg-violet-100 dark:bg-violet-900/30 px-1 py-0.5 rounded"
>[[term]]</code
>
<span class="text-gray-600 dark:text-gray-400"
>- Auto link (queries both w and d tags)</span
>
</li> </li>
<li> <li>
<code class="bg-cyan-100 dark:bg-cyan-900/30 px-1 py-0.5 rounded">[[w:term]]</code> <code
<span class="text-gray-600 dark:text-gray-400">- Reference/mention (backward link)</span> class="bg-cyan-100 dark:bg-cyan-900/30 px-1 py-0.5 rounded"
>[[w:term]]</code
>
<span class="text-gray-600 dark:text-gray-400"
>- Reference/mention (backward link)</span
>
</li> </li>
<li> <li>
<code class="bg-amber-100 dark:bg-amber-900/30 px-1 py-0.5 rounded">[[d:term]]</code> <code
<span class="text-gray-600 dark:text-gray-400">- Definition link (forward link)</span> class="bg-amber-100 dark:bg-amber-900/30 px-1 py-0.5 rounded"
>[[d:term]]</code
>
<span class="text-gray-600 dark:text-gray-400"
>- Definition link (forward link)</span
>
</li> </li>
<li class="mt-2"> <li class="mt-2">
<strong>Custom text:</strong> <code class="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">[[term|display text]]</code> <strong>Custom text:</strong>
<code class="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded"
>[[term|display text]]</code
>
</li> </li>
</ul> </ul>
<p class="text-xs mt-2 text-gray-600 dark:text-gray-400"> <p class="text-xs mt-2 text-gray-600 dark:text-gray-400">
Example: "The concept of [[Knowledge Graphs]] enables..." creates a w-tag automatically. Example: "The concept of [[Knowledge Graphs]] enables..."
creates a w-tag automatically.
</p> </p>
</div> </div>
</div> </div>
@ -1591,7 +1668,7 @@ Understanding the nature of knowledge...
<!-- Hierarchical structure --> <!-- Hierarchical structure -->
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-3"> <div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-3">
<div class="font-mono text-xs space-y-1"> <div class="font-mono text-xs space-y-1">
{#snippet renderEventNode(node, depth = 0)} {#snippet renderEventNode(node: any, depth = 0)}
<div class="py-0.5" style="margin-left: {depth * 1}rem;"> <div class="py-0.5" style="margin-left: {depth * 1}rem;">
{node.eventKind === 30040 ? "📁" : "📄"} {node.eventKind === 30040 ? "📁" : "📄"}
[{node.eventKind}] {node.title || "Untitled"} [{node.eventKind}] {node.title || "Untitled"}

328
src/lib/components/publications/HighlightLayer.svelte

@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
import { getNdkContext, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import {
getNdkContext,
activeInboxRelays,
activeOutboxRelays,
} from "$lib/ndk";
import { pubkeyToHue } from "$lib/utils/nostrUtils"; import { pubkeyToHue } from "$lib/utils/nostrUtils";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk"; import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
@ -12,11 +16,14 @@
encodeHighlightNaddr, encodeHighlightNaddr,
getRelaysFromHighlight, getRelaysFromHighlight,
getAuthorDisplayName, getAuthorDisplayName,
sortHighlightsByTime sortHighlightsByTime,
} from "$lib/utils/highlightUtils"; } from "$lib/utils/highlightUtils";
import { unifiedProfileCache } from "$lib/utils/npubCache"; import { unifiedProfileCache } from "$lib/utils/npubCache";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { highlightByOffset, getPlainText } from "$lib/utils/highlightPositioning"; import {
highlightByOffset,
getPlainText,
} from "$lib/utils/highlightPositioning";
let { let {
eventId, eventId,
@ -47,7 +54,7 @@
// Derived state for color mapping // Derived state for color mapping
let colorMap = $derived.by(() => { let colorMap = $derived.by(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
highlights.forEach(highlight => { highlights.forEach((highlight) => {
if (!map.has(highlight.pubkey)) { if (!map.has(highlight.pubkey)) {
const hue = pubkeyToHue(highlight.pubkey); const hue = pubkeyToHue(highlight.pubkey);
map.set(highlight.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`); map.set(highlight.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
@ -73,8 +80,13 @@
} }
// Collect all event IDs and addresses // Collect all event IDs and addresses
const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(Boolean); const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(
const allAddresses = [...(eventAddress ? [eventAddress] : []), ...eventAddresses].filter(Boolean); Boolean,
);
const allAddresses = [
...(eventAddress ? [eventAddress] : []),
...eventAddresses,
].filter(Boolean);
if (allEventIds.length === 0 && allAddresses.length === 0) { if (allEventIds.length === 0 && allAddresses.length === 0) {
console.warn("[HighlightLayer] No event IDs or addresses provided"); console.warn("[HighlightLayer] No event IDs or addresses provided");
@ -87,20 +99,29 @@
// AI-NOTE: Mock mode allows testing highlight UI without publishing to relays // AI-NOTE: Mock mode allows testing highlight UI without publishing to relays
// This is useful for development and demonstrating the highlight system // This is useful for development and demonstrating the highlight system
if (useMockHighlights) { if (useMockHighlights) {
console.log(`[HighlightLayer] MOCK MODE - Generating mock highlights for ${allAddresses.length} sections`); console.log(
`[HighlightLayer] MOCK MODE - Generating mock highlights for ${allAddresses.length} sections`,
);
try { try {
// Generate mock highlight data // Generate mock highlight data
const mockHighlights = generateMockHighlightsForSections(allAddresses); const mockHighlights = generateMockHighlightsForSections(allAddresses);
// Convert to NDKEvent instances (same as real events) // Convert to NDKEvent instances (same as real events)
highlights = mockHighlights.map(rawEvent => new NDKEventClass(ndk, rawEvent)); highlights = mockHighlights.map(
(rawEvent) => new NDKEventClass(ndk, rawEvent),
);
console.log(`[HighlightLayer] Generated ${highlights.length} mock highlights`); console.log(
`[HighlightLayer] Generated ${highlights.length} mock highlights`,
);
loading = false; loading = false;
return; return;
} catch (err) { } catch (err) {
console.error(`[HighlightLayer] Error generating mock highlights:`, err); console.error(
`[HighlightLayer] Error generating mock highlights:`,
err,
);
loading = false; loading = false;
return; return;
} }
@ -108,7 +129,7 @@
console.log(`[HighlightLayer] Fetching highlights for:`, { console.log(`[HighlightLayer] Fetching highlights for:`, {
eventIds: allEventIds, eventIds: allEventIds,
addresses: allAddresses addresses: allAddresses,
}); });
try { try {
@ -128,7 +149,10 @@
filter["#e"] = allEventIds; filter["#e"] = allEventIds;
} }
console.log(`[HighlightLayer] Fetching with filter:`, JSON.stringify(filter, null, 2)); console.log(
`[HighlightLayer] Fetching with filter:`,
JSON.stringify(filter, null, 2),
);
// Build explicit relay set (same pattern as HighlightSelectionHandler and CommentButton) // Build explicit relay set (same pattern as HighlightSelectionHandler and CommentButton)
const relays = [ const relays = [
@ -137,7 +161,10 @@
...$activeInboxRelays, ...$activeInboxRelays,
]; ];
const uniqueRelays = Array.from(new Set(relays)); const uniqueRelays = Array.from(new Set(relays));
console.log(`[HighlightLayer] Fetching from ${uniqueRelays.length} relays:`, uniqueRelays); console.log(
`[HighlightLayer] Fetching from ${uniqueRelays.length} relays:`,
uniqueRelays,
);
/** /**
* Use WebSocketPool with nostr-tools protocol instead of NDK * Use WebSocketPool with nostr-tools protocol instead of NDK
@ -168,8 +195,11 @@
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
// Log ALL messages from relay.nostr.band for debugging // Log ALL messages from relay.nostr.band for debugging
if (relayUrl.includes('relay.nostr.band')) { if (relayUrl.includes("relay.nostr.band")) {
console.log(`[HighlightLayer] RAW message from ${relayUrl}:`, message); console.log(
`[HighlightLayer] RAW message from ${relayUrl}:`,
message,
);
} }
if (message[0] === "EVENT" && message[1] === subscriptionId) { if (message[0] === "EVENT" && message[1] === subscriptionId) {
@ -178,7 +208,7 @@
id: rawEvent.id, id: rawEvent.id,
kind: rawEvent.kind, kind: rawEvent.kind,
content: rawEvent.content.substring(0, 50), content: rawEvent.content.substring(0, 50),
tags: rawEvent.tags tags: rawEvent.tags,
}); });
// Avoid duplicates // Avoid duplicates
@ -188,11 +218,18 @@
// Convert to NDKEvent // Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent); const ndkEvent = new NDKEventClass(ndk, rawEvent);
highlights = [...highlights, ndkEvent]; highlights = [...highlights, ndkEvent];
console.log(`[HighlightLayer] Added highlight, total now: ${highlights.length}`); console.log(
`[HighlightLayer] Added highlight, total now: ${highlights.length}`,
);
} }
} else if (message[0] === "EOSE" && message[1] === subscriptionId) { } else if (
message[0] === "EOSE" &&
message[1] === subscriptionId
) {
eoseCount++; eoseCount++;
console.log(`[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`); console.log(
`[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`,
);
// Close subscription // Close subscription
ws.send(JSON.stringify(["CLOSE", subscriptionId])); ws.send(JSON.stringify(["CLOSE", subscriptionId]));
@ -200,10 +237,16 @@
WebSocketPool.instance.release(ws); WebSocketPool.instance.release(ws);
resolve(); resolve();
} else if (message[0] === "NOTICE") { } else if (message[0] === "NOTICE") {
console.warn(`[HighlightLayer] NOTICE from ${relayUrl}:`, message[1]); console.warn(
`[HighlightLayer] NOTICE from ${relayUrl}:`,
message[1],
);
} }
} catch (err) { } catch (err) {
console.error(`[HighlightLayer] Error processing message from ${relayUrl}:`, err); console.error(
`[HighlightLayer] Error processing message from ${relayUrl}:`,
err,
);
} }
}; };
@ -211,8 +254,11 @@
// Send REQ // Send REQ
const req = ["REQ", subscriptionId, filter]; const req = ["REQ", subscriptionId, filter];
if (relayUrl.includes('relay.nostr.band')) { if (relayUrl.includes("relay.nostr.band")) {
console.log(`[HighlightLayer] Sending REQ to ${relayUrl}:`, JSON.stringify(req)); console.log(
`[HighlightLayer] Sending REQ to ${relayUrl}:`,
JSON.stringify(req),
);
} else { } else {
console.log(`[HighlightLayer] Sending REQ to ${relayUrl}`); console.log(`[HighlightLayer] Sending REQ to ${relayUrl}`);
} }
@ -229,7 +275,10 @@
}, 5000); }, 5000);
}); });
} catch (err) { } catch (err) {
console.error(`[HighlightLayer] Error connecting to ${relayUrl}:`, err); console.error(
`[HighlightLayer] Error connecting to ${relayUrl}:`,
err,
);
} }
}); });
@ -239,17 +288,19 @@
console.log(`[HighlightLayer] Fetched ${highlights.length} highlights`); console.log(`[HighlightLayer] Fetched ${highlights.length} highlights`);
if (highlights.length > 0) { if (highlights.length > 0) {
console.log(`[HighlightLayer] Highlights summary:`, highlights.map(h => ({ console.log(
content: h.content.substring(0, 30) + "...", `[HighlightLayer] Highlights summary:`,
address: h.tags.find(t => t[0] === "a")?.[1], highlights.map((h) => ({
author: h.pubkey.substring(0, 8) content: h.content.substring(0, 30) + "...",
}))); address: h.tags.find((t) => t[0] === "a")?.[1],
author: h.pubkey.substring(0, 8),
})),
);
} }
loading = false; loading = false;
// Rendering is handled by the visibility/highlights effect // Rendering is handled by the visibility/highlights effect
} catch (err) { } catch (err) {
console.error(`[HighlightLayer] Error fetching highlights:`, err); console.error(`[HighlightLayer] Error fetching highlights:`, err);
loading = false; loading = false;
@ -267,10 +318,12 @@
offsetStart: number, offsetStart: number,
offsetEnd: number, offsetEnd: number,
color: string, color: string,
targetAddress?: string targetAddress?: string,
): boolean { ): boolean {
if (!containerRef) { if (!containerRef) {
console.log(`[HighlightLayer] Cannot highlight by position - no containerRef`); console.log(
`[HighlightLayer] Cannot highlight by position - no containerRef`,
);
return false; return false;
} }
@ -280,17 +333,25 @@
const sectionElement = document.getElementById(targetAddress); const sectionElement = document.getElementById(targetAddress);
if (sectionElement) { if (sectionElement) {
searchRoot = sectionElement; searchRoot = sectionElement;
console.log(`[HighlightLayer] Highlighting in specific section: ${targetAddress}`); console.log(
`[HighlightLayer] Highlighting in specific section: ${targetAddress}`,
);
} else { } else {
console.log(`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`); console.log(
`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`,
);
} }
} }
console.log(`[HighlightLayer] Applying position-based highlight ${offsetStart}-${offsetEnd}`); console.log(
`[HighlightLayer] Applying position-based highlight ${offsetStart}-${offsetEnd}`,
);
const result = highlightByOffset(searchRoot, offsetStart, offsetEnd, color); const result = highlightByOffset(searchRoot, offsetStart, offsetEnd, color);
if (result) { if (result) {
console.log(`[HighlightLayer] Successfully applied position-based highlight`); console.log(
`[HighlightLayer] Successfully applied position-based highlight`,
);
} else { } else {
console.log(`[HighlightLayer] Failed to apply position-based highlight`); console.log(`[HighlightLayer] Failed to apply position-based highlight`);
} }
@ -304,9 +365,15 @@
* @param color - The color to use for highlighting * @param color - The color to use for highlighting
* @param targetAddress - Optional address to limit search to specific section * @param targetAddress - Optional address to limit search to specific section
*/ */
function findAndHighlightText(text: string, color: string, targetAddress?: string): void { function findAndHighlightText(
text: string,
color: string,
targetAddress?: string,
): void {
if (!containerRef || !text || text.trim().length === 0) { if (!containerRef || !text || text.trim().length === 0) {
console.log(`[HighlightLayer] Cannot highlight - containerRef: ${!!containerRef}, text: "${text}"`); console.log(
`[HighlightLayer] Cannot highlight - containerRef: ${!!containerRef}, text: "${text}"`,
);
return; return;
} }
@ -316,19 +383,26 @@
const sectionElement = document.getElementById(targetAddress); const sectionElement = document.getElementById(targetAddress);
if (sectionElement) { if (sectionElement) {
searchRoot = sectionElement; searchRoot = sectionElement;
console.log(`[HighlightLayer] Searching in specific section: ${targetAddress}`); console.log(
`[HighlightLayer] Searching in specific section: ${targetAddress}`,
);
} else { } else {
console.log(`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`); console.log(
`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`,
);
} }
} }
console.log(`[HighlightLayer] Searching for text: "${text}" in`, searchRoot); console.log(
`[HighlightLayer] Searching for text: "${text}" in`,
searchRoot,
);
// Use TreeWalker to find all text nodes // Use TreeWalker to find all text nodes
const walker = document.createTreeWalker( const walker = document.createTreeWalker(
searchRoot, searchRoot,
NodeFilter.SHOW_TEXT, NodeFilter.SHOW_TEXT,
null null,
); );
const textNodes: Node[] = []; const textNodes: Node[] = [];
@ -338,19 +412,30 @@
} }
// Search for the highlight text in text nodes // Search for the highlight text in text nodes
console.log(`[HighlightLayer] Searching through ${textNodes.length} text nodes`); console.log(
`[HighlightLayer] Searching through ${textNodes.length} text nodes`,
);
for (const textNode of textNodes) { for (const textNode of textNodes) {
const nodeText = textNode.textContent || ""; const nodeText = textNode.textContent || "";
const index = nodeText.toLowerCase().indexOf(text.toLowerCase()); const index = nodeText.toLowerCase().indexOf(text.toLowerCase());
if (index !== -1) { if (index !== -1) {
console.log(`[HighlightLayer] Found match in text node:`, nodeText.substring(Math.max(0, index - 20), Math.min(nodeText.length, index + text.length + 20))); console.log(
`[HighlightLayer] Found match in text node:`,
nodeText.substring(
Math.max(0, index - 20),
Math.min(nodeText.length, index + text.length + 20),
),
);
const parent = textNode.parentNode; const parent = textNode.parentNode;
if (!parent) continue; if (!parent) continue;
// Skip if already highlighted // Skip if already highlighted
if (parent.nodeName === "MARK" || (parent instanceof Element && parent.classList?.contains("highlight"))) { if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
continue; continue;
} }
@ -386,10 +471,14 @@
* Render all highlights on the page * Render all highlights on the page
*/ */
function renderHighlights() { function renderHighlights() {
console.log(`[HighlightLayer] renderHighlights called - visible: ${visible}, containerRef: ${!!containerRef}, highlights: ${highlights.length}`); console.log(
`[HighlightLayer] renderHighlights called - visible: ${visible}, containerRef: ${!!containerRef}, highlights: ${highlights.length}`,
);
if (!visible || !containerRef) { if (!visible || !containerRef) {
console.log(`[HighlightLayer] Skipping render - visible: ${visible}, containerRef: ${!!containerRef}`); console.log(
`[HighlightLayer] Skipping render - visible: ${visible}, containerRef: ${!!containerRef}`,
);
return; return;
} }
@ -403,7 +492,10 @@
console.log(`[HighlightLayer] Rendering ${highlights.length} highlights`); console.log(`[HighlightLayer] Rendering ${highlights.length} highlights`);
console.log(`[HighlightLayer] Container element:`, containerRef); console.log(`[HighlightLayer] Container element:`, containerRef);
console.log(`[HighlightLayer] Container has children:`, containerRef.children.length); console.log(
`[HighlightLayer] Container has children:`,
containerRef.children.length,
);
// Apply each highlight // Apply each highlight
for (const highlight of highlights) { for (const highlight of highlights) {
@ -411,12 +503,13 @@
const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.3)"; const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.3)";
// Extract the target address from the highlight's "a" tag // Extract the target address from the highlight's "a" tag
const aTag = highlight.tags.find(tag => tag[0] === "a"); const aTag = highlight.tags.find((tag) => tag[0] === "a");
const targetAddress = aTag ? aTag[1] : undefined; const targetAddress = aTag ? aTag[1] : undefined;
// Check for offset tags (position-based highlighting) // Check for offset tags (position-based highlighting)
const offsetTag = highlight.tags.find(tag => tag[0] === "offset"); const offsetTag = highlight.tags.find((tag) => tag[0] === "offset");
const hasOffset = offsetTag && offsetTag[1] !== undefined && offsetTag[2] !== undefined; const hasOffset =
offsetTag && offsetTag[1] !== undefined && offsetTag[2] !== undefined;
console.log(`[HighlightLayer] Rendering highlight:`, { console.log(`[HighlightLayer] Rendering highlight:`, {
hasOffset, hasOffset,
@ -425,7 +518,7 @@
contentLength: content.length, contentLength: content.length,
targetAddress, targetAddress,
color, color,
allTags: highlight.tags allTags: highlight.tags,
}); });
if (hasOffset) { if (hasOffset) {
@ -434,10 +527,14 @@
const offsetEnd = parseInt(offsetTag[2], 10); const offsetEnd = parseInt(offsetTag[2], 10);
if (!isNaN(offsetStart) && !isNaN(offsetEnd)) { if (!isNaN(offsetStart) && !isNaN(offsetEnd)) {
console.log(`[HighlightLayer] Using position-based highlighting: ${offsetStart}-${offsetEnd}`); console.log(
`[HighlightLayer] Using position-based highlighting: ${offsetStart}-${offsetEnd}`,
);
highlightByPosition(offsetStart, offsetEnd, color, targetAddress); highlightByPosition(offsetStart, offsetEnd, color, targetAddress);
} else { } else {
console.log(`[HighlightLayer] Invalid offset values, falling back to text search`); console.log(
`[HighlightLayer] Invalid offset values, falling back to text search`,
);
if (content && content.trim().length > 0) { if (content && content.trim().length > 0) {
findAndHighlightText(content, color, targetAddress); findAndHighlightText(content, color, targetAddress);
} }
@ -455,7 +552,9 @@
// Check if any highlights were actually rendered // Check if any highlights were actually rendered
const renderedHighlights = containerRef.querySelectorAll("mark.highlight"); const renderedHighlights = containerRef.querySelectorAll("mark.highlight");
console.log(`[HighlightLayer] Rendered ${renderedHighlights.length} highlight marks in DOM`); console.log(
`[HighlightLayer] Rendered ${renderedHighlights.length} highlight marks in DOM`,
);
} }
/** /**
@ -465,7 +564,7 @@
if (!containerRef) return; if (!containerRef) return;
const highlightElements = containerRef.querySelectorAll("mark.highlight"); const highlightElements = containerRef.querySelectorAll("mark.highlight");
highlightElements.forEach(el => { highlightElements.forEach((el) => {
const parent = el.parentNode; const parent = el.parentNode;
if (parent) { if (parent) {
// Replace highlight with plain text // Replace highlight with plain text
@ -477,7 +576,9 @@
} }
}); });
console.log(`[HighlightLayer] Cleared ${highlightElements.length} highlights`); console.log(
`[HighlightLayer] Cleared ${highlightElements.length} highlights`,
);
} }
// Track the last fetched event count to know when to refetch // Track the last fetched event count to know when to refetch
@ -489,7 +590,9 @@
const currentCount = eventIds.length + eventAddresses.length; const currentCount = eventIds.length + eventAddresses.length;
const hasEventData = currentCount > 0; const hasEventData = currentCount > 0;
console.log(`[HighlightLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`); console.log(
`[HighlightLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`,
);
// Only fetch if: // Only fetch if:
// 1. We have event data // 1. We have event data
@ -503,7 +606,9 @@
// Debounce: wait 500ms for more events to arrive before fetching // Debounce: wait 500ms for more events to arrive before fetching
fetchTimeout = setTimeout(() => { fetchTimeout = setTimeout(() => {
console.log(`[HighlightLayer] Event data stabilized at ${currentCount} events, fetching highlights...`); console.log(
`[HighlightLayer] Event data stabilized at ${currentCount} events, fetching highlights...`,
);
lastFetchedCount = currentCount; lastFetchedCount = currentCount;
fetchHighlights(); fetchHighlights();
}, 500); }, 500);
@ -521,10 +626,14 @@
$effect(() => { $effect(() => {
// This effect runs when either visible or highlights.length changes // This effect runs when either visible or highlights.length changes
const highlightCount = highlights.length; const highlightCount = highlights.length;
console.log(`[HighlightLayer] Visibility/highlights effect - visible: ${visible}, highlights: ${highlightCount}`); console.log(
`[HighlightLayer] Visibility/highlights effect - visible: ${visible}, highlights: ${highlightCount}`,
);
if (visible && highlightCount > 0) { if (visible && highlightCount > 0) {
console.log(`[HighlightLayer] Both visible and highlights ready, rendering...`); console.log(
`[HighlightLayer] Both visible and highlights ready, rendering...`,
);
renderHighlights(); renderHighlights();
} else if (!visible) { } else if (!visible) {
clearHighlights(); clearHighlights();
@ -544,7 +653,9 @@
*/ */
async function fetchAuthorProfiles() { async function fetchAuthorProfiles() {
const uniquePubkeys = Array.from(groupedHighlights.keys()); const uniquePubkeys = Array.from(groupedHighlights.keys());
console.log(`[HighlightLayer] Fetching profiles for ${uniquePubkeys.length} authors`); console.log(
`[HighlightLayer] Fetching profiles for ${uniquePubkeys.length} authors`,
);
for (const pubkey of uniquePubkeys) { for (const pubkey of uniquePubkeys) {
try { try {
@ -557,7 +668,10 @@
authorProfiles = new Map(authorProfiles); authorProfiles = new Map(authorProfiles);
} }
} catch (err) { } catch (err) {
console.error(`[HighlightLayer] Error fetching profile for ${pubkey}:`, err); console.error(
`[HighlightLayer] Error fetching profile for ${pubkey}:`,
err,
);
} }
} }
} }
@ -579,7 +693,10 @@
* Scroll to a specific highlight in the document * Scroll to a specific highlight in the document
*/ */
function scrollToHighlight(highlight: NDKEvent) { function scrollToHighlight(highlight: NDKEvent) {
console.log(`[HighlightLayer] scrollToHighlight called for:`, highlight.content.substring(0, 50)); console.log(
`[HighlightLayer] scrollToHighlight called for:`,
highlight.content.substring(0, 50),
);
if (!containerRef) { if (!containerRef) {
console.warn(`[HighlightLayer] No containerRef available`); console.warn(`[HighlightLayer] No containerRef available`);
@ -594,7 +711,9 @@
// Find the highlight mark element // Find the highlight mark element
const highlightMarks = containerRef.querySelectorAll("mark.highlight"); const highlightMarks = containerRef.querySelectorAll("mark.highlight");
console.log(`[HighlightLayer] Found ${highlightMarks.length} highlight marks in DOM`); console.log(
`[HighlightLayer] Found ${highlightMarks.length} highlight marks in DOM`,
);
// Try exact match first // Try exact match first
for (const mark of highlightMarks) { for (const mark of highlightMarks) {
@ -602,7 +721,9 @@
const searchText = content.toLowerCase(); const searchText = content.toLowerCase();
if (markText === searchText) { if (markText === searchText) {
console.log(`[HighlightLayer] Found exact match, scrolling and flashing`); console.log(
`[HighlightLayer] Found exact match, scrolling and flashing`,
);
// Scroll to this element // Scroll to this element
mark.scrollIntoView({ behavior: "smooth", block: "center" }); mark.scrollIntoView({ behavior: "smooth", block: "center" });
@ -621,7 +742,9 @@
const searchText = content.toLowerCase(); const searchText = content.toLowerCase();
if (markText.includes(searchText) || searchText.includes(markText)) { if (markText.includes(searchText) || searchText.includes(markText)) {
console.log(`[HighlightLayer] Found partial match, scrolling and flashing`); console.log(
`[HighlightLayer] Found partial match, scrolling and flashing`,
);
mark.scrollIntoView({ behavior: "smooth", block: "center" }); mark.scrollIntoView({ behavior: "smooth", block: "center" });
mark.classList.add("highlight-flash"); mark.classList.add("highlight-flash");
setTimeout(() => { setTimeout(() => {
@ -631,7 +754,10 @@
} }
} }
console.warn(`[HighlightLayer] Could not find highlight mark for:`, content.substring(0, 50)); console.warn(
`[HighlightLayer] Could not find highlight mark for:`,
content.substring(0, 50),
);
} }
/** /**
@ -679,13 +805,19 @@
</script> </script>
{#if loading && visible} {#if loading && visible}
<div class="fixed top-40 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-3"> <div
<p class="text-sm text-gray-600 dark:text-gray-300">Loading highlights...</p> class="fixed top-40 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-3"
>
<p class="text-sm text-gray-600 dark:text-gray-300">
Loading highlights...
</p>
</div> </div>
{/if} {/if}
{#if visible && highlights.length > 0} {#if visible && highlights.length > 0}
<div class="fixed bottom-4 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 max-w-sm w-80"> <div
class="fixed bottom-4 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 max-w-sm w-80"
>
<h4 class="text-sm font-semibold mb-3 text-gray-900 dark:text-gray-100"> <h4 class="text-sm font-semibold mb-3 text-gray-900 dark:text-gray-100">
Highlights Highlights
</h4> </h4>
@ -707,19 +839,28 @@
class="w-3 h-3 rounded flex-shrink-0" class="w-3 h-3 rounded flex-shrink-0"
style="background-color: {color};" style="background-color: {color};"
></div> ></div>
<span class="font-medium text-gray-900 dark:text-gray-100 flex-1 text-left truncate"> <span
class="font-medium text-gray-900 dark:text-gray-100 flex-1 text-left truncate"
>
{displayName} {displayName}
</span> </span>
<span class="text-xs text-gray-500 dark:text-gray-400"> <span class="text-xs text-gray-500 dark:text-gray-400">
({authorHighlights.length}) ({authorHighlights.length})
</span> </span>
<svg <svg
class="w-4 h-4 text-gray-500 transition-transform {isExpanded ? 'rotate-90' : ''}" class="w-4 h-4 text-gray-500 transition-transform {isExpanded
? 'rotate-90'
: ''}"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg> </svg>
</button> </button>
@ -727,14 +868,18 @@
{#if isExpanded} {#if isExpanded}
<div class="mt-2 ml-5 space-y-2"> <div class="mt-2 ml-5 space-y-2">
{#each sortedHighlights as highlight} {#each sortedHighlights as highlight}
{@const truncated = useMockHighlights ? "test data" : truncateHighlight(highlight.content)} {@const truncated = useMockHighlights
? "test data"
: truncateHighlight(highlight.content)}
{@const showCopied = copyFeedback === highlight.id} {@const showCopied = copyFeedback === highlight.id}
<div class="flex items-start gap-2 group"> <div class="flex items-start gap-2 group">
<button <button
class="flex-1 text-left text-xs text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors" class="flex-1 text-left text-xs text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
onclick={() => scrollToHighlight(highlight)} onclick={() => scrollToHighlight(highlight)}
title={useMockHighlights ? "Mock highlight" : highlight.content} title={useMockHighlights
? "Mock highlight"
: highlight.content}
> >
{truncated} {truncated}
</button> </button>
@ -744,12 +889,30 @@
title="Copy naddr" title="Copy naddr"
> >
{#if showCopied} {#if showCopied}
<svg class="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20"> <svg
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /> class="w-3 h-3 text-green-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg> </svg>
{:else} {:else}
<svg class="w-3 h-3 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> class="w-3 h-3 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg> </svg>
{/if} {/if}
</button> </button>
@ -776,8 +939,9 @@
animation: flash 1.5s ease-in-out; animation: flash 1.5s ease-in-out;
} }
@keyframes :global(flash) { @keyframes -global-flash {
0%, 100% { 0%,
100% {
filter: brightness(1); filter: brightness(1);
} }
50% { 50% {

83
src/lib/components/publications/HighlightSelectionHandler.svelte

@ -73,7 +73,7 @@
tags: tags, tags: tags,
content: selectedText, content: selectedText,
id: "<calculated-on-signing>", id: "<calculated-on-signing>",
sig: "<calculated-on-signing>" sig: "<calculated-on-signing>",
}; };
}); });
@ -110,7 +110,7 @@
address: sectionAddress, address: sectionAddress,
eventId: sectionEventId, eventId: sectionEventId,
allDataAttrs: publicationSection.dataset, allDataAttrs: publicationSection.dataset,
sectionId: publicationSection.id sectionId: publicationSection.id,
}); });
currentSelection = selection; currentSelection = selection;
@ -151,13 +151,14 @@
event.pubkey = $userStore.pubkey; // Set pubkey from user store event.pubkey = $userStore.pubkey; // Set pubkey from user store
// Use the specific section's address/ID if available, otherwise fall back to publication event // Use the specific section's address/ID if available, otherwise fall back to publication event
const useAddress = selectedSectionAddress || publicationEvent.tagAddress(); const useAddress =
selectedSectionAddress || publicationEvent.tagAddress();
const useEventId = selectedSectionEventId || publicationEvent.id; const useEventId = selectedSectionEventId || publicationEvent.id;
console.log("[HighlightSelectionHandler] Creating highlight with:", { console.log("[HighlightSelectionHandler] Creating highlight with:", {
address: useAddress, address: useAddress,
eventId: useEventId, eventId: useEventId,
fallbackUsed: !selectedSectionAddress fallbackUsed: !selectedSectionAddress,
}); });
const tags: string[][] = []; const tags: string[][] = [];
@ -202,7 +203,11 @@
content: String(event.content), content: String(event.content),
}; };
if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) { if (
typeof window !== "undefined" &&
window.nostr &&
window.nostr.signEvent
) {
const signed = await window.nostr.signEvent(plainEvent); const signed = await window.nostr.signEvent(plainEvent);
event.sig = signed.sig; event.sig = signed.sig;
if ("id" in signed) { if ("id" in signed) {
@ -222,7 +227,10 @@
// Remove duplicates // Remove duplicates
const uniqueRelays = Array.from(new Set(relays)); const uniqueRelays = Array.from(new Set(relays));
console.log("[HighlightSelectionHandler] Publishing to relays:", uniqueRelays); console.log(
"[HighlightSelectionHandler] Publishing to relays:",
uniqueRelays,
);
const signedEvent = { const signedEvent = {
...plainEvent, ...plainEvent,
@ -248,11 +256,15 @@
clearTimeout(timeout); clearTimeout(timeout);
if (ok) { if (ok) {
publishedCount++; publishedCount++;
console.log(`[HighlightSelectionHandler] Published to ${relayUrl}`); console.log(
`[HighlightSelectionHandler] Published to ${relayUrl}`,
);
WebSocketPool.instance.release(ws); WebSocketPool.instance.release(ws);
resolve(); resolve();
} else { } else {
console.warn(`[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`); console.warn(
`[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`,
);
WebSocketPool.instance.release(ws); WebSocketPool.instance.release(ws);
reject(new Error(message)); reject(new Error(message));
} }
@ -263,7 +275,10 @@
ws.send(JSON.stringify(["EVENT", signedEvent])); ws.send(JSON.stringify(["EVENT", signedEvent]));
}); });
} catch (e) { } catch (e) {
console.error(`[HighlightSelectionHandler] Failed to publish to ${relayUrl}:`, e); console.error(
`[HighlightSelectionHandler] Failed to publish to ${relayUrl}:`,
e,
);
} }
} }
@ -271,7 +286,10 @@
throw new Error("Failed to publish to any relays"); throw new Error("Failed to publish to any relays");
} }
showFeedbackMessage(`Highlight created and published to ${publishedCount} relay(s)!`, "success"); showFeedbackMessage(
`Highlight created and published to ${publishedCount} relay(s)!`,
"success",
);
// Clear the selection // Clear the selection
if (currentSelection) { if (currentSelection) {
@ -294,7 +312,10 @@
} }
} catch (error) { } catch (error) {
console.error("Failed to create highlight:", error); console.error("Failed to create highlight:", error);
showFeedbackMessage("Failed to create highlight. Please try again.", "error"); showFeedbackMessage(
"Failed to create highlight. Please try again.",
"error",
);
} finally { } finally {
isSubmitting = false; isSubmitting = false;
} }
@ -349,11 +370,18 @@
</script> </script>
{#if showConfirmModal} {#if showConfirmModal}
<Modal title="Create Highlight" bind:open={showConfirmModal} autoclose={false} size="md"> <Modal
title="Create Highlight"
bind:open={showConfirmModal}
autoclose={false}
size="md"
>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<P class="text-sm font-semibold mb-2">Selected Text:</P> <P class="text-sm font-semibold mb-2">Selected Text:</P>
<div class="bg-gray-100 dark:bg-gray-800 p-3 rounded-lg max-h-32 overflow-y-auto"> <div
class="bg-gray-100 dark:bg-gray-800 p-3 rounded-lg max-h-32 overflow-y-auto"
>
<P class="text-sm italic">"{selectedText}"</P> <P class="text-sm italic">"{selectedText}"</P>
</div> </div>
</div> </div>
@ -366,16 +394,21 @@
id="comment" id="comment"
bind:value={comment} bind:value={comment}
placeholder="Share your thoughts about this highlight..." placeholder="Share your thoughts about this highlight..."
rows="3" rows={3}
class="w-full" class="w-full"
/> />
</div> </div>
<!-- JSON Preview Section --> <!-- JSON Preview Section -->
{#if showJsonPreview && previewJson} {#if showJsonPreview && previewJson}
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900"> <div
class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900"
>
<P class="text-sm font-semibold mb-2">Event JSON Preview:</P> <P class="text-sm font-semibold mb-2">Event JSON Preview:</P>
<pre class="text-xs bg-white dark:bg-gray-800 p-3 rounded overflow-x-auto border border-gray-200 dark:border-gray-700"><code>{JSON.stringify(previewJson, null, 2)}</code></pre> <pre
class="text-xs bg-white dark:bg-gray-800 p-3 rounded overflow-x-auto border border-gray-200 dark:border-gray-700"><code
>{JSON.stringify(previewJson, null, 2)}</code
></pre>
</div> </div>
{/if} {/if}
@ -383,7 +416,7 @@
<Button <Button
color="light" color="light"
size="sm" size="sm"
onclick={() => showJsonPreview = !showJsonPreview} onclick={() => (showJsonPreview = !showJsonPreview)}
class="flex items-center gap-1" class="flex items-center gap-1"
> >
{#if showJsonPreview} {#if showJsonPreview}
@ -395,10 +428,18 @@
</Button> </Button>
<div class="flex space-x-2"> <div class="flex space-x-2">
<Button color="alternative" onclick={cancelHighlight} disabled={isSubmitting}> <Button
color="alternative"
onclick={cancelHighlight}
disabled={isSubmitting}
>
Cancel Cancel
</Button> </Button>
<Button color="primary" onclick={createHighlight} disabled={isSubmitting}> <Button
color="primary"
onclick={createHighlight}
disabled={isSubmitting}
>
{isSubmitting ? "Creating..." : "Create Highlight"} {isSubmitting ? "Creating..." : "Create Highlight"}
</Button> </Button>
</div> </div>
@ -409,7 +450,9 @@
{#if showFeedback} {#if showFeedback}
<div <div
class="fixed bottom-4 right-4 z-50 p-4 rounded-lg shadow-lg {feedbackMessage.includes('success') class="fixed bottom-4 right-4 z-50 p-4 rounded-lg shadow-lg {feedbackMessage.includes(
'success',
)
? 'bg-green-500 text-white' ? 'bg-green-500 text-white'
: 'bg-red-500 text-white'}" : 'bg-red-500 text-white'}"
> >

319
src/lib/components/publications/Publication.svelte

@ -7,7 +7,8 @@
SidebarGroup, SidebarGroup,
SidebarWrapper, SidebarWrapper,
Heading, Heading,
CloseButton, uiHelpers CloseButton,
uiHelpers,
} from "flowbite-svelte"; } from "flowbite-svelte";
import { getContext, onDestroy, onMount } from "svelte"; import { getContext, onDestroy, onMount } from "svelte";
import { import {
@ -37,13 +38,14 @@
import { Textarea, P } from "flowbite-svelte"; import { Textarea, P } from "flowbite-svelte";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{ let { rootAddress, publicationType, indexEvent, publicationTree, toc } =
rootAddress: string; $props<{
publicationType: string; rootAddress: string;
indexEvent: NDKEvent; publicationType: string;
publicationTree: SveltePublicationTree; indexEvent: NDKEvent;
toc: TocType; publicationTree: SveltePublicationTree;
}>(); toc: TocType;
}>();
const ndk = getNdkContext(); const ndk = getNdkContext();
@ -64,23 +66,25 @@
// Toggle between mock and real data for testing (DEBUG MODE) // Toggle between mock and real data for testing (DEBUG MODE)
// Can be controlled via VITE_USE_MOCK_COMMENTS and VITE_USE_MOCK_HIGHLIGHTS environment variables // Can be controlled via VITE_USE_MOCK_COMMENTS and VITE_USE_MOCK_HIGHLIGHTS environment variables
let useMockComments = $state(import.meta.env.VITE_USE_MOCK_COMMENTS === "true"); let useMockComments = $state(
let useMockHighlights = $state(import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true"); import.meta.env.VITE_USE_MOCK_COMMENTS === "true",
);
let useMockHighlights = $state(
import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true",
);
// Log initial state for debugging // Log initial state for debugging
console.log('[Publication] Mock data initialized:', { console.log("[Publication] Mock data initialized:", {
useMockComments,
useMockHighlights,
envVars: { envVars: {
VITE_USE_MOCK_COMMENTS: import.meta.env.VITE_USE_MOCK_COMMENTS, VITE_USE_MOCK_COMMENTS: import.meta.env.VITE_USE_MOCK_COMMENTS,
VITE_USE_MOCK_HIGHLIGHTS: import.meta.env.VITE_USE_MOCK_HIGHLIGHTS, VITE_USE_MOCK_HIGHLIGHTS: import.meta.env.VITE_USE_MOCK_HIGHLIGHTS,
} },
}); });
// Derive all event IDs and addresses for highlight fetching // Derive all event IDs and addresses for highlight fetching
let allEventIds = $derived.by(() => { let allEventIds = $derived.by(() => {
const ids = [indexEvent.id]; const ids = [indexEvent.id];
leaves.forEach(leaf => { leaves.forEach((leaf) => {
if (leaf?.id) ids.push(leaf.id); if (leaf?.id) ids.push(leaf.id);
}); });
return ids; return ids;
@ -88,7 +92,7 @@
let allEventAddresses = $derived.by(() => { let allEventAddresses = $derived.by(() => {
const addresses = [rootAddress]; const addresses = [rootAddress];
leaves.forEach(leaf => { leaves.forEach((leaf) => {
if (leaf) { if (leaf) {
const addr = leaf.tagAddress(); const addr = leaf.tagAddress();
if (addr) addresses.push(addr); if (addr) addresses.push(addr);
@ -99,11 +103,11 @@
// Filter comments for the root publication (kind 30040) // Filter comments for the root publication (kind 30040)
let articleComments = $derived( let articleComments = $derived(
comments.filter(comment => { comments.filter((comment) => {
// Check if comment targets the root publication via #a tag // Check if comment targets the root publication via #a tag
const aTag = comment.tags.find(t => t[0] === 'a'); const aTag = comment.tags.find((t) => t[0] === "a");
return aTag && aTag[1] === rootAddress; return aTag && aTag[1] === rootAddress;
}) }),
); );
// #region Loading // #region Loading
@ -125,7 +129,9 @@
return; return;
} }
console.log(`[Publication] Loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`); console.log(
`[Publication] Loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`,
);
isLoading = true; isLoading = true;
@ -159,7 +165,9 @@
console.error("[Publication] Error loading more content:", error); console.error("[Publication] Error loading more content:", error);
} finally { } finally {
isLoading = false; isLoading = false;
console.log(`[Publication] Finished loading. Total leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`); console.log(
`[Publication] Finished loading. Total leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`,
);
} }
} }
@ -198,7 +206,7 @@
hasInitialized = false; hasInitialized = false;
// Reset the publication tree iterator to prevent duplicate events // Reset the publication tree iterator to prevent duplicate events
if (typeof publicationTree.resetIterator === 'function') { if (typeof publicationTree.resetIterator === "function") {
publicationTree.resetIterator(); publicationTree.resetIterator();
} }
@ -298,7 +306,9 @@
const kind = parseInt(kindStr); const kind = parseInt(kindStr);
// Create comment event (kind 1111) // Create comment event (kind 1111)
const commentEvent = new (await import("@nostr-dev-kit/ndk")).NDKEvent(ndk); const commentEvent = new (await import("@nostr-dev-kit/ndk")).NDKEvent(
ndk,
);
commentEvent.kind = 1111; commentEvent.kind = 1111;
commentEvent.content = articleCommentContent; commentEvent.content = articleCommentContent;
@ -330,10 +340,10 @@
articleCommentSuccess = false; articleCommentSuccess = false;
handleCommentPosted(); handleCommentPosted();
}, 1500); }, 1500);
} catch (err) { } catch (err) {
console.error("[Publication] Error posting article comment:", err); console.error("[Publication] Error posting article comment:", err);
articleCommentError = err instanceof Error ? err.message : "Failed to post comment"; articleCommentError =
err instanceof Error ? err.message : "Failed to post comment";
} finally { } finally {
isSubmittingArticleComment = false; isSubmittingArticleComment = false;
} }
@ -344,30 +354,36 @@
*/ */
async function handleDeletePublication() { async function handleDeletePublication() {
const confirmed = confirm( const confirmed = confirm(
"Are you sure you want to delete this entire publication? This action will publish a deletion request to all relays." "Are you sure you want to delete this entire publication? This action will publish a deletion request to all relays.",
); );
if (!confirmed) return; if (!confirmed) return;
try { try {
await deleteEvent({ await deleteEvent(
eventAddress: indexEvent.tagAddress(), {
eventKind: indexEvent.kind, eventAddress: indexEvent.tagAddress(),
reason: "User deleted publication", eventKind: indexEvent.kind,
onSuccess: (deletionEventId) => { reason: "User deleted publication",
console.log("[Publication] Deletion event published:", deletionEventId); onSuccess: (deletionEventId) => {
publicationDeleted = true; console.log(
"[Publication] Deletion event published:",
// Redirect after 2 seconds deletionEventId,
setTimeout(() => { );
goto("/publications"); publicationDeleted = true;
}, 2000);
// Redirect after 2 seconds
setTimeout(() => {
goto("/publications");
}, 2000);
},
onError: (error) => {
console.error("[Publication] Failed to delete publication:", error);
alert(`Failed to delete publication: ${error}`);
},
}, },
onError: (error) => { ndk,
console.error("[Publication] Failed to delete publication:", error); );
alert(`Failed to delete publication: ${error}`);
},
});
} catch (error) { } catch (error) {
console.error("[Publication] Error deleting publication:", error); console.error("[Publication] Error deleting publication:", error);
alert(`Error: ${error}`); alert(`Error: ${error}`);
@ -422,7 +438,12 @@
observer = new IntersectionObserver( observer = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading && !isDone && publicationTree) { if (
entry.isIntersecting &&
!isLoading &&
!isDone &&
publicationTree
) {
loadMore(1); loadMore(1);
} }
}); });
@ -450,14 +471,11 @@
</script> </script>
<!-- Add gap & items-start so sticky sidebars size correctly --> <!-- Add gap & items-start so sticky sidebars size correctly -->
<div class="relative grid gap-4 items-start grid-cols-[1fr_3fr_1fr] grid-rows-[auto_1fr]"> <div
class="relative grid gap-4 items-start grid-cols-[1fr_3fr_1fr] grid-rows-[auto_1fr]"
>
<!-- Full-width ArticleNav row --> <!-- Full-width ArticleNav row -->
<ArticleNav <ArticleNav {publicationType} rootId={indexEvent.id} {indexEvent} />
publicationType={publicationType}
rootId={indexEvent.id}
indexEvent={indexEvent}
/>
<!-- Highlight selection handler --> <!-- Highlight selection handler -->
<HighlightSelectionHandler <HighlightSelectionHandler
@ -477,43 +495,53 @@
<!-- Three-column row --> <!-- Three-column row -->
<div class="contents"> <div class="contents">
<!-- Table of contents --> <!-- Table of contents -->
<div class="mt-[70px] relative {$publicationColumnVisibility.toc ? 'w-64' : 'w-auto'}"> <div
class="mt-[70px] relative {$publicationColumnVisibility.toc
? 'w-64'
: 'w-auto'}"
>
{#if publicationType !== "blog" && !isLeaf} {#if publicationType !== "blog" && !isLeaf}
{#if $publicationColumnVisibility.toc} {#if $publicationColumnVisibility.toc}
<Sidebar <Sidebar
class="z-10 ml-2 fixed top-[162px] max-h-[calc(100vh-165px)] overflow-y-auto dark:bg-primary-900 bg-primary-50 rounded" class="z-10 ml-2 fixed top-[162px] max-h-[calc(100vh-165px)] overflow-y-auto dark:bg-primary-900 bg-primary-50 rounded"
activeUrl={`#${activeAddress ?? ""}`} activeUrl={`#${activeAddress ?? ""}`}
classes={{ classes={{
div: 'dark:bg-primary-900 bg-primary-50', div: "dark:bg-primary-900 bg-primary-50",
active: 'bg-primary-100 dark:bg-primary-800 p-2 rounded-lg', active: "bg-primary-100 dark:bg-primary-800 p-2 rounded-lg",
nonactive: 'bg-primary-50 dark:bg-primary-900', nonactive: "bg-primary-50 dark:bg-primary-900",
}} }}
> >
<SidebarWrapper> <SidebarWrapper>
<CloseButton color="secondary" class="m-2 dark:text-primary-100" onclick={closeToc} ></CloseButton> <CloseButton
color="secondary"
class="m-2 dark:text-primary-100"
onclick={closeToc}
></CloseButton>
<TableOfContents <TableOfContents
{rootAddress} {rootAddress}
{toc} {toc}
depth={2} depth={2}
onSectionFocused={(address: string) => publicationTree.setBookmark(address)} onSectionFocused={(address: string) =>
publicationTree.setBookmark(address)}
onLoadMore={() => { onLoadMore={() => {
if (!isLoading && !isDone && publicationTree) { if (!isLoading && !isDone && publicationTree) {
loadMore(4); loadMore(4);
} }
}} }}
/> />
</SidebarWrapper> </SidebarWrapper>
</Sidebar> </Sidebar>
{/if} {/if}
{/if} {/if}
</div> </div>
<div class="mt-[70px]"> <div class="mt-[70px]">
<!-- Default publications --> <!-- Default publications -->
{#if $publicationColumnVisibility.main} {#if $publicationColumnVisibility.main}
<!-- Remove overflow-auto so page scroll drives it --> <!-- Remove overflow-auto so page scroll drives it -->
<div class="flex flex-col p-4 space-y-4 max-w-3xl flex-grow-2 mx-auto" bind:this={publicationContentRef}> <div
class="flex flex-col p-4 space-y-4 max-w-3xl flex-grow-2 mx-auto"
bind:this={publicationContentRef}
>
<!-- Publication header with comments (similar to section layout) --> <!-- Publication header with comments (similar to section layout) -->
<div class="relative"> <div class="relative">
<!-- Main header content - centered --> <!-- Main header content - centered -->
@ -521,7 +549,10 @@
<div <div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border" class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
> >
<Details event={indexEvent} onDelete={handleDeletePublication} /> <Details
event={indexEvent}
onDelete={handleDeletePublication}
/>
</div> </div>
{#if publicationDeleted} {#if publicationDeleted}
@ -542,7 +573,9 @@
</div> </div>
<!-- Desktop article comments - positioned on right side on XL+ screens --> <!-- Desktop article comments - positioned on right side on XL+ screens -->
<div class="hidden xl:block absolute left-[calc(50%+26rem)] top-0 w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]"> <div
class="hidden xl:block absolute left-[calc(50%+26rem)] top-0 w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]"
>
<SectionComments <SectionComments
sectionAddress={rootAddress} sectionAddress={rootAddress}
comments={articleComments} comments={articleComments}
@ -557,18 +590,14 @@
<Button <Button
color="light" color="light"
size="sm" size="sm"
onclick={() => showArticleCommentUI = !showArticleCommentUI} onclick={() => (showArticleCommentUI = !showArticleCommentUI)}
> >
{showArticleCommentUI ? 'Close Comment' : 'Comment On Article'} {showArticleCommentUI ? "Close Comment" : "Comment On Article"}
</Button> </Button>
<HighlightButton bind:isActive={highlightModeActive} /> <HighlightButton bind:isActive={highlightModeActive} />
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button color="light" size="sm" onclick={toggleComments}>
color="light"
size="sm"
onclick={toggleComments}
>
{#if commentsVisible} {#if commentsVisible}
<EyeSlashOutline class="w-4 h-4 mr-2" /> <EyeSlashOutline class="w-4 h-4 mr-2" />
Hide Comments Hide Comments
@ -577,11 +606,7 @@
Show Comments Show Comments
{/if} {/if}
</Button> </Button>
<Button <Button color="light" size="sm" onclick={toggleHighlights}>
color="light"
size="sm"
onclick={toggleHighlights}
>
{#if highlightsVisible} {#if highlightsVisible}
<EyeSlashOutline class="w-4 h-4 mr-2" /> <EyeSlashOutline class="w-4 h-4 mr-2" />
Hide Highlights Hide Highlights
@ -595,9 +620,13 @@
<!-- Article Comment UI --> <!-- Article Comment UI -->
{#if showArticleCommentUI} {#if showArticleCommentUI}
<div class="mb-4 border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-gray-800"> <div
class="mb-4 border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-gray-800"
>
<div class="space-y-3"> <div class="space-y-3">
<h4 class="font-semibold text-gray-900 dark:text-white">Comment on Article</h4> <h4 class="font-semibold text-gray-900 dark:text-white">
Comment on Article
</h4>
<Textarea <Textarea
bind:value={articleCommentContent} bind:value={articleCommentContent}
@ -607,18 +636,28 @@
/> />
{#if articleCommentError} {#if articleCommentError}
<P class="text-red-600 dark:text-red-400 text-sm">{articleCommentError}</P> <P class="text-red-600 dark:text-red-400 text-sm"
>{articleCommentError}</P
>
{/if} {/if}
{#if articleCommentSuccess} {#if articleCommentSuccess}
<P class="text-green-600 dark:text-green-400 text-sm">Comment posted successfully!</P> <P class="text-green-600 dark:text-green-400 text-sm"
>Comment posted successfully!</P
>
{/if} {/if}
<div class="flex gap-2"> <div class="flex gap-2">
<Button onclick={submitArticleComment} disabled={isSubmittingArticleComment}> <Button
{isSubmittingArticleComment ? 'Posting...' : 'Post Comment'} onclick={submitArticleComment}
disabled={isSubmittingArticleComment}
>
{isSubmittingArticleComment ? "Posting..." : "Post Comment"}
</Button> </Button>
<Button color="light" onclick={() => showArticleCommentUI = false}> <Button
color="light"
onclick={() => (showArticleCommentUI = false)}
>
Cancel Cancel
</Button> </Button>
</div> </div>
@ -651,7 +690,9 @@
{#if isLoading} {#if isLoading}
<Button disabled color="primary">Loading...</Button> <Button disabled color="primary">Loading...</Button>
{:else if !isDone} {:else if !isDone}
<Button color="primary" onclick={() => loadMore(1)}>Show More</Button> <Button color="primary" onclick={() => loadMore(1)}
>Show More</Button
>
{:else} {:else}
<p class="text-gray-500 dark:text-gray-400"> <p class="text-gray-500 dark:text-gray-400">
You've reached the end of the publication. You've reached the end of the publication.
@ -696,57 +737,64 @@
{/if} {/if}
</div> </div>
<div class="mt-[70px] relative {$publicationColumnVisibility.discussion ? 'w-64' : 'w-auto'}"> <div
class="mt-[70px] relative {$publicationColumnVisibility.discussion
? 'w-64'
: 'w-auto'}"
>
<!-- Discussion sidebar --> <!-- Discussion sidebar -->
{#if $publicationColumnVisibility.discussion} {#if $publicationColumnVisibility.discussion}
<Sidebar <Sidebar
class="z-10 ml-4 fixed top-[162px] h-[calc(100vh-165px)] overflow-y-auto" class="z-10 ml-4 fixed top-[162px] h-[calc(100vh-165px)] overflow-y-auto"
classes={{ classes={{
div: 'bg-transparent' div: "bg-transparent",
}} }}
> >
<SidebarWrapper> <SidebarWrapper>
<SidebarGroup> <SidebarGroup>
<div class="flex justify-between items-baseline"> <div class="flex justify-between items-baseline">
<Heading tag="h1" class="h-leather !text-lg">Discussion</Heading> <Heading tag="h1" class="h-leather !text-lg">Discussion</Heading
<Button >
class="btn-leather hidden sm:flex z-30 !p-1 bg-primary-50 dark:bg-gray-800" <Button
outline class="btn-leather hidden sm:flex z-30 !p-1 bg-primary-50 dark:bg-gray-800"
onclick={closeDiscussion} outline
> onclick={closeDiscussion}
<CloseOutline /> >
</Button> <CloseOutline />
</div> </Button>
<div class="flex flex-col space-y-4"> </div>
<!-- TODO <div class="flex flex-col space-y-4">
<!-- TODO
alternative for other publications and alternative for other publications and
when blog is not opened, but discussion is opened from the list when blog is not opened, but discussion is opened from the list
--> -->
{#if showBlogHeader() && currentBlog && currentBlogEvent} {#if showBlogHeader() && currentBlog && currentBlogEvent}
<BlogHeader <BlogHeader
rootId={currentBlog} rootId={currentBlog}
event={currentBlogEvent} event={currentBlogEvent}
onBlogUpdate={loadBlog} onBlogUpdate={loadBlog}
active={true} active={true}
/> />
{/if}
<div class="flex flex-col w-full space-y-4">
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
{#if articleComments.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
No comments yet. Be the first to comment!
</p>
{/if} {/if}
<div class="flex flex-col w-full space-y-4">
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
{#if articleComments.length === 0}
<p
class="text-sm text-gray-500 dark:text-gray-400 text-center py-4"
>
No comments yet. Be the first to comment!
</p>
{/if}
</div>
</div> </div>
</div> </SidebarGroup>
</SidebarGroup> </SidebarWrapper>
</SidebarWrapper> </Sidebar>
</Sidebar> {/if}
{/if}
</div> </div>
</div> </div>
</div> </div>
@ -757,7 +805,7 @@
eventIds={allEventIds} eventIds={allEventIds}
eventAddresses={allEventAddresses} eventAddresses={allEventAddresses}
bind:visible={highlightsVisible} bind:visible={highlightsVisible}
useMockHighlights={useMockHighlights} {useMockHighlights}
/> />
<!-- Comment Layer Component --> <!-- Comment Layer Component -->
@ -765,7 +813,6 @@
bind:this={commentLayerRef} bind:this={commentLayerRef}
eventIds={allEventIds} eventIds={allEventIds}
eventAddresses={allEventAddresses} eventAddresses={allEventAddresses}
bind:comments={comments} bind:comments
useMockComments={useMockComments} {useMockComments}
/> />

70
src/lib/components/publications/PublicationSection.svelte

@ -42,11 +42,11 @@
// Filter comments for this section // Filter comments for this section
let sectionComments = $derived( let sectionComments = $derived(
allComments.filter(comment => { allComments.filter((comment) => {
// Check if comment targets this section via #a tag // Check if comment targets this section via #a tag
const aTag = comment.tags.find(t => t[0] === 'a'); const aTag = comment.tags.find((t) => t[0] === "a");
return aTag && aTag[1] === address; return aTag && aTag[1] === address;
}) }),
); );
let leafEvent: Promise<NDKEvent | null> = $derived.by( let leafEvent: Promise<NDKEvent | null> = $derived.by(
@ -56,10 +56,13 @@
let leafEventId = $state<string>(""); let leafEventId = $state<string>("");
$effect(() => { $effect(() => {
leafEvent.then(e => { leafEvent.then((e) => {
if (e?.id) { if (e?.id) {
leafEventId = e.id; leafEventId = e.id;
console.log(`[PublicationSection] Set leafEventId for ${address}:`, e.id); console.log(
`[PublicationSection] Set leafEventId for ${address}:`,
e.id,
);
} }
}); });
}); });
@ -91,7 +94,10 @@
} else { } else {
// For 30041 and 30818 events, use Asciidoctor (AsciiDoc) // For 30041 and 30818 events, use Asciidoctor (AsciiDoc)
const converted = asciidoctor.convert(content); const converted = asciidoctor.convert(content);
const processed = await postProcessAdvancedAsciidoctorHtml(converted.toString(), ndk); const processed = await postProcessAdvancedAsciidoctorHtml(
converted.toString(),
ndk,
);
return processed; return processed;
} }
}); });
@ -169,26 +175,32 @@
if (!event) return; if (!event) return;
const confirmed = confirm( const confirmed = confirm(
"Are you sure you want to delete this section? This action will publish a deletion request to all relays." "Are you sure you want to delete this section? This action will publish a deletion request to all relays.",
); );
if (!confirmed) return; if (!confirmed) return;
try { try {
await deleteEvent({ await deleteEvent(
eventAddress: address, {
eventKind: event.kind, eventAddress: address,
reason: "User deleted section", eventKind: event.kind,
onSuccess: (deletionEventId) => { reason: "User deleted section",
console.log("[PublicationSection] Deletion event published:", deletionEventId); onSuccess: (deletionEventId) => {
// Refresh the page to reflect the deletion console.log(
window.location.reload(); "[PublicationSection] Deletion event published:",
deletionEventId,
);
// Refresh the page to reflect the deletion
window.location.reload();
},
onError: (error) => {
console.error("[PublicationSection] Deletion failed:", error);
alert(`Failed to delete section: ${error}`);
},
}, },
onError: (error) => { ndk,
console.error("[PublicationSection] Deletion failed:", error); );
alert(`Failed to delete section: ${error}`);
},
}, ndk);
} catch (error) { } catch (error) {
console.error("[PublicationSection] Deletion error:", error); console.error("[PublicationSection] Deletion error:", error);
} }
@ -206,7 +218,7 @@
address, address,
leafEventId, leafEventId,
dataAddress: sectionRef.dataset.eventAddress, dataAddress: sectionRef.dataset.eventAddress,
dataEventId: sectionRef.dataset.eventId dataEventId: sectionRef.dataset.eventId,
}); });
}); });
</script> </script>
@ -221,7 +233,7 @@
data-event-id={leafEventId} data-event-id={leafEventId}
> >
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )} {#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )}
<TextPlaceholder size="xxl" /> <TextPlaceholder size="2xl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
<!-- Main content area - centered --> <!-- Main content area - centered -->
<div class="section-content relative max-w-4xl mx-auto px-4"> <div class="section-content relative max-w-4xl mx-auto px-4">
@ -229,7 +241,11 @@
<div class="xl:hidden absolute top-2 right-2 z-10"> <div class="xl:hidden absolute top-2 right-2 z-10">
{#await leafEvent then event} {#await leafEvent then event}
{#if event} {#if event}
<CardActions {event} sectionAddress={address} onDelete={handleDelete} /> <CardActions
{event}
sectionAddress={address}
onDelete={handleDelete}
/>
{/if} {/if}
{/await} {/await}
</div> </div>
@ -265,14 +281,18 @@
{#await leafEvent then event} {#await leafEvent then event}
{#if event} {#if event}
<!-- Three-dot menu - positioned at top-center on XL+ screens --> <!-- Three-dot menu - positioned at top-center on XL+ screens -->
<div class="hidden xl:block absolute left-[calc(50%+26rem)] top-[20%] z-10"> <div
class="hidden xl:block absolute left-[calc(50%+26rem)] top-[20%] z-10"
>
<CardActions {event} sectionAddress={address} onDelete={handleDelete} /> <CardActions {event} sectionAddress={address} onDelete={handleDelete} />
</div> </div>
{/if} {/if}
{/await} {/await}
<!-- Comments area: positioned below menu, top-center of section --> <!-- Comments area: positioned below menu, top-center of section -->
<div class="hidden xl:block absolute left-[calc(50%+26rem)] top-[calc(20%+3rem)] w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]"> <div
class="hidden xl:block absolute left-[calc(50%+26rem)] top-[calc(20%+3rem)] w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]"
>
<SectionComments <SectionComments
sectionAddress={address} sectionAddress={address}
comments={sectionComments} comments={sectionComments}

85
src/lib/components/util/CardActions.svelte

@ -12,7 +12,11 @@
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { neventEncode, naddrEncode } from "$lib/utils"; import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk"; import {
activeInboxRelays,
activeOutboxRelays,
getNdkContext,
} from "$lib/ndk";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
@ -104,19 +108,20 @@
], ],
content: commentContent, content: commentContent,
id: "<calculated-on-signing>", id: "<calculated-on-signing>",
sig: "<calculated-on-signing>" sig: "<calculated-on-signing>",
}; };
}); });
// Check if user can delete this event (must be the author) // Check if user can delete this event (must be the author)
let canDelete = $derived.by(() => { let canDelete = $derived.by(() => {
const result = user.signedIn && user.pubkey === event.pubkey && onDelete !== undefined; const result =
console.log('[CardActions] canDelete check:', { user.signedIn && user.pubkey === event.pubkey && onDelete !== undefined;
console.log("[CardActions] canDelete check:", {
userSignedIn: user.signedIn, userSignedIn: user.signedIn,
userPubkey: user.pubkey, userPubkey: user.pubkey,
eventPubkey: event.pubkey, eventPubkey: event.pubkey,
onDeleteProvided: onDelete !== undefined, onDeleteProvided: onDelete !== undefined,
canDelete: result canDelete: result,
}); });
return result; return result;
}); });
@ -221,7 +226,9 @@
/** /**
* Parse address to get event details * Parse address to get event details
*/ */
function parseAddress(address: string): { kind: number; pubkey: string; dTag: string } | null { function parseAddress(
address: string,
): { kind: number; pubkey: string; dTag: string } | null {
const parts = address.split(":"); const parts = address.split(":");
if (parts.length !== 3) { if (parts.length !== 3) {
console.error("[CardActions] Invalid address format:", address); console.error("[CardActions] Invalid address format:", address);
@ -301,12 +308,18 @@
const plainEvent = { const plainEvent = {
kind: Number(commentEvent.kind), kind: Number(commentEvent.kind),
pubkey: String(commentEvent.pubkey), pubkey: String(commentEvent.pubkey),
created_at: Number(commentEvent.created_at ?? Math.floor(Date.now() / 1000)), created_at: Number(
commentEvent.created_at ?? Math.floor(Date.now() / 1000),
),
tags: commentEvent.tags.map((tag) => tag.map(String)), tags: commentEvent.tags.map((tag) => tag.map(String)),
content: String(commentEvent.content), content: String(commentEvent.content),
}; };
if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) { if (
typeof window !== "undefined" &&
window.nostr &&
window.nostr.signEvent
) {
const signed = await window.nostr.signEvent(plainEvent); const signed = await window.nostr.signEvent(plainEvent);
commentEvent.sig = signed.sig; commentEvent.sig = signed.sig;
if ("id" in signed) { if ("id" in signed) {
@ -373,10 +386,10 @@
commentContent = ""; commentContent = "";
showJsonPreview = false; showJsonPreview = false;
}, 2000); }, 2000);
} catch (err) { } catch (err) {
console.error("[CardActions] Error submitting comment:", err); console.error("[CardActions] Error submitting comment:", err);
commentError = err instanceof Error ? err.message : "Failed to post comment"; commentError =
err instanceof Error ? err.message : "Failed to post comment";
} finally { } finally {
isSubmittingComment = false; isSubmittingComment = false;
} }
@ -404,7 +417,7 @@
type="button" type="button"
id="dots-{event.id}" id="dots-{event.id}"
class=" hover:bg-primary-50 dark:text-highlight dark:hover:bg-primary-800 p-1 dots" class=" hover:bg-primary-50 dark:text-highlight dark:hover:bg-primary-800 p-1 dots"
color="none" color="primary"
data-popover-target="popover-actions" data-popover-target="popover-actions"
> >
<DotsVerticalOutline class="h-6 w-6" /> <DotsVerticalOutline class="h-6 w-6" />
@ -463,7 +476,8 @@
onDelete?.(); onDelete?.();
}} }}
> >
<TrashBinOutline class="inline mr-2" /> {deleteButtonText} <TrashBinOutline class="inline mr-2" />
{deleteButtonText}
</button> </button>
</li> </li>
{/if} {/if}
@ -570,7 +584,9 @@
> >
<div class="space-y-4"> <div class="space-y-4">
{#if user.profile} {#if user.profile}
<div class="flex items-center gap-3 pb-3 border-b border-gray-200 dark:border-gray-700"> <div
class="flex items-center gap-3 pb-3 border-b border-gray-200 dark:border-gray-700"
>
{#if user.profile.picture} {#if user.profile.picture}
<img <img
src={user.profile.picture} src={user.profile.picture}
@ -597,14 +613,21 @@
{/if} {/if}
{#if commentSuccess} {#if commentSuccess}
<P class="text-green-600 dark:text-green-400 text-sm">Comment posted successfully!</P> <P class="text-green-600 dark:text-green-400 text-sm"
>Comment posted successfully!</P
>
{/if} {/if}
<!-- JSON Preview Section --> <!-- JSON Preview Section -->
{#if showJsonPreview && previewJson} {#if showJsonPreview && previewJson}
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900"> <div
class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900"
>
<P class="text-sm font-semibold mb-2">Event JSON Preview:</P> <P class="text-sm font-semibold mb-2">Event JSON Preview:</P>
<pre class="text-xs bg-white dark:bg-gray-800 p-3 rounded overflow-x-auto border border-gray-200 dark:border-gray-700"><code>{JSON.stringify(previewJson, null, 2)}</code></pre> <pre
class="text-xs bg-white dark:bg-gray-800 p-3 rounded overflow-x-auto border border-gray-200 dark:border-gray-700"><code
>{JSON.stringify(previewJson, null, 2)}</code
></pre>
</div> </div>
{/if} {/if}
@ -612,7 +635,7 @@
<Button <Button
color="light" color="light"
size="sm" size="sm"
onclick={() => showJsonPreview = !showJsonPreview} onclick={() => (showJsonPreview = !showJsonPreview)}
class="flex items-center gap-1" class="flex items-center gap-1"
> >
{#if showJsonPreview} {#if showJsonPreview}
@ -624,20 +647,20 @@
</Button> </Button>
<div class="flex gap-3"> <div class="flex gap-3">
<Button <Button
color="alternative" color="alternative"
onclick={cancelComment} onclick={cancelComment}
disabled={isSubmittingComment} disabled={isSubmittingComment}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
color="primary" color="primary"
onclick={submitComment} onclick={submitComment}
disabled={isSubmittingComment || !commentContent.trim()} disabled={isSubmittingComment || !commentContent.trim()}
> >
{isSubmittingComment ? "Posting..." : "Post Comment"} {isSubmittingComment ? "Posting..." : "Post Comment"}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>

20
src/lib/components/util/Interactions.svelte

@ -11,10 +11,11 @@
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
import { getNdkContext } from "$lib/ndk"; import { getNdkContext } from "$lib/ndk";
const { const { rootId, direction = "row" } = $props<{
rootId, rootId: string;
direction = "row", event?: NDKEvent;
} = $props<{ rootId: string; event?: NDKEvent; direction?: string }>(); direction?: string;
}>();
const ndk = getNdkContext(); const ndk = getNdkContext();
@ -90,26 +91,27 @@
class="InteractiveMenu !hidden flex-{direction} justify-around align-middle text-primary-700 dark:text-gray-300" class="InteractiveMenu !hidden flex-{direction} justify-around align-middle text-primary-700 dark:text-gray-300"
> >
<Button <Button
color="none" color="secondary"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0" class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={doLike} onclick={doLike}
><HeartOutline class="mx-2" size="lg" /><span>{likeCount}</span></Button
> >
<HeartOutline class="mx-2" size="lg" /><span>{likeCount}</span>
</Button>
<Button <Button
color="none" color="secondary"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0" class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={doZap} onclick={doZap}
><ZapOutline className="mx-2" /><span>{zapCount}</span></Button ><ZapOutline className="mx-2" /><span>{zapCount}</span></Button
> >
<Button <Button
color="none" color="secondary"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0" class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={doHighlight} onclick={doHighlight}
><FilePenOutline class="mx-2" size="lg" /><span>{highlightCount}</span ><FilePenOutline class="mx-2" size="lg" /><span>{highlightCount}</span
></Button ></Button
> >
<Button <Button
color="none" color="secondary"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0" class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={showDiscussion} onclick={showDiscussion}
><AnnotationOutline class="mx-2" size="lg" /><span>{commentCount}</span ><AnnotationOutline class="mx-2" size="lg" /><span>{commentCount}</span

37
src/routes/new/edit/+page.svelte

@ -1,10 +1,5 @@
<script lang="ts"> <script lang="ts">
import { import { Heading, Textarea, Toolbar, ToolbarButton } from "flowbite-svelte";
Heading,
Textarea,
Toolbar,
ToolbarButton,
} from "flowbite-svelte";
import { import {
CodeOutline, CodeOutline,
EyeSolid, EyeSolid,
@ -72,29 +67,37 @@
placeholder="Write AsciiDoc content" placeholder="Write AsciiDoc content"
bind:value={editorText} bind:value={editorText}
> >
<Toolbar slot="header" embedded> <!-- MichaelJ 12-04-2025 - This `Toolbar` construct may be invalid with the current version of Flowbite Svelte -->
<ToolbarButton name="Preview" onclick={showPreview}> {#snippet header()}
<EyeSolid class="w-6 h-6" /> <Toolbar embedded>
</ToolbarButton> <ToolbarButton name="Preview" onclick={showPreview}>
<ToolbarButton name="Review" slot="end" onclick={prepareReview}> <EyeSolid class="w-6 h-6" />
<PaperPlaneOutline class="w=6 h-6 rotate-90" /> </ToolbarButton>
</ToolbarButton> {#snippet end()}
</Toolbar> <ToolbarButton name="Review" onclick={prepareReview}>
<PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton>
{/snippet}
</Toolbar>
{/snippet}
</Textarea> </Textarea>
</form> </form>
{:else} {:else}
<form <form
class="border border-gray-400 dark:border-gray-600 rounded-lg flex flex-col space-y-2 h-fit" class="border border-gray-400 dark:border-gray-600 rounded-lg flex flex-col space-y-2 h-fit"
> >
<!-- MichaelJ 12-04-2025 - This `Toolbar` construct may be invalid with the current version of Flowbite Svelte -->
<Toolbar <Toolbar
class="toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800" class="toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800"
> >
<ToolbarButton name="Edit" onclick={hidePreview}> <ToolbarButton name="Edit" onclick={hidePreview}>
<CodeOutline class="w-6 h-6" /> <CodeOutline class="w-6 h-6" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton name="Review" slot="end" onclick={prepareReview}> {#snippet end()}
<PaperPlaneOutline class="w=6 h-6 rotate-90" /> <ToolbarButton name="Review" onclick={prepareReview}>
</ToolbarButton> <PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton>
{/snippet}
</Toolbar> </Toolbar>
{#if rootIndexId} {#if rootIndexId}
<Preview <Preview

581
tests/unit/commentButton.test.ts

File diff suppressed because it is too large Load Diff

77
tests/unit/deletion.test.ts

@ -1,8 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { beforeEach, describe, expect, it, vi } from "vitest";
import { deleteEvent, canDeleteEvent } from '$lib/services/deletion'; import {
import NDK, { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'; canDeleteEvent,
deleteEvent,
describe('Deletion Service', () => { } from "../../src/lib/services/deletion.ts";
import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
describe("Deletion Service", () => {
let mockNdk: NDK; let mockNdk: NDK;
let mockEvent: NDKEvent; let mockEvent: NDKEvent;
@ -10,47 +13,47 @@ describe('Deletion Service', () => {
// Create mock NDK instance // Create mock NDK instance
mockNdk = { mockNdk = {
activeUser: { activeUser: {
pubkey: 'test-pubkey-123', pubkey: "test-pubkey-123",
}, },
pool: { pool: {
relays: new Map([ relays: new Map([
['wss://relay1.example.com', { url: 'wss://relay1.example.com' }], ["wss://relay1.example.com", { url: "wss://relay1.example.com" }],
['wss://relay2.example.com', { url: 'wss://relay2.example.com' }], ["wss://relay2.example.com", { url: "wss://relay2.example.com" }],
]), ]),
}, },
} as unknown as NDK; } as unknown as NDK;
// Create mock event // Create mock event
mockEvent = { mockEvent = {
id: 'event-id-123', id: "event-id-123",
kind: 30041, kind: 30041,
pubkey: 'test-pubkey-123', pubkey: "test-pubkey-123",
tagAddress: () => '30041:test-pubkey-123:test-identifier', tagAddress: () => "30041:test-pubkey-123:test-identifier",
} as unknown as NDKEvent; } as unknown as NDKEvent;
}); });
describe('canDeleteEvent', () => { describe("canDeleteEvent", () => {
it('should return true when user is the event author', () => { it("should return true when user is the event author", () => {
const result = canDeleteEvent(mockEvent, mockNdk); const result = canDeleteEvent(mockEvent, mockNdk);
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false when user is not the event author', () => { it("should return false when user is not the event author", () => {
const differentUserEvent = { const differentUserEvent = {
...mockEvent, ...mockEvent,
pubkey: 'different-pubkey-456', pubkey: "different-pubkey-456",
} as unknown as NDKEvent; } as unknown as NDKEvent;
const result = canDeleteEvent(differentUserEvent, mockNdk); const result = canDeleteEvent(differentUserEvent, mockNdk);
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false when event is null', () => { it("should return false when event is null", () => {
const result = canDeleteEvent(null, mockNdk); const result = canDeleteEvent(null, mockNdk);
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false when ndk has no active user', () => { it("should return false when ndk has no active user", () => {
const ndkWithoutUser = { const ndkWithoutUser = {
...mockNdk, ...mockNdk,
activeUser: undefined, activeUser: undefined,
@ -61,40 +64,44 @@ describe('Deletion Service', () => {
}); });
}); });
describe('deleteEvent', () => { describe("deleteEvent", () => {
it('should return error when no eventId or eventAddress provided', async () => { it("should return error when no eventId or eventAddress provided", async () => {
const result = await deleteEvent({}, mockNdk); const result = await deleteEvent({}, mockNdk);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('Either eventId or eventAddress must be provided'); expect(result.error).toBe(
"Either eventId or eventAddress must be provided",
);
}); });
it('should return error when user is not logged in', async () => { it("should return error when user is not logged in", async () => {
const ndkWithoutUser = { const ndkWithoutUser = {
...mockNdk, ...mockNdk,
activeUser: undefined, activeUser: undefined,
} as unknown as NDK; } as unknown as NDK;
const result = await deleteEvent( const result = await deleteEvent(
{ eventId: 'test-id' }, { eventId: "test-id" },
ndkWithoutUser ndkWithoutUser,
); );
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('Please log in first'); expect(result.error).toBe("Please log in first");
}); });
it('should create deletion event with correct tags', async () => { it("should create deletion event with correct tags", async () => {
const mockSign = vi.fn(); const mockSign = vi.fn();
const mockPublish = vi.fn().mockResolvedValue(new Set(['wss://relay1.example.com'])); const mockPublish = vi.fn().mockResolvedValue(
new Set(["wss://relay1.example.com"]),
);
// Mock NDKEvent constructor // Mock NDKEvent constructor
const MockNDKEvent = vi.fn().mockImplementation(function(this: any) { const MockNDKEvent = vi.fn().mockImplementation(function (this: any) {
this.kind = 0; this.kind = 0;
this.created_at = 0; this.created_at = 0;
this.tags = []; this.tags = [];
this.content = ''; this.content = "";
this.pubkey = ''; this.pubkey = "";
this.sign = mockSign; this.sign = mockSign;
this.publish = mockPublish; this.publish = mockPublish;
return this; return this;
@ -102,20 +109,20 @@ describe('Deletion Service', () => {
// Mock NDKRelaySet // Mock NDKRelaySet
const mockRelaySet = {} as NDKRelaySet; const mockRelaySet = {} as NDKRelaySet;
vi.spyOn(NDKRelaySet, 'fromRelayUrls').mockReturnValue(mockRelaySet); vi.spyOn(NDKRelaySet, "fromRelayUrls").mockReturnValue(mockRelaySet);
// Replace global NDKEvent temporarily // Replace global NDKEvent temporarily
const originalNDKEvent = global.NDKEvent; const originalNDKEvent = (globalThis as any).NDKEvent;
(global as any).NDKEvent = MockNDKEvent; (global as any).NDKEvent = MockNDKEvent;
const result = await deleteEvent( const result = await deleteEvent(
{ {
eventId: 'event-123', eventId: "event-123",
eventAddress: '30041:pubkey:identifier', eventAddress: "30041:pubkey:identifier",
eventKind: 30041, eventKind: 30041,
reason: 'Test deletion', reason: "Test deletion",
}, },
mockNdk mockNdk,
); );
// Restore original // Restore original

52
tests/unit/fetchPublicationHighlights.test.ts

@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NDK, NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { fetchHighlightsForPublication } from "../../src/lib/utils/fetch_publication_highlights"; import type NDK from "@nostr-dev-kit/ndk";
import { fetchHighlightsForPublication } from "../../src/lib/utils/fetch_publication_highlights.ts";
// Mock NDKEvent class // Mock NDKEvent class
class MockNDKEvent { class MockNDKEvent {
@ -83,7 +84,8 @@ describe("fetchHighlightsForPublication", () => {
], ],
created_at: 1744910311, created_at: 1744910311,
id: "4585ed74a0be37655aa887340d239f0bbb9df5476165d912f098c55a71196fef", id: "4585ed74a0be37655aa887340d239f0bbb9df5476165d912f098c55a71196fef",
sig: "e6a832dcfc919c913acee62cb598211544bc8e03a3f61c016eb3bf6c8cb4fb333eff8fecc601517604c7a8029dfa73591f3218465071a532f4abfe8c0bf3662d", sig:
"e6a832dcfc919c913acee62cb598211544bc8e03a3f61c016eb3bf6c8cb4fb333eff8fecc601517604c7a8029dfa73591f3218465071a532f4abfe8c0bf3662d",
}) as unknown as NDKEvent; }) as unknown as NDKEvent;
// Create mock highlight events for different sections // Create mock highlight events for different sections
@ -156,7 +158,7 @@ describe("fetchHighlightsForPublication", () => {
return new Set( return new Set(
mockHighlights.filter((highlight) => mockHighlights.filter((highlight) =>
aTagFilter.includes(highlight.tagValue("a") || "") aTagFilter.includes(highlight.tagValue("a") || "")
) ),
); );
} }
return new Set(); return new Set();
@ -167,33 +169,33 @@ describe("fetchHighlightsForPublication", () => {
it("should extract section references from 30040 publication event", async () => { it("should extract section references from 30040 publication event", async () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
publicationEvent, publicationEvent,
mockNDK mockNDK,
); );
// Should have results for the sections that have highlights // Should have results for the sections that have highlights
expect(result.size).toBeGreaterThan(0); expect(result.size).toBeGreaterThan(0);
expect( expect(
result.has( result.has(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading" "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
) ),
).toBe(true); ).toBe(true);
}); });
it("should fetch highlights for each section reference", async () => { it("should fetch highlights for each section reference", async () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
publicationEvent, publicationEvent,
mockNDK mockNDK,
); );
// First section should have 2 highlights // First section should have 2 highlights
const firstSectionHighlights = result.get( const firstSectionHighlights = result.get(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading" "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
); );
expect(firstSectionHighlights?.length).toBe(2); expect(firstSectionHighlights?.length).toBe(2);
// Second section should have 1 highlight // Second section should have 1 highlight
const secondSectionHighlights = result.get( const secondSectionHighlights = result.get(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading" "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading",
); );
expect(secondSectionHighlights?.length).toBe(1); expect(secondSectionHighlights?.length).toBe(1);
}); });
@ -201,38 +203,38 @@ describe("fetchHighlightsForPublication", () => {
it("should group highlights by section address", async () => { it("should group highlights by section address", async () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
publicationEvent, publicationEvent,
mockNDK mockNDK,
); );
const firstSectionHighlights = result.get( const firstSectionHighlights = result.get(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading" "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
); );
// Verify the highlights are correctly grouped // Verify the highlights are correctly grouped
expect(firstSectionHighlights?.[0].content).toBe( expect(firstSectionHighlights?.[0].content).toBe(
"This is an interesting point" "This is an interesting point",
); );
expect(firstSectionHighlights?.[1].content).toBe( expect(firstSectionHighlights?.[1].content).toBe(
"Another highlight on same section" "Another highlight on same section",
); );
}); });
it("should not include sections without highlights", async () => { it("should not include sections without highlights", async () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
publicationEvent, publicationEvent,
mockNDK mockNDK,
); );
// Sections without highlights should not be in the result // Sections without highlights should not be in the result
expect( expect(
result.has( result.has(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:a-third-first-level-heading" "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:a-third-first-level-heading",
) ),
).toBe(false); ).toBe(false);
expect( expect(
result.has( result.has(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:asciimath-test-document" "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:asciimath-test-document",
) ),
).toBe(false); ).toBe(false);
}); });
@ -249,7 +251,7 @@ describe("fetchHighlightsForPublication", () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
emptyPublication, emptyPublication,
mockNDK mockNDK,
); );
expect(result.size).toBe(0); expect(result.size).toBe(0);
@ -273,7 +275,7 @@ describe("fetchHighlightsForPublication", () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
mixedPublication, mixedPublication,
mockNDK mockNDK,
); );
// Should call fetchEvents with only the 30041 reference // Should call fetchEvents with only the 30041 reference
@ -283,7 +285,7 @@ describe("fetchHighlightsForPublication", () => {
"#a": [ "#a": [
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
], ],
}) }),
); );
}); });
@ -303,7 +305,7 @@ describe("fetchHighlightsForPublication", () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
colonPublication, colonPublication,
mockNDK mockNDK,
); );
// Should correctly parse the section address with colons // Should correctly parse the section address with colons
@ -312,7 +314,7 @@ describe("fetchHighlightsForPublication", () => {
"#a": [ "#a": [
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:section:with:colons", "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:section:with:colons",
], ],
}) }),
); );
}); });
}); });

36
tests/unit/highlightSelection.test.ts

@ -1,11 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk";
// Mock flowbite-svelte components // Mock flowbite-svelte components
vi.mock("flowbite-svelte", () => ({ vi.mock("flowbite-svelte", () => ({
Button: vi.fn().mockImplementation((props) => ({ Button: vi.fn().mockImplementation((props) => ({
$$render: () => `<button data-testid="button">${props.children || ""}</button>`, $$render: () =>
`<button data-testid="button">${props.children || ""}</button>`,
})), })),
Modal: vi.fn().mockImplementation(() => ({ Modal: vi.fn().mockImplementation(() => ({
$$render: () => `<div data-testid="modal"></div>`, $$render: () => `<div data-testid="modal"></div>`,
@ -277,11 +278,14 @@ describe("HighlightSelectionHandler Component Logic", () => {
describe("Context Extraction", () => { describe("Context Extraction", () => {
it("should extract context from parent paragraph", () => { it("should extract context from parent paragraph", () => {
const paragraph = { const paragraph = {
textContent: "This is the full paragraph context with selected text inside.", textContent:
"This is the full paragraph context with selected text inside.",
}; };
const context = paragraph.textContent?.trim() || ""; const context = paragraph.textContent?.trim() || "";
expect(context).toBe("This is the full paragraph context with selected text inside."); expect(context).toBe(
"This is the full paragraph context with selected text inside.",
);
}); });
it("should extract context from parent section", () => { it("should extract context from parent section", () => {
@ -654,7 +658,10 @@ describe("HighlightSelectionHandler Component Logic", () => {
document.addEventListener = mockAddEventListener; document.addEventListener = mockAddEventListener;
document.addEventListener("mouseup", () => {}); document.addEventListener("mouseup", () => {});
expect(mockAddEventListener).toHaveBeenCalledWith("mouseup", expect.any(Function)); expect(mockAddEventListener).toHaveBeenCalledWith(
"mouseup",
expect.any(Function),
);
}); });
it("should remove mouseup listener on unmount", () => { it("should remove mouseup listener on unmount", () => {
@ -689,7 +696,9 @@ describe("HighlightSelectionHandler Component Logic", () => {
// Simulate inactive mode // Simulate inactive mode
document.body.classList.remove("highlight-mode-active"); document.body.classList.remove("highlight-mode-active");
expect(mockClassList.remove).toHaveBeenCalledWith("highlight-mode-active"); expect(mockClassList.remove).toHaveBeenCalledWith(
"highlight-mode-active",
);
}); });
it("should clean up class on unmount", () => { it("should clean up class on unmount", () => {
@ -701,7 +710,9 @@ describe("HighlightSelectionHandler Component Logic", () => {
// Simulate cleanup // Simulate cleanup
document.body.classList.remove("highlight-mode-active"); document.body.classList.remove("highlight-mode-active");
expect(mockClassList.remove).toHaveBeenCalledWith("highlight-mode-active"); expect(mockClassList.remove).toHaveBeenCalledWith(
"highlight-mode-active",
);
}); });
}); });
@ -767,17 +778,6 @@ describe("HighlightSelectionHandler Component Logic", () => {
// Simulate failed creation - callback not called // Simulate failed creation - callback not called
expect(mockCallback).not.toHaveBeenCalled(); expect(mockCallback).not.toHaveBeenCalled();
}); });
it("should handle missing callback gracefully", () => {
const callback = undefined;
// Should not throw error
expect(() => {
if (callback) {
callback();
}
}).not.toThrow();
});
}); });
describe("Integration Scenarios", () => { describe("Integration Scenarios", () => {

Loading…
Cancel
Save