Browse Source

Fix errors surfaced by Svelte check

master
buttercat1791 3 months ago
parent
commit
e935daf08a
  1. 186
      deno.lock
  2. 9
      src/lib/a/cards/AEventPreview.svelte
  3. 88
      src/lib/components/EventDetails.svelte
  4. 108
      src/lib/components/EventKindFilter.svelte
  5. 8
      src/lib/components/Navigation.svelte
  6. 17
      src/lib/components/Preview.svelte
  7. 209
      src/lib/components/ZettelEditor.svelte
  8. 326
      src/lib/components/publications/HighlightLayer.svelte
  9. 83
      src/lib/components/publications/HighlightSelectionHandler.svelte
  10. 187
      src/lib/components/publications/Publication.svelte
  11. 50
      src/lib/components/publications/PublicationSection.svelte
  12. 57
      src/lib/components/util/CardActions.svelte
  13. 20
      src/lib/components/util/Interactions.svelte
  14. 21
      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 @@ @@ -62,6 +62,7 @@
"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: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"
},
"jsr": {
@ -326,261 +327,131 @@ @@ -326,261 +327,131 @@
"tslib"
]
},
"@esbuild/aix-ppc64@0.25.7": {
"integrity": "sha512-uD0kKFHh6ETr8TqEtaAcV+dn/2qnYbH/+8wGEdY70Qf7l1l/jmBUbrmQqwiPKAQE6cOQ7dTj6Xr0HzQDGHyceQ==",
"os": ["aix"],
"cpu": ["ppc64"]
},
"@esbuild/aix-ppc64@0.25.9": {
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
"os": ["aix"],
"cpu": ["ppc64"]
},
"@esbuild/android-arm64@0.25.7": {
"integrity": "sha512-p0ohDnwyIbAtztHTNUTzN5EGD/HJLs1bwysrOPgSdlIA6NDnReoVfoCyxG6W1d85jr2X80Uq5KHftyYgaK9LPQ==",
"os": ["android"],
"cpu": ["arm64"]
},
"@esbuild/android-arm64@0.25.9": {
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
"os": ["android"],
"cpu": ["arm64"]
},
"@esbuild/android-arm@0.25.7": {
"integrity": "sha512-Jhuet0g1k9rAJHrXGIh7sFknFuT4sfytYZpZpuZl7YKDhnPByVAm5oy2LEBmMbuYf3ejWVYCc2seX81Mk+madA==",
"os": ["android"],
"cpu": ["arm"]
},
"@esbuild/android-arm@0.25.9": {
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
"os": ["android"],
"cpu": ["arm"]
},
"@esbuild/android-x64@0.25.7": {
"integrity": "sha512-mMxIJFlSgVK23HSsII3ZX9T2xKrBCDGyk0qiZnIW10LLFFtZLkFD6imZHu7gUo2wkNZwS9Yj3mOtZD3ZPcjCcw==",
"os": ["android"],
"cpu": ["x64"]
},
"@esbuild/android-x64@0.25.9": {
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
"os": ["android"],
"cpu": ["x64"]
},
"@esbuild/darwin-arm64@0.25.7": {
"integrity": "sha512-jyOFLGP2WwRwxM8F1VpP6gcdIJc8jq2CUrURbbTouJoRO7XCkU8GdnTDFIHdcifVBT45cJlOYsZ1kSlfbKjYUQ==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@esbuild/darwin-arm64@0.25.9": {
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@esbuild/darwin-x64@0.25.7": {
"integrity": "sha512-m9bVWqZCwQ1BthruifvG64hG03zzz9gE2r/vYAhztBna1/+qXiHyP9WgnyZqHgGeXoimJPhAmxfbeU+nMng6ZA==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@esbuild/darwin-x64@0.25.9": {
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@esbuild/freebsd-arm64@0.25.7": {
"integrity": "sha512-Bss7P4r6uhr3kDzRjPNEnTm/oIBdTPRNQuwaEFWT/uvt6A1YzK/yn5kcx5ZxZ9swOga7LqeYlu7bDIpDoS01bA==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
"@esbuild/freebsd-arm64@0.25.9": {
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
"@esbuild/freebsd-x64@0.25.7": {
"integrity": "sha512-S3BFyjW81LXG7Vqmr37ddbThrm3A84yE7ey/ERBlK9dIiaWgrjRlre3pbG7txh1Uaxz8N7wGGQXmC9zV+LIpBQ==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@esbuild/freebsd-x64@0.25.9": {
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@esbuild/linux-arm64@0.25.7": {
"integrity": "sha512-HfQZQqrNOfS1Okn7PcsGUqHymL1cWGBslf78dGvtrj8q7cN3FkapFgNA4l/a5lXDwr7BqP2BSO6mz9UremNPbg==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@esbuild/linux-arm64@0.25.9": {
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@esbuild/linux-arm@0.25.7": {
"integrity": "sha512-JZMIci/1m5vfQuhKoFXogCKVYVfYQmoZJg8vSIMR4TUXbF+0aNlfXH3DGFEFMElT8hOTUF5hisdZhnrZO/bkDw==",
"os": ["linux"],
"cpu": ["arm"]
},
"@esbuild/linux-arm@0.25.9": {
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
"os": ["linux"],
"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": {
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
"os": ["linux"],
"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": {
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
"os": ["linux"],
"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": {
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
"os": ["linux"],
"cpu": ["mips64el"]
},
"@esbuild/linux-ppc64@0.25.7": {
"integrity": "sha512-MrOjirGQWGReJl3BNQ58BLhUBPpWABnKrnq8Q/vZWWwAB1wuLXOIxS2JQ1LT3+5T+3jfPh0tyf5CpbyQHqnWIQ==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@esbuild/linux-ppc64@0.25.9": {
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@esbuild/linux-riscv64@0.25.7": {
"integrity": "sha512-9pr23/pqzyqIZEZmQXnFyqp3vpa+KBk5TotfkzGMqpw089PGm0AIowkUppHB9derQzqniGn3wVXgck19+oqiOw==",
"os": ["linux"],
"cpu": ["riscv64"]
},
"@esbuild/linux-riscv64@0.25.9": {
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
"os": ["linux"],
"cpu": ["riscv64"]
},
"@esbuild/linux-s390x@0.25.7": {
"integrity": "sha512-4dP11UVGh9O6Y47m8YvW8eoA3r8qL2toVZUbBKyGta8j6zdw1cn9F/Rt59/Mhv0OgY68pHIMjGXWOUaykCnx+w==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@esbuild/linux-s390x@0.25.9": {
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@esbuild/linux-x64@0.25.7": {
"integrity": "sha512-ghJMAJTdw/0uhz7e7YnpdX1xVn7VqA0GrWrAO2qKMuqbvgHT2VZiBv1BQ//VcHsPir4wsL3P2oPggfKPzTKoCA==",
"os": ["linux"],
"cpu": ["x64"]
},
"@esbuild/linux-x64@0.25.9": {
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
"os": ["linux"],
"cpu": ["x64"]
},
"@esbuild/netbsd-arm64@0.25.7": {
"integrity": "sha512-bwXGEU4ua45+u5Ci/a55B85KWaDSRS8NPOHtxy2e3etDjbz23wlry37Ffzapz69JAGGc4089TBo+dGzydQmydg==",
"os": ["netbsd"],
"cpu": ["arm64"]
},
"@esbuild/netbsd-arm64@0.25.9": {
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
"os": ["netbsd"],
"cpu": ["arm64"]
},
"@esbuild/netbsd-x64@0.25.7": {
"integrity": "sha512-tUZRvLtgLE5OyN46sPSYlgmHoBS5bx2URSrgZdW1L1teWPYVmXh+QN/sKDqkzBo/IHGcKcHLKDhBeVVkO7teEA==",
"os": ["netbsd"],
"cpu": ["x64"]
},
"@esbuild/netbsd-x64@0.25.9": {
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
"os": ["netbsd"],
"cpu": ["x64"]
},
"@esbuild/openbsd-arm64@0.25.7": {
"integrity": "sha512-bTJ50aoC+WDlDGBReWYiObpYvQfMjBNlKztqoNUL0iUkYtwLkBQQeEsTq/I1KyjsKA5tyov6VZaPb8UdD6ci6Q==",
"os": ["openbsd"],
"cpu": ["arm64"]
},
"@esbuild/openbsd-arm64@0.25.9": {
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
"os": ["openbsd"],
"cpu": ["arm64"]
},
"@esbuild/openbsd-x64@0.25.7": {
"integrity": "sha512-TA9XfJrgzAipFUU895jd9j2SyDh9bbNkK2I0gHcvqb/o84UeQkBpi/XmYX3cO1q/9hZokdcDqQxIi6uLVrikxg==",
"os": ["openbsd"],
"cpu": ["x64"]
},
"@esbuild/openbsd-x64@0.25.9": {
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
"os": ["openbsd"],
"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": {
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
"@esbuild/sunos-x64@0.25.7": {
"integrity": "sha512-umkbn7KTxsexhv2vuuJmj9kggd4AEtL32KodkJgfhNOHMPtQ55RexsaSrMb+0+jp9XL4I4o2y91PZauVN4cH3A==",
"os": ["sunos"],
"cpu": ["x64"]
},
"@esbuild/sunos-x64@0.25.9": {
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
"os": ["sunos"],
"cpu": ["x64"]
},
"@esbuild/win32-arm64@0.25.7": {
"integrity": "sha512-j20JQGP/gz8QDgzl5No5Gr4F6hurAZvtkFxAKhiv2X49yi/ih8ECK4Y35YnjlMogSKJk931iNMcd35BtZ4ghfw==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@esbuild/win32-arm64@0.25.9": {
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@esbuild/win32-ia32@0.25.7": {
"integrity": "sha512-4qZ6NUfoiiKZfLAXRsvFkA0hoWVM+1y2bSHXHkpdLAs/+r0LgwqYohmfZCi985c6JWHhiXP30mgZawn/XrqAkQ==",
"os": ["win32"],
"cpu": ["ia32"]
},
"@esbuild/win32-ia32@0.25.9": {
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
"os": ["win32"],
"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": {
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
"os": ["win32"],
@ -2128,32 +1999,32 @@ @@ -2128,32 +1999,32 @@
"esbuild@0.25.9": {
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
"optionalDependencies": [
"@esbuild/aix-ppc64@0.25.9",
"@esbuild/android-arm@0.25.9",
"@esbuild/android-arm64@0.25.9",
"@esbuild/android-x64@0.25.9",
"@esbuild/darwin-arm64@0.25.9",
"@esbuild/darwin-x64@0.25.9",
"@esbuild/freebsd-arm64@0.25.9",
"@esbuild/freebsd-x64@0.25.9",
"@esbuild/linux-arm@0.25.9",
"@esbuild/linux-arm64@0.25.9",
"@esbuild/linux-ia32@0.25.9",
"@esbuild/linux-loong64@0.25.9",
"@esbuild/linux-mips64el@0.25.9",
"@esbuild/linux-ppc64@0.25.9",
"@esbuild/linux-riscv64@0.25.9",
"@esbuild/linux-s390x@0.25.9",
"@esbuild/linux-x64@0.25.9",
"@esbuild/netbsd-arm64@0.25.9",
"@esbuild/netbsd-x64@0.25.9",
"@esbuild/openbsd-arm64@0.25.9",
"@esbuild/openbsd-x64@0.25.9",
"@esbuild/openharmony-arm64@0.25.9",
"@esbuild/sunos-x64@0.25.9",
"@esbuild/win32-arm64@0.25.9",
"@esbuild/win32-ia32@0.25.9",
"@esbuild/win32-x64@0.25.9"
"@esbuild/aix-ppc64",
"@esbuild/android-arm",
"@esbuild/android-arm64",
"@esbuild/android-x64",
"@esbuild/darwin-arm64",
"@esbuild/darwin-x64",
"@esbuild/freebsd-arm64",
"@esbuild/freebsd-x64",
"@esbuild/linux-arm",
"@esbuild/linux-arm64",
"@esbuild/linux-ia32",
"@esbuild/linux-loong64",
"@esbuild/linux-mips64el",
"@esbuild/linux-ppc64",
"@esbuild/linux-riscv64",
"@esbuild/linux-s390x",
"@esbuild/linux-x64",
"@esbuild/netbsd-arm64",
"@esbuild/netbsd-x64",
"@esbuild/openbsd-arm64",
"@esbuild/openbsd-x64",
"@esbuild/openharmony-arm64",
"@esbuild/sunos-x64",
"@esbuild/win32-arm64",
"@esbuild/win32-ia32",
"@esbuild/win32-x64"
],
"scripts": true,
"bin": true
@ -3650,6 +3521,9 @@ @@ -3650,6 +3521,9 @@
"wrappy@1.0.2": {
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"ws@8.18.3": {
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="
},
"y18n@4.0.3": {
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},

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

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
<script lang="ts">
/**
/**
* @fileoverview AEventPreview Component - Alexandria
*
* A card component for displaying nostr event previews with configurable display options.
@ -198,7 +198,7 @@ @@ -198,7 +198,7 @@
<Card
class="event-preview-card"
role="group"
tabindex="0"
tabindex={0}
aria-label="Event preview"
onclick={handleSelect}
onkeydown={handleKeydown}
@ -219,10 +219,7 @@ @@ -219,10 +219,7 @@
</span>
{/if}
{#if community}
<span
class="community-badge"
title="Has posted to the community"
>
<span class="community-badge" title="Has posted to the community">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<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"

88
src/lib/components/EventDetails.svelte

@ -41,7 +41,10 @@ @@ -41,7 +41,10 @@
let authorDisplayName = $state<string | undefined>(undefined);
let showFullContent = $state(false);
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 {
// First try to get title from title tag
@ -253,13 +256,15 @@ @@ -253,13 +256,15 @@
return;
}
getUserMetadata(toNpub(event.pubkey) as string, undefined).then((profile) => {
getUserMetadata(toNpub(event.pubkey) as string, undefined).then(
(profile) => {
authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
profile.name ||
event.pubkey;
});
},
);
});
// --- Identifier helpers ---
@ -300,7 +305,11 @@ @@ -300,7 +305,11 @@
ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` });
} catch {}
// 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;
}
@ -335,7 +344,7 @@ @@ -335,7 +344,7 @@
{#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400 min-w-0"
>Author: {@render userBadge(
toNpub(event.pubkey) || '',
toNpub(event.pubkey) || "",
profile?.display_name || undefined,
ndk,
)}</span
@ -357,7 +366,9 @@ @@ -357,7 +366,9 @@
<div class="flex flex-col space-y-1 min-w-0">
<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)}
</div>
</div>
@ -370,29 +381,41 @@ @@ -370,29 +381,41 @@
{#if event.kind !== 0}
{@const kind = event.kind}
{@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">
<span class="text-gray-700 dark:text-gray-300 font-semibold">Content:</span>
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}>
<span class="text-gray-700 dark:text-gray-300 font-semibold"
>Content:</span
>
<div class={shouldTruncate ? "max-h-32 overflow-hidden" : ""}>
{#if isRepost}
<!-- Repost content handling -->
{#if repostKinds.includes(event.kind)}
<!-- 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">
{event.kind === 6 ? 'Reposted content:' : 'Generic reposted content:'}
{event.kind === 6
? "Reposted content:"
: "Generic reposted content:"}
</div>
{@render repostContent(event.content)}
</div>
{:else if event.kind === 1 && event.getMatchingTags("q").length > 0}
<!-- 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">
Quote repost:
</div>
{@render quotedContent(event, [], ndk)}
{#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">
Added comment:
</div>
@ -407,7 +430,7 @@ @@ -407,7 +430,7 @@
{/if}
{:else}
<!-- Regular content -->
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}>
<div class={shouldTruncate ? "max-h-32 overflow-hidden" : ""}>
{#if repostKinds.includes(kind)}
{@html content}
{:else}
@ -428,35 +451,46 @@ @@ -428,35 +451,46 @@
<!-- If event is profile -->
{#if event.kind === 0}
<AProfilePreview event={event} profile={profile} communityStatusMap={communityStatusMap} />
<AProfilePreview {event} {profile} {communityStatusMap} />
{/if}
<ATechBlock>
{#snippet content()}
<Heading tag="h3" class="h-leather my-6">
Technical details
</Heading>
<Heading tag="h3" class="h-leather my-6">Technical details</Heading>
<Accordion flush class="w-full">
<AccordionItem open={false} >
<AccordionItem open={false}>
{#snippet header()}Identifiers{/snippet}
{#if event}
<div class="flex flex-col gap-2">
{#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">
<span class="min-w-24 text-gray-600 dark:text-gray-400">{identifier.label}:</span>
<div
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">
{#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"
onclick={() => navigateToIdentifier(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"
onclick={() =>
navigateToIdentifier(identifier.link ?? "")}
>
{identifier.value}
</button>
{: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}
</div>
<div class="justify-self-end">
<CopyToClipboard displayText="" copyText={identifier.value} />
<CopyToClipboard
displayText=""
copyText={identifier.value}
/>
</div>
</div>
{/each}
@ -495,7 +529,9 @@ @@ -495,7 +529,9 @@
</div>
{#if event}
<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>
{/if}
</AccordionItem>

108
src/lib/components/EventKindFilter.svelte

@ -1,41 +1,41 @@ @@ -1,41 +1,41 @@
<script lang="ts">
import { visualizationConfig, enabledEventKinds } from '$lib/stores/visualizationConfig';
import { Button, Badge } from 'flowbite-svelte';
import { CloseCircleOutline } from 'flowbite-svelte-icons';
import {
visualizationConfig,
enabledEventKinds,
} from "$lib/stores/visualizationConfig";
import { Button, Badge } from "flowbite-svelte";
import { CloseCircleOutline } from "flowbite-svelte-icons";
import type { EventCounts } from "$lib/types";
import { NostrKind } from '$lib/types';
import { NostrKind } from "$lib/types";
let {
onReload = () => {},
eventCounts = {}
} = $props<{
let { onReload = () => {}, eventCounts = {} } = $props<{
onReload?: () => void;
eventCounts?: EventCounts;
}>();
let newKind = $state('');
let newKind = $state("");
let showAddInput = $state(false);
let inputError = $state('');
let inputError = $state("");
function validateKind(value: string): number | null {
if (!value || value.trim() === '') {
inputError = '';
if (!value || value.trim() === "") {
inputError = "";
return null;
}
const kind = parseInt(value.trim());
if (isNaN(kind)) {
inputError = 'Must be a number';
inputError = "Must be a number";
return null;
}
if (kind < 0) {
inputError = 'Must be positive';
inputError = "Must be positive";
return null;
}
if ($visualizationConfig.eventConfigs.some(ec => ec.kind === kind)) {
inputError = 'Already added';
if ($visualizationConfig.eventConfigs.some((ec) => ec.kind === kind)) {
inputError = "Already added";
return null;
}
inputError = '';
inputError = "";
return kind;
}
@ -43,19 +43,19 @@ @@ -43,19 +43,19 @@
const kind = validateKind(newKind);
if (kind != null) {
visualizationConfig.addEventKind(kind);
newKind = '';
newKind = "";
showAddInput = false;
inputError = '';
inputError = "";
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
if (e.key === "Enter") {
handleAddKind();
} else if (e.key === 'Escape') {
} else if (e.key === "Escape") {
showAddInput = false;
newKind = '';
inputError = '';
newKind = "";
inputError = "";
}
}
@ -69,12 +69,18 @@ @@ -69,12 +69,18 @@
function getKindName(kind: number): string {
switch (kind) {
case NostrKind.PublicationIndex: return 'Publication Index';
case NostrKind.PublicationContent: return 'Publication Content';
case NostrKind.Wiki: return 'Wiki';
case NostrKind.TextNote: return 'Text Note';
case NostrKind.UserMetadata: return 'Metadata';
default: return `Kind ${kind}`;
case NostrKind.PublicationIndex:
return "Publication Index";
case NostrKind.PublicationContent:
return "Publication Content";
case NostrKind.Wiki:
return "Wiki";
case NostrKind.TextNote:
return "Text Note";
case NostrKind.UserMetadata:
return "Metadata";
default:
return `Kind ${kind}`;
}
}
</script>
@ -84,15 +90,21 @@ @@ -84,15 +90,21 @@
{#each $visualizationConfig.eventConfigs as ec}
{@const isEnabled = ec.enabled !== false}
{@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
class="badge-container {isEnabled ? '' : 'disabled'} {isLoaded ? 'loaded' : 'not-loaded'}"
class="badge-container {isEnabled ? '' : 'disabled'} {isLoaded
? 'loaded'
: 'not-loaded'}"
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
color="dark"
class="flex items-center gap-1 px-2 py-1 {isEnabled ? '' : 'opacity-40'} border-2 {borderColor}"
color="primary"
class="flex items-center gap-1 px-2 py-1 {isEnabled
? ''
: 'opacity-40'} border-2 {borderColor}"
>
<span class="text-xs">{ec.kind}</span>
{#if isLoaded}
@ -116,7 +128,7 @@ @@ -116,7 +128,7 @@
<Button
size="xs"
color="light"
onclick={() => showAddInput = true}
onclick={() => (showAddInput = true)}
class="gap-1"
>
<span>+</span>
@ -131,8 +143,19 @@ @@ -131,8 +143,19 @@
class="gap-1"
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">
<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
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>
<span>Reload</span>
</Button>
@ -151,16 +174,14 @@ @@ -151,16 +174,14 @@
validateKind(value);
}}
/>
<Button size="xs" onclick={handleAddKind} disabled={!newKind}>
Add
</Button>
<Button size="xs" onclick={handleAddKind} disabled={!newKind}>Add</Button>
<Button
size="xs"
color="light"
onclick={() => {
showAddInput = false;
newKind = '';
inputError = '';
newKind = "";
inputError = "";
}}
>
Cancel
@ -175,7 +196,8 @@ @@ -175,7 +196,8 @@
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<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>
</p>
<p class="flex items-center gap-2">

8
src/lib/components/Navigation.svelte

@ -20,12 +20,14 @@ @@ -20,12 +20,14 @@
<NavBrand href="/">
<div class="flex flex-col">
<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>
</NavBrand>
</div>
<div class="flex md:order-2">
<Profile isNav={true} />
<Profile />
<NavHamburger class="btn-leather" />
</div>
<NavUl class="ul-leather">
@ -40,7 +42,7 @@ @@ -40,7 +42,7 @@
<NavLi href="/about">About</NavLi>
<NavLi href="/contact">Contact</NavLi>
<NavLi>
<DarkMode btnClass="btn-leather p-0" />
<DarkMode class="btn-leather p-0" />
</NavLi>
</NavUl>
</Navbar>

17
src/lib/components/Preview.svelte

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

209
src/lib/components/ZettelEditor.svelte

@ -22,8 +22,11 @@ @@ -22,8 +22,11 @@
exportEventsFromTree,
} from "$lib/utils/asciidoc_publication_parser";
import { getNdkContext } from "$lib/ndk";
import Asciidoctor from "asciidoctor";
import { extractWikiLinks, renderWikiLinksToHtml } from "$lib/utils/wiki_links";
import Asciidoctor, { Document } from "asciidoctor";
import {
extractWikiLinks,
renderWikiLinksToHtml,
} from "$lib/utils/wiki_links";
// Initialize Asciidoctor processor
const asciidoctor = Asciidoctor();
@ -159,13 +162,6 @@ @@ -159,13 +162,6 @@
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)
const getEventDTag = (event: any) => {
if (event?.tagValue) {
@ -179,11 +175,16 @@ @@ -179,11 +175,16 @@
};
// 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) => {
const matchesDTag = getEventDTag(event) === dTag;
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;
@ -218,7 +219,11 @@ @@ -218,7 +219,11 @@
} else {
// contentEvents can contain both 30040 and 30041 events at parse level 3+
// 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)
@ -228,7 +233,6 @@ @@ -228,7 +233,6 @@
const titleTag = event?.tags.find((t: string[]) => t[0] === "title");
const eventTitle = titleTag ? titleTag[1] : node.title;
// For content events, remove the first heading from content since we'll use the title tag
let processedContent = event?.content || "";
if (event && node.eventType === "content") {
@ -237,8 +241,8 @@ @@ -237,8 +241,8 @@
// since the title is displayed separately from the "title" tag
const lines = processedContent.split("\n");
const expectedHeading = `${"=".repeat(node.level)} ${node.title}`;
const titleHeadingIndex = lines.findIndex((line: string) =>
line.trim() === expectedHeading.trim(),
const titleHeadingIndex = lines.findIndex(
(line: string) => line.trim() === expectedHeading.trim(),
);
if (titleHeadingIndex !== -1) {
// Remove only the specific title heading line
@ -247,7 +251,6 @@ @@ -247,7 +251,6 @@
}
}
return {
title: eventTitle,
content: processedContent,
@ -378,11 +381,11 @@ @@ -378,11 +381,11 @@
for (const link of wikiLinks) {
const className =
link.type === 'auto'
? 'cm-wiki-link-auto'
: link.type === 'w'
? 'cm-wiki-link-ref'
: 'cm-wiki-link-def';
link.type === "auto"
? "cm-wiki-link-auto"
: link.type === "w"
? "cm-wiki-link-ref"
: "cm-wiki-link-def";
ranges.push({
from: link.startIndex,
@ -730,14 +733,16 @@ @@ -730,14 +733,16 @@
".cm-wiki-link-auto": {
color: "var(--color-primary-700)", // [[term]] (auto) - medium leather
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",
borderRadius: "3px",
},
".cm-wiki-link-ref": {
color: "var(--color-primary-800)", // [[w:term]] (reference) - darker leather
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",
borderRadius: "3px",
},
@ -790,7 +795,10 @@ @@ -790,7 +795,10 @@
},
}),
// Override background and text to match preview (gray-800 bg, gray-100 text)
...(isDarkMode ? [EditorView.theme({
...(isDarkMode
? [
EditorView.theme(
{
"&": {
backgroundColor: "#1f2937",
color: "#f3f4f6",
@ -815,10 +823,15 @@ @@ -815,10 +823,15 @@
".cm-selectionBackground, ::selection": {
backgroundColor: "#374151 !important",
},
"&.cm-focused .cm-selectionBackground, &.cm-focused ::selection": {
"&.cm-focused .cm-selectionBackground, &.cm-focused ::selection":
{
backgroundColor: "#4b5563 !important",
},
}, { dark: true })] : []),
},
{ dark: true },
),
]
: []),
],
});
@ -847,12 +860,12 @@ @@ -847,12 +860,12 @@
// Mount CodeMirror when component mounts
onMount(() => {
// Initialize dark mode state
isDarkMode = document.documentElement.classList.contains('dark');
isDarkMode = document.documentElement.classList.contains("dark");
createEditor();
// Watch for dark mode changes
const observer = new MutationObserver(() => {
const newDarkMode = document.documentElement.classList.contains('dark');
const newDarkMode = document.documentElement.classList.contains("dark");
if (newDarkMode !== isDarkMode) {
isDarkMode = newDarkMode;
// Recreate editor with new theme
@ -876,7 +889,7 @@ @@ -876,7 +889,7 @@
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
attributeFilter: ["class"],
});
return () => {
@ -1042,7 +1055,9 @@ @@ -1042,7 +1055,9 @@
</h3>
</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">
{#if !content.trim()}
<div
@ -1093,8 +1108,12 @@ @@ -1093,8 +1108,12 @@
<!-- Tags and wiki links -->
{#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')}
{@const tTags = section.tags.filter(
(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">
@ -1156,8 +1175,12 @@ @@ -1156,8 +1175,12 @@
<!-- 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')}
{@const tTags = section.tags.filter(
(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">
@ -1198,7 +1221,9 @@ @@ -1198,7 +1221,9 @@
>
{@html (() => {
// Extract wiki links and replace with placeholders BEFORE Asciidoctor
const wikiLinks = extractWikiLinks(section.content);
const wikiLinks = extractWikiLinks(
section.content,
);
let contentWithPlaceholders = section.content;
const placeholders = new Map();
@ -1207,13 +1232,19 @@ @@ -1207,13 +1232,19 @@
const innerPlaceholder = `WIKILINK${index}PLACEHOLDER`;
const placeholder = `pass:[${innerPlaceholder}]`;
placeholders.set(innerPlaceholder, link); // Store by inner placeholder (what will remain after Asciidoctor)
contentWithPlaceholders = contentWithPlaceholders.replace(link.fullMatch, placeholder);
contentWithPlaceholders =
contentWithPlaceholders.replace(
link.fullMatch,
placeholder,
);
});
// Check if content contains nested headers
const hasNestedHeaders = contentWithPlaceholders.includes('\n===') || contentWithPlaceholders.includes('\n====');
const hasNestedHeaders =
contentWithPlaceholders.includes("\n===") ||
contentWithPlaceholders.includes("\n====");
let rendered;
let rendered: string | Document;
if (hasNestedHeaders) {
// For proper nested header parsing, we need full document context
// Create a complete AsciiDoc document structure
@ -1230,52 +1261,79 @@ @@ -1230,52 +1261,79 @@
// Extract just the content we want (remove the temporary structure)
// Find the section we care about
const sectionStart = rendered.indexOf(`<h${section.level}`);
const sectionStart = rendered
.toString()
.indexOf(`<h${section.level}`);
if (sectionStart !== -1) {
const nextSectionStart = rendered.indexOf(`</h${section.level}>`, sectionStart);
const nextSectionStart = rendered
.toString()
.indexOf(
`</h${section.level}>`,
sectionStart,
);
if (nextSectionStart !== -1) {
// Get everything after our section header
const afterHeader = rendered.substring(nextSectionStart + `</h${section.level}>`.length);
const afterHeader = rendered
.toString()
.substring(
nextSectionStart +
`</h${section.level}>`.length,
);
// Find where the section ends (at the closing div)
const sectionEnd = afterHeader.lastIndexOf('</div>');
const sectionEnd =
afterHeader.lastIndexOf("</div>");
if (sectionEnd !== -1) {
rendered = afterHeader.substring(0, sectionEnd);
rendered = afterHeader.substring(
0,
sectionEnd,
);
}
}
}
} else {
// Simple content without nested headers
rendered = asciidoctor.convert(contentWithPlaceholders, {
rendered = asciidoctor.convert(
contentWithPlaceholders,
{
standalone: false,
attributes: {
showtitle: false,
sectids: false,
},
});
},
);
}
// 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';
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)';
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);
const regex = new RegExp(
placeholder.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&",
),
"g",
);
rendered = rendered
.toString()
.replace(regex, html);
});
return rendered;
@ -1497,23 +1555,42 @@ Understanding the nature of knowledge... @@ -1497,23 +1555,42 @@ Understanding the nature of knowledge...
</p>
<ul class="space-y-2 text-xs">
<li>
<code 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>
<code
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>
<code 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>
<code
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>
<code 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>
<code
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 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>
</ul>
<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>
</div>
</div>
@ -1591,7 +1668,7 @@ Understanding the nature of knowledge... @@ -1591,7 +1668,7 @@ Understanding the nature of knowledge...
<!-- Hierarchical structure -->
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-3">
<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;">
{node.eventKind === 30040 ? "📁" : "📄"}
[{node.eventKind}] {node.title || "Untitled"}

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

@ -1,5 +1,9 @@ @@ -1,5 +1,9 @@
<script lang="ts">
import { getNdkContext, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import {
getNdkContext,
activeInboxRelays,
activeOutboxRelays,
} from "$lib/ndk";
import { pubkeyToHue } from "$lib/utils/nostrUtils";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
@ -12,11 +16,14 @@ @@ -12,11 +16,14 @@
encodeHighlightNaddr,
getRelaysFromHighlight,
getAuthorDisplayName,
sortHighlightsByTime
sortHighlightsByTime,
} from "$lib/utils/highlightUtils";
import { unifiedProfileCache } from "$lib/utils/npubCache";
import { nip19 } from "nostr-tools";
import { highlightByOffset, getPlainText } from "$lib/utils/highlightPositioning";
import {
highlightByOffset,
getPlainText,
} from "$lib/utils/highlightPositioning";
let {
eventId,
@ -47,7 +54,7 @@ @@ -47,7 +54,7 @@
// Derived state for color mapping
let colorMap = $derived.by(() => {
const map = new Map<string, string>();
highlights.forEach(highlight => {
highlights.forEach((highlight) => {
if (!map.has(highlight.pubkey)) {
const hue = pubkeyToHue(highlight.pubkey);
map.set(highlight.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
@ -73,8 +80,13 @@ @@ -73,8 +80,13 @@
}
// Collect all event IDs and addresses
const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(Boolean);
const allAddresses = [...(eventAddress ? [eventAddress] : []), ...eventAddresses].filter(Boolean);
const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(
Boolean,
);
const allAddresses = [
...(eventAddress ? [eventAddress] : []),
...eventAddresses,
].filter(Boolean);
if (allEventIds.length === 0 && allAddresses.length === 0) {
console.warn("[HighlightLayer] No event IDs or addresses provided");
@ -87,20 +99,29 @@ @@ -87,20 +99,29 @@
// AI-NOTE: Mock mode allows testing highlight UI without publishing to relays
// This is useful for development and demonstrating the highlight system
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 {
// Generate mock highlight data
const mockHighlights = generateMockHighlightsForSections(allAddresses);
// 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;
return;
} catch (err) {
console.error(`[HighlightLayer] Error generating mock highlights:`, err);
console.error(
`[HighlightLayer] Error generating mock highlights:`,
err,
);
loading = false;
return;
}
@ -108,7 +129,7 @@ @@ -108,7 +129,7 @@
console.log(`[HighlightLayer] Fetching highlights for:`, {
eventIds: allEventIds,
addresses: allAddresses
addresses: allAddresses,
});
try {
@ -128,7 +149,10 @@ @@ -128,7 +149,10 @@
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)
const relays = [
@ -137,7 +161,10 @@ @@ -137,7 +161,10 @@
...$activeInboxRelays,
];
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
@ -168,8 +195,11 @@ @@ -168,8 +195,11 @@
const message = JSON.parse(event.data);
// Log ALL messages from relay.nostr.band for debugging
if (relayUrl.includes('relay.nostr.band')) {
console.log(`[HighlightLayer] RAW message from ${relayUrl}:`, message);
if (relayUrl.includes("relay.nostr.band")) {
console.log(
`[HighlightLayer] RAW message from ${relayUrl}:`,
message,
);
}
if (message[0] === "EVENT" && message[1] === subscriptionId) {
@ -178,7 +208,7 @@ @@ -178,7 +208,7 @@
id: rawEvent.id,
kind: rawEvent.kind,
content: rawEvent.content.substring(0, 50),
tags: rawEvent.tags
tags: rawEvent.tags,
});
// Avoid duplicates
@ -188,11 +218,18 @@ @@ -188,11 +218,18 @@
// Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent);
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++;
console.log(`[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`);
console.log(
`[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`,
);
// Close subscription
ws.send(JSON.stringify(["CLOSE", subscriptionId]));
@ -200,10 +237,16 @@ @@ -200,10 +237,16 @@
WebSocketPool.instance.release(ws);
resolve();
} else if (message[0] === "NOTICE") {
console.warn(`[HighlightLayer] NOTICE from ${relayUrl}:`, message[1]);
console.warn(
`[HighlightLayer] NOTICE from ${relayUrl}:`,
message[1],
);
}
} catch (err) {
console.error(`[HighlightLayer] Error processing message from ${relayUrl}:`, err);
console.error(
`[HighlightLayer] Error processing message from ${relayUrl}:`,
err,
);
}
};
@ -211,8 +254,11 @@ @@ -211,8 +254,11 @@
// Send REQ
const req = ["REQ", subscriptionId, filter];
if (relayUrl.includes('relay.nostr.band')) {
console.log(`[HighlightLayer] Sending REQ to ${relayUrl}:`, JSON.stringify(req));
if (relayUrl.includes("relay.nostr.band")) {
console.log(
`[HighlightLayer] Sending REQ to ${relayUrl}:`,
JSON.stringify(req),
);
} else {
console.log(`[HighlightLayer] Sending REQ to ${relayUrl}`);
}
@ -229,7 +275,10 @@ @@ -229,7 +275,10 @@
}, 5000);
});
} catch (err) {
console.error(`[HighlightLayer] Error connecting to ${relayUrl}:`, err);
console.error(
`[HighlightLayer] Error connecting to ${relayUrl}:`,
err,
);
}
});
@ -239,17 +288,19 @@ @@ -239,17 +288,19 @@
console.log(`[HighlightLayer] Fetched ${highlights.length} highlights`);
if (highlights.length > 0) {
console.log(`[HighlightLayer] Highlights summary:`, highlights.map(h => ({
console.log(
`[HighlightLayer] Highlights summary:`,
highlights.map((h) => ({
content: h.content.substring(0, 30) + "...",
address: h.tags.find(t => t[0] === "a")?.[1],
author: h.pubkey.substring(0, 8)
})));
address: h.tags.find((t) => t[0] === "a")?.[1],
author: h.pubkey.substring(0, 8),
})),
);
}
loading = false;
// Rendering is handled by the visibility/highlights effect
} catch (err) {
console.error(`[HighlightLayer] Error fetching highlights:`, err);
loading = false;
@ -267,10 +318,12 @@ @@ -267,10 +318,12 @@
offsetStart: number,
offsetEnd: number,
color: string,
targetAddress?: string
targetAddress?: string,
): boolean {
if (!containerRef) {
console.log(`[HighlightLayer] Cannot highlight by position - no containerRef`);
console.log(
`[HighlightLayer] Cannot highlight by position - no containerRef`,
);
return false;
}
@ -280,17 +333,25 @@ @@ -280,17 +333,25 @@
const sectionElement = document.getElementById(targetAddress);
if (sectionElement) {
searchRoot = sectionElement;
console.log(`[HighlightLayer] Highlighting in specific section: ${targetAddress}`);
console.log(
`[HighlightLayer] Highlighting in specific section: ${targetAddress}`,
);
} 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);
if (result) {
console.log(`[HighlightLayer] Successfully applied position-based highlight`);
console.log(
`[HighlightLayer] Successfully applied position-based highlight`,
);
} else {
console.log(`[HighlightLayer] Failed to apply position-based highlight`);
}
@ -304,9 +365,15 @@ @@ -304,9 +365,15 @@
* @param color - The color to use for highlighting
* @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) {
console.log(`[HighlightLayer] Cannot highlight - containerRef: ${!!containerRef}, text: "${text}"`);
console.log(
`[HighlightLayer] Cannot highlight - containerRef: ${!!containerRef}, text: "${text}"`,
);
return;
}
@ -316,19 +383,26 @@ @@ -316,19 +383,26 @@
const sectionElement = document.getElementById(targetAddress);
if (sectionElement) {
searchRoot = sectionElement;
console.log(`[HighlightLayer] Searching in specific section: ${targetAddress}`);
console.log(
`[HighlightLayer] Searching in specific section: ${targetAddress}`,
);
} 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
const walker = document.createTreeWalker(
searchRoot,
NodeFilter.SHOW_TEXT,
null
null,
);
const textNodes: Node[] = [];
@ -338,19 +412,30 @@ @@ -338,19 +412,30 @@
}
// 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) {
const nodeText = textNode.textContent || "";
const index = nodeText.toLowerCase().indexOf(text.toLowerCase());
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;
if (!parent) continue;
// 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;
}
@ -386,10 +471,14 @@ @@ -386,10 +471,14 @@
* Render all highlights on the page
*/
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) {
console.log(`[HighlightLayer] Skipping render - visible: ${visible}, containerRef: ${!!containerRef}`);
console.log(
`[HighlightLayer] Skipping render - visible: ${visible}, containerRef: ${!!containerRef}`,
);
return;
}
@ -403,7 +492,10 @@ @@ -403,7 +492,10 @@
console.log(`[HighlightLayer] Rendering ${highlights.length} highlights`);
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
for (const highlight of highlights) {
@ -411,12 +503,13 @@ @@ -411,12 +503,13 @@
const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.3)";
// 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;
// Check for offset tags (position-based highlighting)
const offsetTag = highlight.tags.find(tag => tag[0] === "offset");
const hasOffset = offsetTag && offsetTag[1] !== undefined && offsetTag[2] !== undefined;
const offsetTag = highlight.tags.find((tag) => tag[0] === "offset");
const hasOffset =
offsetTag && offsetTag[1] !== undefined && offsetTag[2] !== undefined;
console.log(`[HighlightLayer] Rendering highlight:`, {
hasOffset,
@ -425,7 +518,7 @@ @@ -425,7 +518,7 @@
contentLength: content.length,
targetAddress,
color,
allTags: highlight.tags
allTags: highlight.tags,
});
if (hasOffset) {
@ -434,10 +527,14 @@ @@ -434,10 +527,14 @@
const offsetEnd = parseInt(offsetTag[2], 10);
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);
} 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) {
findAndHighlightText(content, color, targetAddress);
}
@ -455,7 +552,9 @@ @@ -455,7 +552,9 @@
// Check if any highlights were actually rendered
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 @@ @@ -465,7 +564,7 @@
if (!containerRef) return;
const highlightElements = containerRef.querySelectorAll("mark.highlight");
highlightElements.forEach(el => {
highlightElements.forEach((el) => {
const parent = el.parentNode;
if (parent) {
// Replace highlight with plain text
@ -477,7 +576,9 @@ @@ -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
@ -489,7 +590,9 @@ @@ -489,7 +590,9 @@
const currentCount = eventIds.length + eventAddresses.length;
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:
// 1. We have event data
@ -503,7 +606,9 @@ @@ -503,7 +606,9 @@
// Debounce: wait 500ms for more events to arrive before fetching
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;
fetchHighlights();
}, 500);
@ -521,10 +626,14 @@ @@ -521,10 +626,14 @@
$effect(() => {
// This effect runs when either visible or highlights.length changes
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) {
console.log(`[HighlightLayer] Both visible and highlights ready, rendering...`);
console.log(
`[HighlightLayer] Both visible and highlights ready, rendering...`,
);
renderHighlights();
} else if (!visible) {
clearHighlights();
@ -544,7 +653,9 @@ @@ -544,7 +653,9 @@
*/
async function fetchAuthorProfiles() {
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) {
try {
@ -557,7 +668,10 @@ @@ -557,7 +668,10 @@
authorProfiles = new Map(authorProfiles);
}
} catch (err) {
console.error(`[HighlightLayer] Error fetching profile for ${pubkey}:`, err);
console.error(
`[HighlightLayer] Error fetching profile for ${pubkey}:`,
err,
);
}
}
}
@ -579,7 +693,10 @@ @@ -579,7 +693,10 @@
* Scroll to a specific highlight in the document
*/
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) {
console.warn(`[HighlightLayer] No containerRef available`);
@ -594,7 +711,9 @@ @@ -594,7 +711,9 @@
// Find the highlight mark element
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
for (const mark of highlightMarks) {
@ -602,7 +721,9 @@ @@ -602,7 +721,9 @@
const searchText = content.toLowerCase();
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
mark.scrollIntoView({ behavior: "smooth", block: "center" });
@ -621,7 +742,9 @@ @@ -621,7 +742,9 @@
const searchText = content.toLowerCase();
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.classList.add("highlight-flash");
setTimeout(() => {
@ -631,7 +754,10 @@ @@ -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 @@ @@ -679,13 +805,19 @@
</script>
{#if loading && visible}
<div 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
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>
{/if}
{#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">
Highlights
</h4>
@ -707,19 +839,28 @@ @@ -707,19 +839,28 @@
class="w-3 h-3 rounded flex-shrink-0"
style="background-color: {color};"
></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}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
({authorHighlights.length})
</span>
<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"
stroke="currentColor"
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>
</button>
@ -727,14 +868,18 @@ @@ -727,14 +868,18 @@
{#if isExpanded}
<div class="mt-2 ml-5 space-y-2">
{#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}
<div class="flex items-start gap-2 group">
<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"
onclick={() => scrollToHighlight(highlight)}
title={useMockHighlights ? "Mock highlight" : highlight.content}
title={useMockHighlights
? "Mock highlight"
: highlight.content}
>
{truncated}
</button>
@ -744,12 +889,30 @@ @@ -744,12 +889,30 @@
title="Copy naddr"
>
{#if showCopied}
<svg 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
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>
{: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">
<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
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>
{/if}
</button>
@ -776,8 +939,9 @@ @@ -776,8 +939,9 @@
animation: flash 1.5s ease-in-out;
}
@keyframes :global(flash) {
0%, 100% {
@keyframes -global-flash {
0%,
100% {
filter: brightness(1);
}
50% {

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

@ -73,7 +73,7 @@ @@ -73,7 +73,7 @@
tags: tags,
content: selectedText,
id: "<calculated-on-signing>",
sig: "<calculated-on-signing>"
sig: "<calculated-on-signing>",
};
});
@ -110,7 +110,7 @@ @@ -110,7 +110,7 @@
address: sectionAddress,
eventId: sectionEventId,
allDataAttrs: publicationSection.dataset,
sectionId: publicationSection.id
sectionId: publicationSection.id,
});
currentSelection = selection;
@ -151,13 +151,14 @@ @@ -151,13 +151,14 @@
event.pubkey = $userStore.pubkey; // Set pubkey from user store
// 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;
console.log("[HighlightSelectionHandler] Creating highlight with:", {
address: useAddress,
eventId: useEventId,
fallbackUsed: !selectedSectionAddress
fallbackUsed: !selectedSectionAddress,
});
const tags: string[][] = [];
@ -202,7 +203,11 @@ @@ -202,7 +203,11 @@
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);
event.sig = signed.sig;
if ("id" in signed) {
@ -222,7 +227,10 @@ @@ -222,7 +227,10 @@
// Remove duplicates
const uniqueRelays = Array.from(new Set(relays));
console.log("[HighlightSelectionHandler] Publishing to relays:", uniqueRelays);
console.log(
"[HighlightSelectionHandler] Publishing to relays:",
uniqueRelays,
);
const signedEvent = {
...plainEvent,
@ -248,11 +256,15 @@ @@ -248,11 +256,15 @@
clearTimeout(timeout);
if (ok) {
publishedCount++;
console.log(`[HighlightSelectionHandler] Published to ${relayUrl}`);
console.log(
`[HighlightSelectionHandler] Published to ${relayUrl}`,
);
WebSocketPool.instance.release(ws);
resolve();
} else {
console.warn(`[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`);
console.warn(
`[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`,
);
WebSocketPool.instance.release(ws);
reject(new Error(message));
}
@ -263,7 +275,10 @@ @@ -263,7 +275,10 @@
ws.send(JSON.stringify(["EVENT", signedEvent]));
});
} catch (e) {
console.error(`[HighlightSelectionHandler] Failed to publish to ${relayUrl}:`, e);
console.error(
`[HighlightSelectionHandler] Failed to publish to ${relayUrl}:`,
e,
);
}
}
@ -271,7 +286,10 @@ @@ -271,7 +286,10 @@
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
if (currentSelection) {
@ -294,7 +312,10 @@ @@ -294,7 +312,10 @@
}
} catch (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 {
isSubmitting = false;
}
@ -349,11 +370,18 @@ @@ -349,11 +370,18 @@
</script>
{#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>
<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>
</div>
</div>
@ -366,16 +394,21 @@ @@ -366,16 +394,21 @@
id="comment"
bind:value={comment}
placeholder="Share your thoughts about this highlight..."
rows="3"
rows={3}
class="w-full"
/>
</div>
<!-- JSON Preview Section -->
{#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>
<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>
{/if}
@ -383,7 +416,7 @@ @@ -383,7 +416,7 @@
<Button
color="light"
size="sm"
onclick={() => showJsonPreview = !showJsonPreview}
onclick={() => (showJsonPreview = !showJsonPreview)}
class="flex items-center gap-1"
>
{#if showJsonPreview}
@ -395,10 +428,18 @@ @@ -395,10 +428,18 @@
</Button>
<div class="flex space-x-2">
<Button color="alternative" onclick={cancelHighlight} disabled={isSubmitting}>
<Button
color="alternative"
onclick={cancelHighlight}
disabled={isSubmitting}
>
Cancel
</Button>
<Button color="primary" onclick={createHighlight} disabled={isSubmitting}>
<Button
color="primary"
onclick={createHighlight}
disabled={isSubmitting}
>
{isSubmitting ? "Creating..." : "Create Highlight"}
</Button>
</div>
@ -409,7 +450,9 @@ @@ -409,7 +450,9 @@
{#if showFeedback}
<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-red-500 text-white'}"
>

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

@ -7,7 +7,8 @@ @@ -7,7 +7,8 @@
SidebarGroup,
SidebarWrapper,
Heading,
CloseButton, uiHelpers
CloseButton,
uiHelpers,
} from "flowbite-svelte";
import { getContext, onDestroy, onMount } from "svelte";
import {
@ -37,7 +38,8 @@ @@ -37,7 +38,8 @@
import { Textarea, P } from "flowbite-svelte";
import { userStore } from "$lib/stores/userStore";
let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{
let { rootAddress, publicationType, indexEvent, publicationTree, toc } =
$props<{
rootAddress: string;
publicationType: string;
indexEvent: NDKEvent;
@ -64,23 +66,25 @@ @@ -64,23 +66,25 @@
// 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
let useMockComments = $state(import.meta.env.VITE_USE_MOCK_COMMENTS === "true");
let useMockHighlights = $state(import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true");
let useMockComments = $state(
import.meta.env.VITE_USE_MOCK_COMMENTS === "true",
);
let useMockHighlights = $state(
import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true",
);
// Log initial state for debugging
console.log('[Publication] Mock data initialized:', {
useMockComments,
useMockHighlights,
console.log("[Publication] Mock data initialized:", {
envVars: {
VITE_USE_MOCK_COMMENTS: import.meta.env.VITE_USE_MOCK_COMMENTS,
VITE_USE_MOCK_HIGHLIGHTS: import.meta.env.VITE_USE_MOCK_HIGHLIGHTS,
}
},
});
// Derive all event IDs and addresses for highlight fetching
let allEventIds = $derived.by(() => {
const ids = [indexEvent.id];
leaves.forEach(leaf => {
leaves.forEach((leaf) => {
if (leaf?.id) ids.push(leaf.id);
});
return ids;
@ -88,7 +92,7 @@ @@ -88,7 +92,7 @@
let allEventAddresses = $derived.by(() => {
const addresses = [rootAddress];
leaves.forEach(leaf => {
leaves.forEach((leaf) => {
if (leaf) {
const addr = leaf.tagAddress();
if (addr) addresses.push(addr);
@ -99,11 +103,11 @@ @@ -99,11 +103,11 @@
// Filter comments for the root publication (kind 30040)
let articleComments = $derived(
comments.filter(comment => {
comments.filter((comment) => {
// 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;
})
}),
);
// #region Loading
@ -125,7 +129,9 @@ @@ -125,7 +129,9 @@
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;
@ -159,7 +165,9 @@ @@ -159,7 +165,9 @@
console.error("[Publication] Error loading more content:", error);
} finally {
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 @@ @@ -198,7 +206,7 @@
hasInitialized = false;
// Reset the publication tree iterator to prevent duplicate events
if (typeof publicationTree.resetIterator === 'function') {
if (typeof publicationTree.resetIterator === "function") {
publicationTree.resetIterator();
}
@ -298,7 +306,9 @@ @@ -298,7 +306,9 @@
const kind = parseInt(kindStr);
// 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.content = articleCommentContent;
@ -330,10 +340,10 @@ @@ -330,10 +340,10 @@
articleCommentSuccess = false;
handleCommentPosted();
}, 1500);
} catch (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 {
isSubmittingArticleComment = false;
}
@ -344,18 +354,22 @@ @@ -344,18 +354,22 @@
*/
async function handleDeletePublication() {
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;
try {
await deleteEvent({
await deleteEvent(
{
eventAddress: indexEvent.tagAddress(),
eventKind: indexEvent.kind,
reason: "User deleted publication",
onSuccess: (deletionEventId) => {
console.log("[Publication] Deletion event published:", deletionEventId);
console.log(
"[Publication] Deletion event published:",
deletionEventId,
);
publicationDeleted = true;
// Redirect after 2 seconds
@ -367,7 +381,9 @@ @@ -367,7 +381,9 @@
console.error("[Publication] Failed to delete publication:", error);
alert(`Failed to delete publication: ${error}`);
},
});
},
ndk,
);
} catch (error) {
console.error("[Publication] Error deleting publication:", error);
alert(`Error: ${error}`);
@ -422,7 +438,12 @@ @@ -422,7 +438,12 @@
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading && !isDone && publicationTree) {
if (
entry.isIntersecting &&
!isLoading &&
!isDone &&
publicationTree
) {
loadMore(1);
}
});
@ -450,14 +471,11 @@ @@ -450,14 +471,11 @@
</script>
<!-- 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 -->
<ArticleNav
publicationType={publicationType}
rootId={indexEvent.id}
indexEvent={indexEvent}
/>
<ArticleNav {publicationType} rootId={indexEvent.id} {indexEvent} />
<!-- Highlight selection handler -->
<HighlightSelectionHandler
@ -477,43 +495,53 @@ @@ -477,43 +495,53 @@
<!-- Three-column row -->
<div class="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 $publicationColumnVisibility.toc}
<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"
activeUrl={`#${activeAddress ?? ""}`}
classes={{
div: 'dark:bg-primary-900 bg-primary-50',
active: 'bg-primary-100 dark:bg-primary-800 p-2 rounded-lg',
nonactive: 'bg-primary-50 dark:bg-primary-900',
div: "dark:bg-primary-900 bg-primary-50",
active: "bg-primary-100 dark:bg-primary-800 p-2 rounded-lg",
nonactive: "bg-primary-50 dark:bg-primary-900",
}}
>
<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
{rootAddress}
{toc}
depth={2}
onSectionFocused={(address: string) => publicationTree.setBookmark(address)}
onSectionFocused={(address: string) =>
publicationTree.setBookmark(address)}
onLoadMore={() => {
if (!isLoading && !isDone && publicationTree) {
loadMore(4);
}
}}
/>
</SidebarWrapper>
</Sidebar>
{/if}
{/if}
</div>
<div class="mt-[70px]">
<!-- Default publications -->
{#if $publicationColumnVisibility.main}
<!-- 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) -->
<div class="relative">
<!-- Main header content - centered -->
@ -521,7 +549,10 @@ @@ -521,7 +549,10 @@
<div
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>
{#if publicationDeleted}
@ -542,7 +573,9 @@ @@ -542,7 +573,9 @@
</div>
<!-- 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
sectionAddress={rootAddress}
comments={articleComments}
@ -557,18 +590,14 @@ @@ -557,18 +590,14 @@
<Button
color="light"
size="sm"
onclick={() => showArticleCommentUI = !showArticleCommentUI}
onclick={() => (showArticleCommentUI = !showArticleCommentUI)}
>
{showArticleCommentUI ? 'Close Comment' : 'Comment On Article'}
{showArticleCommentUI ? "Close Comment" : "Comment On Article"}
</Button>
<HighlightButton bind:isActive={highlightModeActive} />
</div>
<div class="flex gap-2">
<Button
color="light"
size="sm"
onclick={toggleComments}
>
<Button color="light" size="sm" onclick={toggleComments}>
{#if commentsVisible}
<EyeSlashOutline class="w-4 h-4 mr-2" />
Hide Comments
@ -577,11 +606,7 @@ @@ -577,11 +606,7 @@
Show Comments
{/if}
</Button>
<Button
color="light"
size="sm"
onclick={toggleHighlights}
>
<Button color="light" size="sm" onclick={toggleHighlights}>
{#if highlightsVisible}
<EyeSlashOutline class="w-4 h-4 mr-2" />
Hide Highlights
@ -595,9 +620,13 @@ @@ -595,9 +620,13 @@
<!-- Article Comment UI -->
{#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">
<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
bind:value={articleCommentContent}
@ -607,18 +636,28 @@ @@ -607,18 +636,28 @@
/>
{#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 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}
<div class="flex gap-2">
<Button onclick={submitArticleComment} disabled={isSubmittingArticleComment}>
{isSubmittingArticleComment ? 'Posting...' : 'Post Comment'}
<Button
onclick={submitArticleComment}
disabled={isSubmittingArticleComment}
>
{isSubmittingArticleComment ? "Posting..." : "Post Comment"}
</Button>
<Button color="light" onclick={() => showArticleCommentUI = false}>
<Button
color="light"
onclick={() => (showArticleCommentUI = false)}
>
Cancel
</Button>
</div>
@ -651,7 +690,9 @@ @@ -651,7 +690,9 @@
{#if isLoading}
<Button disabled color="primary">Loading...</Button>
{:else if !isDone}
<Button color="primary" onclick={() => loadMore(1)}>Show More</Button>
<Button color="primary" onclick={() => loadMore(1)}
>Show More</Button
>
{:else}
<p class="text-gray-500 dark:text-gray-400">
You've reached the end of the publication.
@ -696,19 +737,24 @@ @@ -696,19 +737,24 @@
{/if}
</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 -->
{#if $publicationColumnVisibility.discussion}
<Sidebar
class="z-10 ml-4 fixed top-[162px] h-[calc(100vh-165px)] overflow-y-auto"
classes={{
div: 'bg-transparent'
div: "bg-transparent",
}}
>
<SidebarWrapper>
<SidebarGroup>
<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"
outline
@ -737,7 +783,9 @@ @@ -737,7 +783,9 @@
visible={commentsVisible}
/>
{#if articleComments.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
<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}
@ -757,7 +805,7 @@ @@ -757,7 +805,7 @@
eventIds={allEventIds}
eventAddresses={allEventAddresses}
bind:visible={highlightsVisible}
useMockHighlights={useMockHighlights}
{useMockHighlights}
/>
<!-- Comment Layer Component -->
@ -765,7 +813,6 @@ @@ -765,7 +813,6 @@
bind:this={commentLayerRef}
eventIds={allEventIds}
eventAddresses={allEventAddresses}
bind:comments={comments}
useMockComments={useMockComments}
bind:comments
{useMockComments}
/>

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

@ -42,11 +42,11 @@ @@ -42,11 +42,11 @@
// Filter comments for this section
let sectionComments = $derived(
allComments.filter(comment => {
allComments.filter((comment) => {
// 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;
})
}),
);
let leafEvent: Promise<NDKEvent | null> = $derived.by(
@ -56,10 +56,13 @@ @@ -56,10 +56,13 @@
let leafEventId = $state<string>("");
$effect(() => {
leafEvent.then(e => {
leafEvent.then((e) => {
if (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 @@ @@ -91,7 +94,10 @@
} else {
// For 30041 and 30818 events, use Asciidoctor (AsciiDoc)
const converted = asciidoctor.convert(content);
const processed = await postProcessAdvancedAsciidoctorHtml(converted.toString(), ndk);
const processed = await postProcessAdvancedAsciidoctorHtml(
converted.toString(),
ndk,
);
return processed;
}
});
@ -169,18 +175,22 @@ @@ -169,18 +175,22 @@
if (!event) return;
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;
try {
await deleteEvent({
await deleteEvent(
{
eventAddress: address,
eventKind: event.kind,
reason: "User deleted section",
onSuccess: (deletionEventId) => {
console.log("[PublicationSection] Deletion event published:", deletionEventId);
console.log(
"[PublicationSection] Deletion event published:",
deletionEventId,
);
// Refresh the page to reflect the deletion
window.location.reload();
},
@ -188,7 +198,9 @@ @@ -188,7 +198,9 @@
console.error("[PublicationSection] Deletion failed:", error);
alert(`Failed to delete section: ${error}`);
},
}, ndk);
},
ndk,
);
} catch (error) {
console.error("[PublicationSection] Deletion error:", error);
}
@ -206,7 +218,7 @@ @@ -206,7 +218,7 @@
address,
leafEventId,
dataAddress: sectionRef.dataset.eventAddress,
dataEventId: sectionRef.dataset.eventId
dataEventId: sectionRef.dataset.eventId,
});
});
</script>
@ -221,7 +233,7 @@ @@ -221,7 +233,7 @@
data-event-id={leafEventId}
>
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )}
<TextPlaceholder size="xxl" />
<TextPlaceholder size="2xl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
<!-- Main content area - centered -->
<div class="section-content relative max-w-4xl mx-auto px-4">
@ -229,7 +241,11 @@ @@ -229,7 +241,11 @@
<div class="xl:hidden absolute top-2 right-2 z-10">
{#await leafEvent then event}
{#if event}
<CardActions {event} sectionAddress={address} onDelete={handleDelete} />
<CardActions
{event}
sectionAddress={address}
onDelete={handleDelete}
/>
{/if}
{/await}
</div>
@ -265,14 +281,18 @@ @@ -265,14 +281,18 @@
{#await leafEvent then event}
{#if event}
<!-- 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} />
</div>
{/if}
{/await}
<!-- 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
sectionAddress={address}
comments={sectionComments}

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

@ -12,7 +12,11 @@ @@ -12,7 +12,11 @@
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
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 { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
@ -104,19 +108,20 @@ @@ -104,19 +108,20 @@
],
content: commentContent,
id: "<calculated-on-signing>",
sig: "<calculated-on-signing>"
sig: "<calculated-on-signing>",
};
});
// Check if user can delete this event (must be the author)
let canDelete = $derived.by(() => {
const result = user.signedIn && user.pubkey === event.pubkey && onDelete !== undefined;
console.log('[CardActions] canDelete check:', {
const result =
user.signedIn && user.pubkey === event.pubkey && onDelete !== undefined;
console.log("[CardActions] canDelete check:", {
userSignedIn: user.signedIn,
userPubkey: user.pubkey,
eventPubkey: event.pubkey,
onDeleteProvided: onDelete !== undefined,
canDelete: result
canDelete: result,
});
return result;
});
@ -221,7 +226,9 @@ @@ -221,7 +226,9 @@
/**
* 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(":");
if (parts.length !== 3) {
console.error("[CardActions] Invalid address format:", address);
@ -301,12 +308,18 @@ @@ -301,12 +308,18 @@
const plainEvent = {
kind: Number(commentEvent.kind),
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)),
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);
commentEvent.sig = signed.sig;
if ("id" in signed) {
@ -373,10 +386,10 @@ @@ -373,10 +386,10 @@
commentContent = "";
showJsonPreview = false;
}, 2000);
} catch (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 {
isSubmittingComment = false;
}
@ -404,7 +417,7 @@ @@ -404,7 +417,7 @@
type="button"
id="dots-{event.id}"
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"
>
<DotsVerticalOutline class="h-6 w-6" />
@ -463,7 +476,8 @@ @@ -463,7 +476,8 @@
onDelete?.();
}}
>
<TrashBinOutline class="inline mr-2" /> {deleteButtonText}
<TrashBinOutline class="inline mr-2" />
{deleteButtonText}
</button>
</li>
{/if}
@ -570,7 +584,9 @@ @@ -570,7 +584,9 @@
>
<div class="space-y-4">
{#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}
<img
src={user.profile.picture}
@ -597,14 +613,21 @@ @@ -597,14 +613,21 @@
{/if}
{#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}
<!-- JSON Preview Section -->
{#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>
<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>
{/if}
@ -612,7 +635,7 @@ @@ -612,7 +635,7 @@
<Button
color="light"
size="sm"
onclick={() => showJsonPreview = !showJsonPreview}
onclick={() => (showJsonPreview = !showJsonPreview)}
class="flex items-center gap-1"
>
{#if showJsonPreview}

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

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

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

@ -1,10 +1,5 @@ @@ -1,10 +1,5 @@
<script lang="ts">
import {
Heading,
Textarea,
Toolbar,
ToolbarButton,
} from "flowbite-svelte";
import { Heading, Textarea, Toolbar, ToolbarButton } from "flowbite-svelte";
import {
CodeOutline,
EyeSolid,
@ -72,29 +67,37 @@ @@ -72,29 +67,37 @@
placeholder="Write AsciiDoc content"
bind:value={editorText}
>
<Toolbar slot="header" embedded>
<!-- MichaelJ 12-04-2025 - This `Toolbar` construct may be invalid with the current version of Flowbite Svelte -->
{#snippet header()}
<Toolbar embedded>
<ToolbarButton name="Preview" onclick={showPreview}>
<EyeSolid class="w-6 h-6" />
</ToolbarButton>
<ToolbarButton name="Review" slot="end" onclick={prepareReview}>
{#snippet end()}
<ToolbarButton name="Review" onclick={prepareReview}>
<PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton>
{/snippet}
</Toolbar>
{/snippet}
</Textarea>
</form>
{:else}
<form
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
class="toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800"
>
<ToolbarButton name="Edit" onclick={hidePreview}>
<CodeOutline class="w-6 h-6" />
</ToolbarButton>
<ToolbarButton name="Review" slot="end" onclick={prepareReview}>
{#snippet end()}
<ToolbarButton name="Review" onclick={prepareReview}>
<PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton>
{/snippet}
</Toolbar>
{#if rootIndexId}
<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 @@ @@ -1,8 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { deleteEvent, canDeleteEvent } from '$lib/services/deletion';
import NDK, { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk';
describe('Deletion Service', () => {
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canDeleteEvent,
deleteEvent,
} from "../../src/lib/services/deletion.ts";
import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
describe("Deletion Service", () => {
let mockNdk: NDK;
let mockEvent: NDKEvent;
@ -10,47 +13,47 @@ describe('Deletion Service', () => { @@ -10,47 +13,47 @@ describe('Deletion Service', () => {
// Create mock NDK instance
mockNdk = {
activeUser: {
pubkey: 'test-pubkey-123',
pubkey: "test-pubkey-123",
},
pool: {
relays: new Map([
['wss://relay1.example.com', { url: 'wss://relay1.example.com' }],
['wss://relay2.example.com', { url: 'wss://relay2.example.com' }],
["wss://relay1.example.com", { url: "wss://relay1.example.com" }],
["wss://relay2.example.com", { url: "wss://relay2.example.com" }],
]),
},
} as unknown as NDK;
// Create mock event
mockEvent = {
id: 'event-id-123',
id: "event-id-123",
kind: 30041,
pubkey: 'test-pubkey-123',
tagAddress: () => '30041:test-pubkey-123:test-identifier',
pubkey: "test-pubkey-123",
tagAddress: () => "30041:test-pubkey-123:test-identifier",
} as unknown as NDKEvent;
});
describe('canDeleteEvent', () => {
it('should return true when user is the event author', () => {
describe("canDeleteEvent", () => {
it("should return true when user is the event author", () => {
const result = canDeleteEvent(mockEvent, mockNdk);
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 = {
...mockEvent,
pubkey: 'different-pubkey-456',
pubkey: "different-pubkey-456",
} as unknown as NDKEvent;
const result = canDeleteEvent(differentUserEvent, mockNdk);
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);
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 = {
...mockNdk,
activeUser: undefined,
@ -61,40 +64,44 @@ describe('Deletion Service', () => { @@ -61,40 +64,44 @@ describe('Deletion Service', () => {
});
});
describe('deleteEvent', () => {
it('should return error when no eventId or eventAddress provided', async () => {
describe("deleteEvent", () => {
it("should return error when no eventId or eventAddress provided", async () => {
const result = await deleteEvent({}, mockNdk);
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 = {
...mockNdk,
activeUser: undefined,
} as unknown as NDK;
const result = await deleteEvent(
{ eventId: 'test-id' },
ndkWithoutUser
{ eventId: "test-id" },
ndkWithoutUser,
);
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 mockPublish = vi.fn().mockResolvedValue(new Set(['wss://relay1.example.com']));
const mockPublish = vi.fn().mockResolvedValue(
new Set(["wss://relay1.example.com"]),
);
// Mock NDKEvent constructor
const MockNDKEvent = vi.fn().mockImplementation(function(this: any) {
const MockNDKEvent = vi.fn().mockImplementation(function (this: any) {
this.kind = 0;
this.created_at = 0;
this.tags = [];
this.content = '';
this.pubkey = '';
this.content = "";
this.pubkey = "";
this.sign = mockSign;
this.publish = mockPublish;
return this;
@ -102,20 +109,20 @@ describe('Deletion Service', () => { @@ -102,20 +109,20 @@ describe('Deletion Service', () => {
// Mock NDKRelaySet
const mockRelaySet = {} as NDKRelaySet;
vi.spyOn(NDKRelaySet, 'fromRelayUrls').mockReturnValue(mockRelaySet);
vi.spyOn(NDKRelaySet, "fromRelayUrls").mockReturnValue(mockRelaySet);
// Replace global NDKEvent temporarily
const originalNDKEvent = global.NDKEvent;
const originalNDKEvent = (globalThis as any).NDKEvent;
(global as any).NDKEvent = MockNDKEvent;
const result = await deleteEvent(
{
eventId: 'event-123',
eventAddress: '30041:pubkey:identifier',
eventId: "event-123",
eventAddress: "30041:pubkey:identifier",
eventKind: 30041,
reason: 'Test deletion',
reason: "Test deletion",
},
mockNdk
mockNdk,
);
// Restore original

52
tests/unit/fetchPublicationHighlights.test.ts

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

36
tests/unit/highlightSelection.test.ts

@ -1,11 +1,12 @@ @@ -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 type NDK from "@nostr-dev-kit/ndk";
// Mock flowbite-svelte components
vi.mock("flowbite-svelte", () => ({
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(() => ({
$$render: () => `<div data-testid="modal"></div>`,
@ -277,11 +278,14 @@ describe("HighlightSelectionHandler Component Logic", () => { @@ -277,11 +278,14 @@ describe("HighlightSelectionHandler Component Logic", () => {
describe("Context Extraction", () => {
it("should extract context from parent 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() || "";
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", () => {
@ -654,7 +658,10 @@ describe("HighlightSelectionHandler Component Logic", () => { @@ -654,7 +658,10 @@ describe("HighlightSelectionHandler Component Logic", () => {
document.addEventListener = mockAddEventListener;
document.addEventListener("mouseup", () => {});
expect(mockAddEventListener).toHaveBeenCalledWith("mouseup", expect.any(Function));
expect(mockAddEventListener).toHaveBeenCalledWith(
"mouseup",
expect.any(Function),
);
});
it("should remove mouseup listener on unmount", () => {
@ -689,7 +696,9 @@ describe("HighlightSelectionHandler Component Logic", () => { @@ -689,7 +696,9 @@ describe("HighlightSelectionHandler Component Logic", () => {
// Simulate inactive mode
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", () => {
@ -701,7 +710,9 @@ describe("HighlightSelectionHandler Component Logic", () => { @@ -701,7 +710,9 @@ describe("HighlightSelectionHandler Component Logic", () => {
// Simulate cleanup
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", () => { @@ -767,17 +778,6 @@ describe("HighlightSelectionHandler Component Logic", () => {
// Simulate failed creation - callback not called
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", () => {

Loading…
Cancel
Save