Browse Source

advanced markup editing (asciidoc and markdown)

publishing utilities
refined search
master
Silberengel 1 month ago
parent
commit
d55085d02e
  1. 395
      package-lock.json
  2. 11
      package.json
  3. 4
      public/healthz.json
  4. 297
      src/lib/components/find/SearchAddressableEvents.svelte
  5. 255
      src/lib/components/layout/UnifiedSearch.svelte
  6. 378
      src/lib/components/write/AdvancedEditor.svelte
  7. 453
      src/lib/components/write/CreateEventForm.svelte
  8. 95
      src/lib/components/write/EditEventForm.svelte
  9. 12
      src/lib/modules/comments/CommentForm.svelte
  10. 420
      src/lib/types/kind-metadata.ts
  11. 124
      src/lib/utils/nostr-link-processor.ts
  12. 233
      src/routes/find/+page.svelte

395
package-lock.json generated

@ -9,10 +9,21 @@ @@ -9,10 +9,21 @@
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/basic-setup": "^0.20.0",
"@codemirror/commands": "^6.10.1",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.1",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.12",
"@lezer/markdown": "^1.6.3",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"@tanstack/svelte-virtual": "^3.0.0",
"asciidoctor": "3.0.x",
"codemirror-asciidoc": "^2.0.1",
"dompurify": "^3.0.6",
"emoji-picker-element": "^1.28.1",
"highlight.js": "^11.11.1",
@ -178,6 +189,293 @@ @@ -178,6 +189,293 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/basic-setup": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/basic-setup/-/basic-setup-0.20.0.tgz",
"integrity": "sha512-W/ERKMLErWkrVLyP5I8Yh8PXl4r+WFNkdYVSzkXYPQv2RMPSkWpr2BgggiSJ8AHF/q3GuApncDD8I4BZz65fyg==",
"deprecated": "In version 6.0, this package has been renamed to just 'codemirror'",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^0.20.0",
"@codemirror/commands": "^0.20.0",
"@codemirror/language": "^0.20.0",
"@codemirror/lint": "^0.20.0",
"@codemirror/search": "^0.20.0",
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0"
}
},
"node_modules/@codemirror/basic-setup/node_modules/@codemirror/autocomplete": {
"version": "0.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-0.20.3.tgz",
"integrity": "sha512-lYB+NPGP+LEzAudkWhLfMxhTrxtLILGl938w+RcFrGdrIc54A+UgmCoz+McE3IYRFp4xyQcL4uFJwo+93YdgHw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^0.20.0",
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/basic-setup/node_modules/@codemirror/commands": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-0.20.0.tgz",
"integrity": "sha512-v9L5NNVA+A9R6zaFvaTbxs30kc69F6BkOoiEbeFw4m4I0exmDEKBILN6mK+GksJtvTzGBxvhAPlVFTdQW8GB7Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^0.20.0",
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/basic-setup/node_modules/@codemirror/language": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-0.20.2.tgz",
"integrity": "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0",
"@lezer/highlight": "^0.16.0",
"@lezer/lr": "^0.16.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/basic-setup/node_modules/@codemirror/search": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-0.20.1.tgz",
"integrity": "sha512-ROe6gRboQU5E4z6GAkNa2kxhXqsGNbeLEisbvzbOeB7nuDYXUZ70vGIgmqPu0tB+1M3F9yWk6W8k2vrFpJaD4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/basic-setup/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/basic-setup/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/basic-setup/node_modules/@lezer/common": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-0.16.1.tgz",
"integrity": "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA==",
"license": "MIT"
},
"node_modules/@codemirror/basic-setup/node_modules/@lezer/highlight": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-0.16.0.tgz",
"integrity": "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/basic-setup/node_modules/@lezer/lr": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-0.16.3.tgz",
"integrity": "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz",
"integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript/node_modules/@codemirror/lint": {
"version": "6.9.3",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.3.tgz",
"integrity": "sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "0.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-0.20.3.tgz",
"integrity": "sha512-06xUScbbspZ8mKoODQCEx6hz1bjaq9m8W8DxdycWARMiiX1wMtfCh/MoHpaL7ws/KUMwlsFFfp2qhm32oaCvVA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.2",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/lint/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/lint/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.39.12",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.12.tgz",
"integrity": "sha512-f+/VsHVn/kOA9lltk/GFzuYwVVAKmOnNjxbrhkk3tPHntFqjWeI2TbIXx006YkBkqC10wZ4NsnWXCQiFPeAISQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
@ -772,6 +1070,79 @@ @@ -772,6 +1070,79 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
"integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
@ -2077,6 +2448,12 @@ @@ -2077,6 +2448,12 @@
"node": ">=6"
}
},
"node_modules/codemirror-asciidoc": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/codemirror-asciidoc/-/codemirror-asciidoc-2.0.1.tgz",
"integrity": "sha512-h6Xhj+ZsWh/DTNE3xMfRv9edufchsVVwPED7wSGMeEdoYk/UtCZmwRGH0ZZQkr43aNVF3tWGLZJGT+cAeYgUIg==",
"license": "BSD"
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2131,6 +2508,12 @@ @@ -2131,6 +2508,12 @@
"node": ">= 0.6"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4531,6 +4914,12 @@ @@ -4531,6 +4914,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@ -5082,6 +5471,12 @@ @@ -5082,6 +5471,12 @@
"node": ">=0.10.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

11
package.json

@ -23,10 +23,21 @@ @@ -23,10 +23,21 @@
"format": "prettier --write ."
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/basic-setup": "^0.20.0",
"@codemirror/commands": "^6.10.1",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.1",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.12",
"@lezer/markdown": "^1.6.3",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"@tanstack/svelte-virtual": "^3.0.0",
"asciidoctor": "3.0.x",
"codemirror-asciidoc": "^2.0.1",
"dompurify": "^3.0.6",
"emoji-picker-element": "^1.28.1",
"highlight.js": "^11.11.1",

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.2.0",
"buildTime": "2026-02-06T16:38:51.661Z",
"buildTime": "2026-02-06T17:22:41.111Z",
"gitCommit": "unknown",
"timestamp": 1770395931661
"timestamp": 1770398561111
}

297
src/lib/components/find/SearchAddressableEvents.svelte

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { getEventsByKind } from '../../services/cache/event-cache.js';
import ProfileBadge from '../layout/ProfileBadge.svelte';
import RelayBadge from '../layout/RelayBadge.svelte';
import CacheBadge from '../layout/CacheBadge.svelte';
@ -12,14 +13,19 @@ @@ -12,14 +13,19 @@
let searchQuery = $state('');
let searching = $state(false);
let results = $state<Array<{ event: NostrEvent; matchedTag: string; matchedValue: string; relevance: number; relay?: string }>>([]);
let cacheResults = $state<Array<{ event: NostrEvent; matchedTag: string; matchedValue: string; relevance: number; relay?: string }>>([]);
let timeoutId: ReturnType<typeof setTimeout> | null = $state(null);
let cacheSearchTimeoutId: ReturnType<typeof setTimeout> | null = $state(null);
// Tag search order (by relevance)
const searchTags = ['d', 'T', 'C', 'title', 'author', 'summary', 'description'];
const SEARCH_TIMEOUT = 10000; // 10 seconds
const CACHE_SEARCH_DEBOUNCE = 500; // 500ms debounce for cache search
// Map to track results by event ID to avoid duplicates
const resultsMap = new Map<string, { event: NostrEvent; matchedTag: string; matchedValue: string; relevance: number; relay?: string }>();
// Map to track cache results by event ID
const cacheResultsMap = new Map<string, { event: NostrEvent; matchedTag: string; matchedValue: string; relevance: number; relay?: string }>();
// Map to track which relay each event came from
const eventRelayMap = new Map<string, string>();
@ -67,9 +73,12 @@ @@ -67,9 +73,12 @@
return bestMatch;
}
function addResult(event: NostrEvent, query: string, relay?: string) {
function addResult(event: NostrEvent, query: string, relay?: string, isCache: boolean = false) {
const targetMap = isCache ? cacheResultsMap : resultsMap;
const limit = 100;
// Limit to 100 results total
if (resultsMap.size >= 100) {
if (targetMap.size >= limit) {
return;
}
@ -85,19 +94,113 @@ @@ -85,19 +94,113 @@
matchedTag: bestMatch.tag,
matchedValue: bestMatch.value,
relevance: bestMatch.relevance,
relay: relay || eventRelayMap.get(event.id)
relay: relay || (isCache ? 'cache' : eventRelayMap.get(event.id))
};
// Update or add result
resultsMap.set(event.id, result);
targetMap.set(event.id, result);
// Update results array (sorted by relevance, limit to 100)
results = Array.from(resultsMap.values())
.sort((a, b) => b.relevance - a.relevance)
.slice(0, 100);
if (isCache) {
cacheResults = Array.from(cacheResultsMap.values())
.sort((a, b) => b.relevance - a.relevance)
.slice(0, limit);
} else {
results = Array.from(resultsMap.values())
.sort((a, b) => b.relevance - a.relevance)
.slice(0, limit);
}
}
}
// Search cache only (debounced)
async function searchCache() {
if (!searchQuery.trim()) {
cacheResults = [];
cacheResultsMap.clear();
return;
}
const query = searchQuery.trim();
cacheResults = [];
cacheResultsMap.clear();
try {
// Check if it's an naddr
if (/^naddr1[a-z0-9]+$/i.test(query)) {
try {
const decoded = nip19.decode(query);
if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data && 'identifier' in decoded.data) {
const naddrData = decoded.data as { kind: number; pubkey: string; identifier: string };
const kind = naddrData.kind;
const pubkey = String(naddrData.pubkey);
const dTag = String(naddrData.identifier);
// Search cache for event with matching kind, pubkey, and d tag
const cachedByKind = await getEventsByKind(kind, 1000);
const event = cachedByKind.find(e =>
e.pubkey.toLowerCase() === pubkey.toLowerCase() &&
e.tags.some(t => t[0] === 'd' && t[1] === dTag)
);
if (event) {
addResult(event, dTag, 'cache', true);
return;
}
}
} catch {
// Not a valid naddr, continue with regular search
}
}
const queryLower = query.toLowerCase();
// Fetch all cached events in kind range 30000-39999
const kinds: number[] = [];
for (let kind = 30000; kind <= 39999; kind++) {
kinds.push(kind);
}
// Get all cached events for these kinds
const allCachedEvents: NostrEvent[] = [];
for (const kind of kinds) {
const cached = await getEventsByKind(kind, 1000); // Get up to 1000 per kind
allCachedEvents.push(...cached);
}
// Filter and score events
for (const event of allCachedEvents) {
if (cacheResultsMap.size < 100) {
addResult(event, queryLower, 'cache', true);
}
}
} catch (error) {
console.error('Error searching cache:', error);
}
}
// Debounced cache search
$effect(() => {
// Clear previous timeout
if (cacheSearchTimeoutId) {
clearTimeout(cacheSearchTimeoutId);
}
if (searchQuery.trim()) {
cacheSearchTimeoutId = setTimeout(() => {
searchCache();
}, CACHE_SEARCH_DEBOUNCE);
} else {
cacheResults = [];
cacheResultsMap.clear();
}
return () => {
if (cacheSearchTimeoutId) {
clearTimeout(cacheSearchTimeoutId);
}
};
});
async function search() {
if (!searchQuery.trim()) {
results = [];
@ -116,7 +219,7 @@ @@ -116,7 +219,7 @@
resultsMap.clear();
eventRelayMap.clear();
const query = searchQuery.trim().toLowerCase();
const query = searchQuery.trim();
// Set timeout
timeoutId = setTimeout(() => {
@ -125,6 +228,46 @@ @@ -125,6 +228,46 @@
}, SEARCH_TIMEOUT);
try {
// Check if it's an naddr
if (/^naddr1[a-z0-9]+$/i.test(query)) {
try {
const decoded = nip19.decode(query);
if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data && 'identifier' in decoded.data) {
const naddrData = decoded.data as { kind: number; pubkey: string; identifier: string; relays?: string[] };
const kind = naddrData.kind;
const pubkey = String(naddrData.pubkey);
const dTag = String(naddrData.identifier);
// Use relay hints from naddr if available, otherwise use default relays
const relays = naddrData.relays && naddrData.relays.length > 0
? naddrData.relays
: relayManager.getProfileReadRelays();
// Fetch the specific event
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
relays,
{ useCache: 'cache-first', cacheResults: true, timeout: SEARCH_TIMEOUT }
);
if (events.length > 0) {
const event = events[0];
addResult(event, dTag, relays[0] || 'cache');
// Clear timeout
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
searching = false;
return;
}
}
} catch {
// Not a valid naddr, continue with regular search
}
}
const queryLower = query.toLowerCase();
// Fetch all events in kind range 30000-39999
const kinds: number[] = [];
for (let kind = 30000; kind <= 39999; kind++) {
@ -145,7 +288,7 @@ @@ -145,7 +288,7 @@
// Process events incrementally as they arrive (limit to 100 total)
for (const { event, relay } of eventsWithRelay) {
if (!resultsMap.has(event.id) && resultsMap.size < 100) {
addResult(event, query, relay);
addResult(event, queryLower, relay);
}
}
}
@ -164,11 +307,6 @@ @@ -164,11 +307,6 @@
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
search();
}
}
function getTagValue(event: NostrEvent, tagName: string): string | null {
const tagVariants = [tagName, tagName.toLowerCase(), tagName.toUpperCase()];
@ -221,6 +359,24 @@ @@ -221,6 +359,24 @@
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
export function clearSearch() {
searchQuery = '';
results = [];
cacheResults = [];
resultsMap.clear();
cacheResultsMap.clear();
eventRelayMap.clear();
searching = false;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (cacheSearchTimeoutId) {
clearTimeout(cacheSearchTimeoutId);
cacheSearchTimeoutId = null;
}
}
</script>
<div class="addressable-search">
@ -233,7 +389,6 @@ @@ -233,7 +389,6 @@
<input
type="text"
bind:value={searchQuery}
onkeydown={handleKeydown}
placeholder="e.g., jane eyre, jane-eyre, Charlotte Bronte..."
class="search-input"
disabled={searching}
@ -247,10 +402,111 @@ @@ -247,10 +402,111 @@
</button>
</div>
{#if results.length > 0}
{#if cacheResults.length > 0 || results.length > 0}
<div class="results-container">
<h3>Results ({results.length})</h3>
<div class="results-grid">
{#if cacheResults.length > 0}
<h3>Found in Cache: ({cacheResults.length})</h3>
<div class="results-grid">
{#each cacheResults as { event, matchedTag, matchedValue, relevance, relay }}
<div
class="result-card"
role="button"
tabindex="0"
onclick={() => handleResultClick(event)}
onkeydown={(e) => handleResultKeydown(e, event)}
>
{#if getImageTag(event)}
<div class="card-image">
<img src={getImageTag(event)} alt="" onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
</div>
{/if}
<div class="card-content">
<div class="card-header">
<div class="card-header-left">
<ProfileBadge pubkey={event.pubkey} inline={true} />
<CacheBadge />
</div>
<span class="kind-label">{getKindInfo(event.kind).description}</span>
</div>
<div class="card-metadata">
{#if getTagValue(event, 'd')}
<div class="metadata-item">
<strong class="metadata-label">d:</strong>
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'd'}>
{@html highlightText(getTagValue(event, 'd') || '', searchQuery, matchedTag.toLowerCase() === 'd')}
</span>
</div>
{/if}
{#if getTagValue(event, 'T')}
<div class="metadata-item">
<strong class="metadata-label">T:</strong>
<span class="metadata-value" class:highlighted={matchedTag === 'T'}>
{@html highlightText(getTagValue(event, 'T') || '', searchQuery, matchedTag === 'T')}
</span>
</div>
{/if}
{#if getTagValue(event, 'C')}
<div class="metadata-item">
<strong class="metadata-label">C:</strong>
<span class="metadata-value" class:highlighted={matchedTag === 'C'}>
{@html highlightText(getTagValue(event, 'C') || '', searchQuery, matchedTag === 'C')}
</span>
</div>
{/if}
{#if getTagValue(event, 'title')}
<div class="metadata-item">
<strong class="metadata-label">title:</strong>
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'title'}>
{@html highlightText(getTagValue(event, 'title') || '', searchQuery, matchedTag.toLowerCase() === 'title')}
</span>
</div>
{/if}
{#if getTagValue(event, 'author')}
<div class="metadata-item">
<strong class="metadata-label">author:</strong>
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'author'}>
{@html highlightText(getTagValue(event, 'author') || '', searchQuery, matchedTag.toLowerCase() === 'author')}
</span>
</div>
{/if}
{#if getTagValue(event, 'summary')}
<div class="metadata-item">
<strong class="metadata-label">summary:</strong>
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'summary'}>
{@html highlightText(getTagValue(event, 'summary') || '', searchQuery, matchedTag.toLowerCase() === 'summary')}
</span>
</div>
{/if}
{#if getTagValue(event, 'description')}
<div class="metadata-item">
<strong class="metadata-label">description:</strong>
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'description'}>
{@html highlightText(getTagValue(event, 'description') || '', searchQuery, matchedTag.toLowerCase() === 'description')}
</span>
</div>
{/if}
</div>
<div class="card-footer">
<code class="event-id">{event.id}</code>
</div>
</div>
</div>
{/each}
</div>
{/if}
{#if results.length > 0}
<h3>Results from Relays ({results.length})</h3>
<div class="results-grid">
{#each results as { event, matchedTag, matchedValue, relevance, relay }}
<div
class="result-card"
@ -351,9 +607,10 @@ @@ -351,9 +607,10 @@
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
{:else if !searching && searchQuery.trim()}
{:else if !searching && searchQuery.trim() && cacheResults.length === 0}
<div class="no-results">
No addressable events found matching "{searchQuery}"
</div>

255
src/lib/components/layout/UnifiedSearch.svelte

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { getEvent, getEventsByKind } from '../../services/cache/event-cache.js';
import { getEvent, getEventsByKind, getEventsByPubkey } from '../../services/cache/event-cache.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
@ -33,12 +33,17 @@ @@ -33,12 +33,17 @@
let showResults = $state(false);
let searchInput: HTMLInputElement | null = $state(null);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let cacheSearchTimeoutId: ReturnType<typeof setTimeout> | null = null;
// For collecting results when hideDropdownResults is true
let foundEvents: NostrEvent[] = [];
let foundProfiles: string[] = [];
// For cache-only results
let cacheEvents: NostrEvent[] = [];
let cacheProfiles: string[] = [];
// Map to track which relay each event came from
const eventRelayMap = new Map<string, string>();
const CACHE_SEARCH_DEBOUNCE = 500; // 500ms debounce for cache search
// Clear results at start of search
function clearResults() {
@ -47,6 +52,183 @@ @@ -47,6 +52,183 @@
eventRelayMap.clear();
}
// Clear cache results
function clearCacheResults() {
cacheEvents = [];
cacheProfiles = [];
}
// Search cache only (debounced)
async function searchCacheOnly() {
if (!searchQuery.trim()) {
clearCacheResults();
if (hideDropdownResults && onSearchResults) {
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() });
}
return;
}
const query = searchQuery.trim();
clearCacheResults();
try {
// 1. Check if it's a hex event ID
if (/^[0-9a-f]{64}$/i.test(query)) {
const hexId = query.toLowerCase();
const event = await getEvent(hexId);
if (event) {
// Check if kind matches filter
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(event.kind)) {
// Doesn't match, continue
} else {
cacheEvents = [event];
if (hideDropdownResults && onSearchResults) {
const cacheRelays = new Map<string, string>();
cacheRelays.set(event.id, 'cache');
onSearchResults({ events: cacheEvents, profiles: [], relays: [], eventRelays: cacheRelays });
}
return;
}
}
// Try as pubkey
const cachedByPubkey = await getEventsByPubkey(hexId, 100);
if (cachedByPubkey.length > 0) {
const filtered = effectiveKinds && effectiveKinds.length > 0
? cachedByPubkey.filter(e => effectiveKinds.includes(e.kind))
: cachedByPubkey;
cacheEvents = filtered;
cacheProfiles = [hexId];
if (hideDropdownResults && onSearchResults) {
const cacheRelays = new Map<string, string>();
for (const event of cacheEvents) {
cacheRelays.set(event.id, 'cache');
}
onSearchResults({ events: cacheEvents, profiles: cacheProfiles, relays: [], eventRelays: cacheRelays });
}
return;
}
}
// 2. Check if it's a bech32 npub/nprofile/naddr
if (/^(npub|nprofile|naddr)1[a-z0-9]+$/i.test(query)) {
try {
const decoded = nip19.decode(query);
let pubkey: string | null = null;
if (decoded.type === 'npub') {
pubkey = String(decoded.data);
} else if (decoded.type === 'nprofile') {
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
pubkey = String(decoded.data.pubkey);
}
} else if (decoded.type === 'naddr') {
// naddr encodes kind + pubkey + d tag
if (decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data && 'identifier' in decoded.data) {
const naddrData = decoded.data as { kind: number; pubkey: string; identifier: string };
const kind = naddrData.kind;
pubkey = String(naddrData.pubkey);
const dTag = String(naddrData.identifier);
// Check if kind matches filter
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(kind)) {
// Doesn't match, continue
} else {
// Search cache for event with matching kind, pubkey, and d tag
const cachedByKind = await getEventsByKind(kind, 1000);
const event = cachedByKind.find(e =>
pubkey && e.pubkey.toLowerCase() === pubkey.toLowerCase() &&
e.tags.some(t => t[0] === 'd' && t[1] === dTag)
);
if (event) {
cacheEvents = [event];
if (hideDropdownResults && onSearchResults) {
const cacheRelays = new Map<string, string>();
cacheRelays.set(event.id, 'cache');
onSearchResults({ events: cacheEvents, profiles: [], relays: [], eventRelays: cacheRelays });
}
return;
}
}
}
}
if (pubkey) {
const normalizedPubkey = pubkey.toLowerCase();
const cachedByPubkey = await getEventsByPubkey(normalizedPubkey, 100);
if (cachedByPubkey.length > 0) {
const filtered = effectiveKinds && effectiveKinds.length > 0
? cachedByPubkey.filter(e => effectiveKinds.includes(e.kind))
: cachedByPubkey;
cacheEvents = filtered;
cacheProfiles = [normalizedPubkey];
if (hideDropdownResults && onSearchResults) {
const cacheRelays = new Map<string, string>();
for (const event of cacheEvents) {
cacheRelays.set(event.id, 'cache');
}
onSearchResults({ events: cacheEvents, profiles: cacheProfiles, relays: [], eventRelays: cacheRelays });
}
return;
}
}
} catch {
// Not a valid bech32, continue
}
}
// 3. Search by kind if specified
if (effectiveKinds && effectiveKinds.length > 0) {
const allCached: NostrEvent[] = [];
for (const kind of effectiveKinds) {
const cached = await getEventsByKind(kind, 100);
allCached.push(...cached);
}
// Filter by content matching query
const queryLower = query.toLowerCase();
const matching = allCached.filter(event => {
return event.content.toLowerCase().includes(queryLower) ||
event.tags.some(tag => tag.some(v => v && v.toLowerCase().includes(queryLower)));
});
cacheEvents = matching.slice(0, 100);
if (hideDropdownResults && onSearchResults && cacheEvents.length > 0) {
const cacheRelays = new Map<string, string>();
for (const event of cacheEvents) {
cacheRelays.set(event.id, 'cache');
}
onSearchResults({ events: cacheEvents, profiles: [], relays: [], eventRelays: cacheRelays });
}
}
} catch (error) {
console.error('Error searching cache:', error);
}
}
// Debounced cache search
$effect(() => {
if (cacheSearchTimeoutId) {
clearTimeout(cacheSearchTimeoutId);
}
if (searchQuery.trim() && hideDropdownResults && onSearchResults) {
cacheSearchTimeoutId = setTimeout(() => {
searchCacheOnly();
}, CACHE_SEARCH_DEBOUNCE);
} else {
clearCacheResults();
}
return () => {
if (cacheSearchTimeoutId) {
clearTimeout(cacheSearchTimeoutId);
}
};
});
// For filter mode: resolved search result
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }>({ type: null, value: null, kind: null });
@ -516,8 +698,62 @@ @@ -516,8 +698,62 @@
eventId = String(decoded.data.id);
}
} else if (decoded.type === 'naddr') {
// naddr is more complex, would need kind+pubkey+d to fetch
// For now, we'll skip it and treat as text search
// naddr encodes kind + pubkey + d tag
if (decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data && 'identifier' in decoded.data) {
const naddrData = decoded.data as { kind: number; pubkey: string; identifier: string; relays?: string[] };
const kind = naddrData.kind;
const pubkey = String(naddrData.pubkey);
const dTag = String(naddrData.identifier);
// Check if kind matches filter
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(kind)) {
// Doesn't match, continue
} else {
// Try to find in cache first
const cachedByKind = await getEventsByKind(kind, 1000);
let event: NostrEvent | undefined = cachedByKind.find(e =>
e.pubkey.toLowerCase() === pubkey.toLowerCase() &&
e.tags.some(t => t[0] === 'd' && t[1] === dTag)
);
if (!event) {
// Fetch from relays
const relays = naddrData.relays && naddrData.relays.length > 0
? naddrData.relays
: relayManager.getAllAvailableRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
event = events[0];
}
}
if (event) {
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
const foundEventRelays = new Map<string, string>();
foundEventRelays.set(event.id, 'cache');
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays });
} else {
searchResults = [{ event, matchType: 'Addressable Event (naddr)' }];
showResults = true;
}
} else {
filterResult = { type: 'event', value: event.id, kind: kind };
if (onFilterChange) onFilterChange(filterResult);
}
searching = false;
resolving = false;
return;
}
}
}
}
if (eventId) {
@ -834,6 +1070,19 @@ @@ -834,6 +1070,19 @@
performSearch();
}
export function clearSearch() {
searchQuery = '';
searchResults = [];
showResults = false;
clearResults();
clearCacheResults();
filterResult = { type: null, value: null, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
if (hideDropdownResults && onSearchResults) {
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() });
}
}
// Note: filterResult kind is updated in performSearch, not here to avoid loops
// Get all kinds for dropdown (sorted by number)

378
src/lib/components/write/AdvancedEditor.svelte

@ -0,0 +1,378 @@ @@ -0,0 +1,378 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { keymap } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { closeBrackets, autocompletion, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
import { markdown } from '@codemirror/lang-markdown';
import { StreamLanguage } from '@codemirror/language';
import { asciidoc } from 'codemirror-asciidoc';
import { oneDark } from '@codemirror/theme-one-dark';
interface Props {
value: string;
mode: 'markdown' | 'asciidoc';
onUpdate?: (value: string) => void;
onClose?: () => void;
}
let { value, mode, onUpdate, onClose }: Props = $props();
let editorContainer = $state<HTMLElement | null>(null);
let editorView: EditorView | null = $state(null);
let isDark = $state(false);
let initialized = $state(false);
// Check for dark mode preference
onMount(() => {
if (initialized || !editorContainer) return;
const checkDarkMode = () => {
isDark = document.documentElement.classList.contains('dark') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
};
checkDarkMode();
// Watch for dark mode changes
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
// Initialize editor
try {
const language = mode === 'asciidoc'
? StreamLanguage.define(asciidoc)
: markdown();
const extensions = [
history(),
closeBrackets(),
autocompletion(),
highlightSelectionMatches(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...completionKeymap
]),
language,
EditorView.updateListener.of((update) => {
if (update.docChanged && onUpdate) {
const newValue = update.state.doc.toString();
onUpdate(newValue);
}
}),
EditorView.theme({
'&': {
fontSize: '14px',
height: '100%'
},
'.cm-editor': {
height: '100%'
},
'.cm-scroller': {
height: '100%',
overflow: 'auto'
},
'.cm-content': {
minHeight: '400px',
padding: '1rem',
fontFamily: 'SF Mono, Monaco, Inconsolata, Fira Code, Droid Sans Mono, Source Code Pro, monospace',
lineHeight: '1.6'
},
'.cm-focused': {
outline: 'none'
}
})
];
// Add dark theme if needed
if (isDark) {
extensions.push(oneDark);
}
const state = EditorState.create({
doc: value,
extensions
});
editorView = new EditorView({
state,
parent: editorContainer
});
initialized = true;
} catch (error) {
console.error('Error initializing CodeMirror:', error);
}
return () => {
observer.disconnect();
if (editorView) {
editorView.destroy();
editorView = null;
initialized = false;
}
};
});
onDestroy(() => {
if (editorView) {
editorView.destroy();
editorView = null;
initialized = false;
}
});
function handleSave() {
if (editorView && onUpdate) {
const content = editorView.state.doc.toString();
onUpdate(content);
}
if (onClose) {
onClose();
}
}
function handleCancel() {
if (onClose) {
onClose();
}
}
</script>
<div class="advanced-editor-modal" role="dialog" aria-modal="true" aria-labelledby="editor-title">
<div class="editor-container">
<div class="editor-header">
<h2 id="editor-title" class="editor-title">
Advanced Editor - {mode === 'asciidoc' ? 'AsciiDoc' : 'Markdown'}
</h2>
<button
class="close-button"
onclick={handleCancel}
aria-label="Close editor"
>
×
</button>
</div>
<div class="editor-body">
<div class="editor-wrapper" bind:this={editorContainer}></div>
</div>
<div class="editor-footer">
<button class="cancel-button" onclick={handleCancel}>
Cancel
</button>
<button class="save-button" onclick={handleSave}>
Save & Close
</button>
</div>
</div>
</div>
<style>
.advanced-editor-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
.editor-container {
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
width: 100%;
max-width: 1200px;
max-height: 90vh;
overflow: hidden;
}
:global(.dark) .editor-container {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2);
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .editor-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.editor-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .editor-title {
color: var(--fog-dark-text, #f9fafb);
}
.close-button {
background: none;
border: none;
font-size: 1.75rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text, #64748b);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.close-button:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .close-button {
color: var(--fog-dark-text-light, #9ca3af);
}
:global(.dark) .close-button:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.editor-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 400px;
}
.editor-wrapper {
flex: 1;
overflow: hidden;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
margin: 1rem 1.5rem;
}
:global(.dark) .editor-wrapper {
border-color: var(--fog-dark-border, #374151);
}
.editor-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .editor-footer {
border-top-color: var(--fog-dark-border, #374151);
}
.cancel-button,
.save-button {
padding: 0.625rem 1.25rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: 1px solid var(--fog-border, #e5e7eb);
}
.cancel-button {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #475569);
}
:global(.dark) .cancel-button {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #cbd5e1);
}
.cancel-button:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .cancel-button:hover {
background: var(--fog-dark-border, #475569);
}
.save-button {
background: var(--fog-accent, #64748b);
color: var(--fog-text, #f1f5f9);
border: none;
}
:global(.dark) .save-button {
background: var(--fog-dark-accent, #94a3b8);
}
.save-button:hover {
opacity: 0.9;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.advanced-editor-modal {
padding: 0;
}
.editor-container {
max-width: 100%;
max-height: 100vh;
border-radius: 0;
border-left: none;
border-right: none;
}
.editor-header {
padding: 0.75rem 1rem;
}
.editor-title {
font-size: 1.125rem;
}
.editor-wrapper {
margin: 0.75rem 1rem;
min-height: calc(100vh - 200px);
}
.editor-footer {
padding: 0.75rem 1rem;
flex-direction: column-reverse;
}
.cancel-button,
.save-button {
width: 100%;
padding: 0.75rem 1.25rem;
}
}
</style>

453
src/lib/components/write/CreateEventForm.svelte

@ -9,31 +9,16 @@ @@ -9,31 +9,16 @@
import MarkdownRenderer from '../content/MarkdownRenderer.svelte';
import MediaAttachments from '../content/MediaAttachments.svelte';
import RichTextEditor from '../content/RichTextEditor.svelte';
import AdvancedEditor from './AdvancedEditor.svelte';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
import { goto } from '$app/navigation';
import { KIND } from '../../types/kind-lookup.js';
import { getKindMetadata, getWritableKinds } from '../../types/kind-metadata.js';
import type { NostrEvent } from '../../types/nostr.js';
import { autoExtractTags, ensureDTagForParameterizedReplaceable } from '../../services/auto-tagging.js';
import { isParameterizedReplaceableKind } from '../../types/kind-lookup.js';
const SUPPORTED_KINDS = [
{ value: 1, label: '1 - Short Text Note' },
{ value: 11, label: '11 - Discussion Thread' },
{ value: 20, label: '20 - Picture Note' },
{ value: 21, label: '21 - Video Note' },
{ value: 22, label: '22 - Short Video Note' },
{ value: 24, label: '24 - Public Message' },
{ value: 1068, label: '1068 - Poll' },
{ value: 1222, label: '1222 - Voice Note' },
{ value: 9802, label: '9802 - Highlighted Article' },
{ value: 10895, label: '10895 - RSS Feed' },
{ value: 30023, label: '30023 - Long-form Note' },
{ value: 30040, label: '30040 - Event Index (metadata-only)' },
{ value: 30041, label: '30041 - AsciiDoc' },
{ value: 30817, label: '30817 - AsciiDoc' },
{ value: 30818, label: '30818 - AsciiDoc' },
{ value: -1, label: 'Unknown Kind' }
];
const SUPPORTED_KINDS = getWritableKinds();
interface Props {
initialKind?: number | null;
@ -109,10 +94,28 @@ @@ -109,10 +94,28 @@
let showJsonModal = $state(false);
let showPreviewModal = $state(false);
let showExampleModal = $state(false);
let showAdvancedEditor = $state(false);
let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
let eventJson = $state('{}');
const isUnknownKind = $derived(selectedKind === -1);
const effectiveKind = $derived(isUnknownKind ? (parseInt(customKindId) || 1) : selectedKind);
// Determine editor mode based on selected kind
const editorMode = $derived(
effectiveKind === 30818 || effectiveKind === 30041 ? 'asciidoc' : 'markdown'
);
// Show advanced editor button when editing (has initial content) or for AsciiDoc kinds
const showAdvancedEditorButton = $derived(
(propInitialContent !== null && propInitialContent !== undefined) ||
effectiveKind === 30818 ||
effectiveKind === 30041 ||
effectiveKind === 30023 || // Long-form note (markdown)
effectiveKind === 1 // Short text note (markdown)
);
// Sync selectedKind when initialKind prop changes
$effect(() => {
if (initialKind !== null && initialKind !== undefined) {
@ -140,331 +143,27 @@ @@ -140,331 +143,27 @@
// Clear content for metadata-only kinds
$effect(() => {
if (selectedKind === 30040 || selectedKind === 10895) {
const metadata = getKindMetadata(selectedKind);
if (metadata.requiresContent === false) {
content = '';
}
});
const isKind30040 = $derived(selectedKind === 30040);
const isKind10895 = $derived(selectedKind === 10895);
const isUnknownKind = $derived(selectedKind === -1);
const effectiveKind = $derived(isUnknownKind ? (parseInt(customKindId) || 1) : selectedKind);
const kindMetadata = $derived(getKindMetadata(effectiveKind));
const helpText = $derived(kindMetadata.helpText);
function getKindHelpText(kind: number): { description: string; suggestedTags: string[] } {
switch (kind) {
case 1:
return {
description: 'A simple plaintext note (NIP-10). The content property contains some human-readable text.',
suggestedTags: ['e (event references)', 'p (pubkey mentions)', 'q (quoted events)', 't (hashtags)']
};
case 11:
return {
description: 'A thread (NIP-7D). A thread is a kind 11 event. Threads SHOULD include a title tag. Replies use kind 1111 comments (NIP-22).',
suggestedTags: ['title (required)', 't (topics/hashtags)']
};
case 20:
return {
description: 'Picture-first feeds (NIP-68). Event kind 20 for picture-first clients. Images must be self-contained. They are hosted externally and referenced using imeta tags.',
suggestedTags: ['title', 'imeta (url, m, blurhash, dim, alt, x, fallback)', 'p (tagged users)', 'm (media type)', 'x (image hash)', 't (hashtags)', 'location', 'g (geohash)', 'L/l (language)', 'content-warning']
};
case 21:
return {
description: 'Video Events (NIP-71). Normal videos representing a dedicated post of externally hosted content. The content is a summary or description on the video content.',
suggestedTags: ['title (required)', 'imeta (url, dim, m, image, fallback, service, bitrate, duration)', 'published_at', 'text-track', 'content-warning', 'alt', 'segment', 't (hashtags)', 'p (participants)', 'r (web references)']
};
case 22:
return {
description: 'Video Events (NIP-71). Short videos (stories/reels style) representing a dedicated post of externally hosted content. The content is a summary or description on the video content.',
suggestedTags: ['title (required)', 'imeta (url, dim, m, image, fallback, service, bitrate, duration)', 'published_at', 'text-track', 'content-warning', 'alt', 'segment', 't (hashtags)', 'p (participants)', 'r (web references)']
};
case 24:
return {
description: 'Public Messages (NIP-A4). A simple plaintext message to one or more Nostr users. The content contains the message. p tags identify one or more receivers. Designed to be shown and replied to from notification screens.',
suggestedTags: ['p (receiver pubkeys, required)', 'expiration (recommended)', 'q (quoted events)', 'imeta (for image/video links)', 't (hashtags)']
};
case 1068:
return {
description: 'Polls (NIP-88). The poll event is defined as a kind 1068 event. The content key holds the label for the poll.',
suggestedTags: ['option (optionId, label)', 'relay (one or more)', 'polltype (singlechoice|multiplechoice)', 'endsAt (unix timestamp)']
};
case 1222:
return {
description: 'Voice Messages (NIP-A0). Root messages for short voice messages, typically up to 60 seconds in length. Content MUST be a URL pointing directly to an audio file (audio/mp4 recommended).',
suggestedTags: ['imeta (with url, waveform, duration)', 't (hashtags)', 'g (geohash)']
};
case 9802:
return {
description: 'Highlights (NIP-84). A highlight event to signal content a user finds valuable. The content of these events is the highlighted portion of the text.',
suggestedTags: ['a (addressable event)', 'e (event reference)', 'r (URL reference)', 'p (author pubkeys)', 'context (surrounding text)', 'comment (for quote highlights)']
};
case 10895:
return {
description: 'RSS Feed subscription event. Lists external RSS feeds to subscribe to. Content should be empty.',
suggestedTags: ['u (RSS feed URL, repeat for multiple feeds)']
};
case 30023:
return {
description: 'Long-form Content (NIP-23). Long-form text content, generally referred to as "articles" or "blog posts". The content should be a string text in Markdown syntax. Include a d tag for editability.',
suggestedTags: ['d (required for editability)', 'title', 'image', 'summary', 'published_at', 't (hashtags)']
};
case 30040:
return {
description: 'Publication Index (NKBIP-01). A publication index defines the structure and metadata of a publication. The content field MUST be empty.',
suggestedTags: ['d (required)', 'title (required)', 'a (referenced events)', 'auto-update (yes|ask|no)', 'p (original author)', 'E (original event)', 'source', 'version', 'type', 'author', 'i (ISBN)', 't (hashtags)', 'published_on', 'published_by', 'image', 'summary']
};
case 30041:
return {
description: 'Publication Content (NKBIP-01). Also known as sections, zettels, episodes, or chapters contain the actual content that makes up a publication. The content field MUST contain text meant for display to the end user and MAY contain AsciiDoc markup.',
suggestedTags: ['d (required)', 'title (required)', 'wikilink']
};
case 30817:
return {
description: 'An AsciiDoc article. Similar to 30818 but may have different conventions.',
suggestedTags: ['d (identifier)', 'title', 'summary', 'a (addressable event)', 'e (event reference)']
};
case 30818:
return {
description: 'Wiki (NIP-54). Descriptions (or encyclopedia entries) of particular subjects. Articles are identified by lowercase, normalized d tags. The content should be Asciidoc with wikilinks and nostr:... links.',
suggestedTags: ['d (required, normalized)', 'title', 'summary', 'a (addressable event)', 'e (event reference)']
};
default:
return {
description: `Custom kind ${kind}. Refer to the relevant NIP specification for tag requirements.`,
suggestedTags: []
};
}
}
const helpText = $derived(getKindHelpText(effectiveKind));
function getExampleJSON(kind: number): string {
function getExampleJSON(): string {
const examplePubkey = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
const exampleEventId = '67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446';
const exampleRelay = 'wss://relay.example.com';
const timestamp = Math.floor(Date.now() / 1000);
switch (kind) {
case 1:
return JSON.stringify({
kind: 1,
pubkey: examplePubkey,
created_at: timestamp,
content: 'Hello nostr! This is a simple text note.',
tags: [
['e', exampleEventId, exampleRelay],
['p', examplePubkey],
['t', 'nostr']
],
id: '...',
sig: '...'
}, null, 2);
case 11:
return JSON.stringify({
kind: 11,
pubkey: examplePubkey,
created_at: timestamp,
content: 'This is a discussion thread about a topic.',
tags: [
['title', 'Discussion Thread Title'],
['t', 'topic1'],
['t', 'topic2']
],
id: '...',
sig: '...'
}, null, 2);
case 9802:
return JSON.stringify({
kind: 9802,
pubkey: examplePubkey,
created_at: timestamp,
content: 'This is the highlighted text portion.',
tags: [
['e', exampleEventId, exampleRelay],
['p', examplePubkey, '', 'author'],
['context', 'This is the highlighted text portion within the surrounding context text...']
],
id: '...',
sig: '...'
}, null, 2);
case 1222:
return JSON.stringify({
kind: 1222,
pubkey: examplePubkey,
created_at: timestamp,
content: 'https://example.com/audio/voice-message.m4a',
tags: [
['imeta', 'url https://example.com/audio/voice-message.m4a', 'waveform 0 7 35 8 100 100 49', 'duration 8'],
['t', 'voice']
],
id: '...',
sig: '...'
}, null, 2);
case 20:
return JSON.stringify({
kind: 20,
pubkey: examplePubkey,
created_at: timestamp,
content: 'A beautiful sunset photo',
tags: [
['title', 'Sunset Photo'],
['imeta', 'url https://nostr.build/i/image.jpg', 'm image/jpeg', 'dim 3024x4032', 'alt A scenic sunset'],
['t', 'photography'],
['location', 'San Francisco, CA']
],
id: '...',
sig: '...'
}, null, 2);
case 21:
return JSON.stringify({
kind: 21,
pubkey: examplePubkey,
created_at: timestamp,
content: 'A detailed video about Nostr protocol',
tags: [
['title', 'Introduction to Nostr'],
['imeta', 'url https://example.com/video.mp4', 'dim 1920x1080', 'm video/mp4', 'duration 300', 'bitrate 3000000'],
['published_at', timestamp.toString()],
['t', 'tutorial']
],
id: '...',
sig: '...'
}, null, 2);
case 22:
return JSON.stringify({
kind: 22,
pubkey: examplePubkey,
created_at: timestamp,
content: 'Quick video update',
tags: [
['title', 'Quick Update'],
['imeta', 'url https://example.com/short.mp4', 'dim 1080x1920', 'm video/mp4', 'duration 15'],
['published_at', timestamp.toString()]
],
id: '...',
sig: '...'
}, null, 2);
case 30023:
return JSON.stringify({
kind: 30023,
pubkey: examplePubkey,
created_at: timestamp,
content: '# Long-form Article\n\nThis is a long-form article written in Markdown...',
tags: [
['d', 'article-slug'],
['title', 'My Long-form Article'],
['summary', 'A brief summary of the article'],
['published_at', timestamp.toString()],
['t', 'article'],
['t', 'longform']
],
id: '...',
sig: '...'
}, null, 2);
case 30818:
return JSON.stringify({
kind: 30818,
pubkey: examplePubkey,
created_at: timestamp,
content: '= Wiki Article\n\nThis is a wiki article written in AsciiDoc.',
tags: [
['d', 'wiki-article'],
['title', 'Wiki Article'],
['summary', 'A brief summary']
],
id: '...',
sig: '...'
}, null, 2);
case 30817:
return JSON.stringify({
kind: 30817,
pubkey: examplePubkey,
created_at: timestamp,
content: '= AsciiDoc Document\n\nContent in AsciiDoc format...',
tags: [
['d', 'asciidoc-doc'],
['title', 'AsciiDoc Document']
],
id: '...',
sig: '...'
}, null, 2);
case 30041:
return JSON.stringify({
kind: 30041,
pubkey: examplePubkey,
created_at: timestamp,
content: '= Chapter Title\n\nChapter content with [[wikilinks]]...',
tags: [
['d', 'publication-chapter-1'],
['title', 'Chapter 1: Introduction']
],
id: '...',
sig: '...'
}, null, 2);
case 30040:
return JSON.stringify({
kind: 30040,
pubkey: examplePubkey,
created_at: timestamp,
content: '',
tags: [
['d', 'publication-slug'],
['title', 'My Publication'],
['author', 'Author Name'],
['summary', 'Publication summary'],
['type', 'book'],
['a', '30041:' + examplePubkey + ':chapter-1', exampleRelay],
['a', '30041:' + examplePubkey + ':chapter-2', exampleRelay],
['auto-update', 'ask']
],
id: '...',
sig: '...'
}, null, 2);
case 1068:
return JSON.stringify({
kind: 1068,
pubkey: examplePubkey,
created_at: timestamp,
content: 'What is your favorite color?',
tags: [
['option', 'opt1', 'Red'],
['option', 'opt2', 'Blue'],
['option', 'opt3', 'Green'],
['relay', exampleRelay],
['polltype', 'singlechoice'],
['endsAt', (timestamp + 86400).toString()]
],
id: '...',
sig: '...'
}, null, 2);
case 10895:
return JSON.stringify({
kind: 10895,
pubkey: examplePubkey,
created_at: timestamp,
content: '',
tags: [
['u', 'https://example.com/feed.rss'],
['u', 'https://another-site.com/rss.xml']
],
id: '...',
sig: '...'
}, null, 2);
default:
return JSON.stringify({
kind: kind,
pubkey: examplePubkey,
created_at: timestamp,
content: 'Custom event content',
tags: [
['example', 'tag', 'value']
],
id: '...',
sig: '...'
}, null, 2);
}
return JSON.stringify(kindMetadata.exampleJSON(examplePubkey, exampleEventId, exampleRelay, timestamp), null, 2);
}
const exampleJSON = $derived(getExampleJSON(effectiveKind));
const exampleJSON = $derived(getExampleJSON());
const isKind30040 = $derived(selectedKind === KIND.PUBLICATION_INDEX);
const isKind10895 = $derived(selectedKind === KIND.RSS_FEED);
function addTag() {
tags = [...tags, ['', '']];
@ -613,13 +312,17 @@ @@ -613,13 +312,17 @@
allTags.push(['client', 'aitherboard']);
}
// Process content to add "nostr:" prefix to valid Nostr addresses
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js');
const processedContent = processNostrLinks(contentWithUrls.trim());
// Create a plain object (not a Proxy) to avoid cloning issues
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: effectiveKind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: allTags,
content: contentWithUrls.trim()
content: processedContent
};
const signedEvent = await session.signer(eventTemplate);
@ -708,12 +411,16 @@ @@ -708,12 +411,16 @@
.filter(t => t[0] && t[1])
.map(tag => [...tag]); // Create new array for each tag to avoid Proxy
// Process content to add "nostr:" prefix to valid Nostr addresses
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js');
const processedContent = processNostrLinks(content);
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: effectiveKind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: plainTags,
content
content: processedContent
};
const results = await signAndPublish(eventTemplate, relays);
@ -780,7 +487,20 @@ @@ -780,7 +487,20 @@
{#if !isKind30040 && !isKind10895}
<div class="form-group">
<label for="content-textarea" class="form-label">Content</label>
<div class="content-header">
<label for="content-textarea" class="form-label">Content</label>
{#if showAdvancedEditorButton}
<button
type="button"
class="advanced-editor-button"
onclick={() => showAdvancedEditor = true}
disabled={publishing}
title="Open advanced editor"
>
Advanced Editor
</button>
{/if}
</div>
<RichTextEditor
bind:this={richTextEditorRef}
bind:value={content}
@ -878,6 +598,17 @@ @@ -878,6 +598,17 @@
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
{#if showAdvancedEditor}
<AdvancedEditor
value={content}
mode={editorMode}
onUpdate={(newContent) => {
content = newContent;
}}
onClose={() => showAdvancedEditor = false}
/>
{/if}
<!-- JSON View Modal -->
{#if showJsonModal}
<div
@ -1260,6 +991,58 @@ @@ -1260,6 +991,58 @@
gap: 0.5rem;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.advanced-editor-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #475569);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
:global(.dark) .advanced-editor-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #cbd5e1);
}
.advanced-editor-button:hover:not(:disabled) {
background: var(--fog-accent, #64748b);
color: var(--fog-text, #f1f5f9);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .advanced-editor-button:hover:not(:disabled) {
background: var(--fog-dark-accent, #94a3b8);
border-color: var(--fog-dark-accent, #94a3b8);
}
.advanced-editor-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 768px) {
.content-header {
flex-direction: column;
align-items: flex-start;
}
.advanced-editor-button {
width: 100%;
}
}
.form-label {
font-weight: 500;
color: var(--fog-text, #475569);

95
src/lib/components/write/EditEventForm.svelte

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
import { relayManager } from '../../services/nostr/relay-manager.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import PublicationStatusModal from '../modals/PublicationStatusModal.svelte';
import AdvancedEditor from './AdvancedEditor.svelte';
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../types/nostr.js';
@ -18,6 +19,12 @@ @@ -18,6 +19,12 @@
let publishing = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let showAdvancedEditor = $state(false);
// Determine editor mode based on event kind
const editorMode = $derived(
event.kind === 30818 || event.kind === 30041 ? 'asciidoc' : 'markdown'
);
// Sync state when event prop changes
$effect(() => {
@ -57,13 +64,17 @@ @@ -57,13 +64,17 @@
publishing = true;
try {
// Process content to add "nostr:" prefix to valid Nostr addresses
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js');
const processedContent = processNostrLinks(content);
// Create new event (id, sig, created_at will be generated)
const eventTemplate = {
kind: event.kind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags.filter(t => t[0] && t[1]), // Filter out empty tags
content
content: processedContent
};
// Sign event
@ -110,13 +121,17 @@ @@ -110,13 +121,17 @@
true
);
// Process content to add "nostr:" prefix to valid Nostr addresses
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js');
const processedContent = processNostrLinks(content);
const results = await signAndPublish(
{
kind: event.kind,
pubkey: event.pubkey,
created_at: event.created_at,
tags: tags.filter(t => t[0] && t[1]),
content
content: processedContent
},
relays
);
@ -135,7 +150,18 @@ @@ -135,7 +150,18 @@
<p class="form-description">Edit the event content and tags. ID, kind, pubkey, sig, and created_at are generated on publish.</p>
<div class="form-group">
<label for="content-textarea" class="form-label">Content</label>
<div class="content-header">
<label for="content-textarea" class="form-label">Content</label>
<button
type="button"
class="advanced-editor-button"
onclick={() => showAdvancedEditor = true}
disabled={publishing}
title="Open advanced editor"
>
Advanced Editor
</button>
</div>
<textarea
id="content-textarea"
bind:value={content}
@ -193,6 +219,17 @@ @@ -193,6 +219,17 @@
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
{#if showAdvancedEditor}
<AdvancedEditor
value={content}
mode={editorMode}
onUpdate={(newContent) => {
content = newContent;
}}
onClose={() => showAdvancedEditor = false}
/>
{/if}
{#if publicationResults && publicationResults.success.length === 0 && publicationResults.failed.length > 0}
<div class="republish-section">
<p class="republish-text">All relays failed. You can attempt to republish from cache.</p>
@ -236,6 +273,58 @@ @@ -236,6 +273,58 @@
gap: 0.5rem;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.advanced-editor-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #475569);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
:global(.dark) .advanced-editor-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #cbd5e1);
}
.advanced-editor-button:hover:not(:disabled) {
background: var(--fog-accent, #64748b);
color: var(--fog-text, #f1f5f9);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .advanced-editor-button:hover:not(:disabled) {
background: var(--fog-dark-accent, #94a3b8);
border-color: var(--fog-dark-accent, #94a3b8);
}
.advanced-editor-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 768px) {
.content-header {
flex-direction: column;
align-items: flex-start;
}
.advanced-editor-button {
width: 100%;
}
}
.form-label {
font-weight: 500;
color: var(--fog-text, #1f2937);

12
src/lib/modules/comments/CommentForm.svelte

@ -198,12 +198,16 @@ @@ -198,12 +198,16 @@
// Convert all tags to plain arrays to avoid Proxy cloning issues
const plainTags: string[][] = tags.map(tag => [...tag]);
// Process content to add "nostr:" prefix to valid Nostr addresses
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js');
const processedContent = processNostrLinks(contentWithUrls.trim());
const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: replyKind,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags: plainTags,
content: contentWithUrls.trim()
content: processedContent
};
console.log(`[CommentForm] Event to publish:`, JSON.stringify(event, null, 2));
@ -339,12 +343,16 @@ @@ -339,12 +343,16 @@
tags.push(['client', 'aitherboard']);
}
// Process content to add "nostr:" prefix to valid Nostr addresses
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js');
const processedContent = processNostrLinks(contentWithUrls.trim());
const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: replyKind,
pubkey: sessionManager.getCurrentPubkey() || '',
created_at: Math.floor(Date.now() / 1000),
tags,
content: contentWithUrls.trim()
content: processedContent
};
return JSON.stringify(event, null, 2);

420
src/lib/types/kind-metadata.ts

@ -0,0 +1,420 @@ @@ -0,0 +1,420 @@
/**
* Kind metadata including help text, examples, and form configuration
* Based on NIPs from nips-silberengel directory
*/
import { KIND, KIND_LOOKUP, type KindInfo } from './kind-lookup.js';
export interface KindMetadata extends KindInfo {
helpText: {
description: string;
suggestedTags: string[];
};
exampleJSON: (pubkey: string, eventId: string, relay: string, timestamp: number) => object;
writable?: boolean; // Whether this kind can be created via the write form
requiresContent?: boolean; // Whether content field is required (default: true)
}
// Kinds that can be written via the form
const WRITABLE_KINDS = [
KIND.SHORT_TEXT_NOTE,
KIND.DISCUSSION_THREAD,
KIND.PICTURE_NOTE,
KIND.VIDEO_NOTE,
KIND.SHORT_VIDEO_NOTE,
KIND.PUBLIC_MESSAGE,
KIND.POLL,
KIND.VOICE_NOTE,
KIND.HIGHLIGHTED_ARTICLE,
KIND.RSS_FEED,
KIND.LONG_FORM_NOTE,
KIND.PUBLICATION_INDEX,
KIND.PUBLICATION_CONTENT,
KIND.WIKI_MARKDOWN,
KIND.WIKI_ASCIIDOC,
] as const;
export const KIND_METADATA: Record<number, KindMetadata> = {
[KIND.SHORT_TEXT_NOTE]: {
...KIND_LOOKUP[KIND.SHORT_TEXT_NOTE],
writable: true,
helpText: {
description: 'A simple plaintext note (NIP-10). The content property contains some human-readable text.',
suggestedTags: ['e (event references)', 'p (pubkey mentions)', 'q (quoted events)', 't (hashtags)']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.SHORT_TEXT_NOTE,
pubkey,
created_at: timestamp,
content: 'Hello nostr! This is a simple text note.',
tags: [
['e', eventId, relay],
['p', pubkey],
['t', 'nostr']
],
id: '...',
sig: '...'
})
},
[KIND.DISCUSSION_THREAD]: {
...KIND_LOOKUP[KIND.DISCUSSION_THREAD],
writable: true,
helpText: {
description: 'A thread (NIP-7D). A thread is a kind 11 event. Threads SHOULD include a title tag. Replies use kind 1111 comments (NIP-22).',
suggestedTags: ['title (required)', 't (topics/hashtags)']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.DISCUSSION_THREAD,
pubkey,
created_at: timestamp,
content: 'This is a discussion thread about a topic.',
tags: [
['title', 'Discussion Thread Title'],
['t', 'topic1'],
['t', 'topic2']
],
id: '...',
sig: '...'
})
},
[KIND.PICTURE_NOTE]: {
...KIND_LOOKUP[KIND.PICTURE_NOTE],
writable: true,
helpText: {
description: 'Picture-first feeds (NIP-68). Event kind 20 for picture-first clients. Images must be self-contained. They are hosted externally and referenced using imeta tags.',
suggestedTags: ['title', 'imeta (url, m, blurhash, dim, alt, x, fallback)', 'p (tagged users)', 'm (media type)', 'x (image hash)', 't (hashtags)', 'location', 'g (geohash)', 'L/l (language)', 'content-warning']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.PICTURE_NOTE,
pubkey,
created_at: timestamp,
content: 'A beautiful sunset photo',
tags: [
['title', 'Sunset Photo'],
['imeta', 'url https://nostr.build/i/image.jpg', 'm image/jpeg', 'dim 3024x4032', 'alt A scenic sunset'],
['t', 'photography'],
['location', 'San Francisco, CA']
],
id: '...',
sig: '...'
})
},
[KIND.VIDEO_NOTE]: {
...KIND_LOOKUP[KIND.VIDEO_NOTE],
writable: true,
helpText: {
description: 'Video Events (NIP-71). Normal videos representing a dedicated post of externally hosted content. The content is a summary or description on the video content.',
suggestedTags: ['title (required)', 'imeta (url, dim, m, image, fallback, service, bitrate, duration)', 'published_at', 'text-track', 'content-warning', 'alt', 'segment', 't (hashtags)', 'p (participants)', 'r (web references)']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.VIDEO_NOTE,
pubkey,
created_at: timestamp,
content: 'A detailed video about Nostr protocol',
tags: [
['title', 'Introduction to Nostr'],
['imeta', 'url https://example.com/video.mp4', 'dim 1920x1080', 'm video/mp4', 'duration 300', 'bitrate 3000000'],
['published_at', timestamp.toString()],
['t', 'tutorial']
],
id: '...',
sig: '...'
})
},
[KIND.SHORT_VIDEO_NOTE]: {
...KIND_LOOKUP[KIND.SHORT_VIDEO_NOTE],
writable: true,
helpText: {
description: 'Video Events (NIP-71). Short videos (stories/reels style) representing a dedicated post of externally hosted content. The content is a summary or description on the video content.',
suggestedTags: ['title (required)', 'imeta (url, dim, m, image, fallback, service, bitrate, duration)', 'published_at', 'text-track', 'content-warning', 'alt', 'segment', 't (hashtags)', 'p (participants)', 'r (web references)']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.SHORT_VIDEO_NOTE,
pubkey,
created_at: timestamp,
content: 'Quick video update',
tags: [
['title', 'Quick Update'],
['imeta', 'url https://example.com/short.mp4', 'dim 1080x1920', 'm video/mp4', 'duration 15'],
['published_at', timestamp.toString()]
],
id: '...',
sig: '...'
})
},
[KIND.PUBLIC_MESSAGE]: {
...KIND_LOOKUP[KIND.PUBLIC_MESSAGE],
writable: true,
helpText: {
description: 'Public Messages (NIP-A4). A simple plaintext message to one or more Nostr users. The content contains the message. p tags identify one or more receivers. Designed to be shown and replied to from notification screens.',
suggestedTags: ['p (receiver pubkeys, required)', 'expiration (recommended)', 'q (quoted events)', 'imeta (for image/video links)', 't (hashtags)']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.PUBLIC_MESSAGE,
pubkey,
created_at: timestamp,
content: 'Hello! This is a public message.',
tags: [
['p', pubkey],
['expiration', (timestamp + 86400).toString()],
['t', 'message']
],
id: '...',
sig: '...'
})
},
[KIND.POLL]: {
...KIND_LOOKUP[KIND.POLL],
writable: true,
helpText: {
description: 'Polls (NIP-88). The poll event is defined as a kind 1068 event. The content key holds the label for the poll.',
suggestedTags: ['option (optionId, label)', 'relay (one or more)', 'polltype (singlechoice|multiplechoice)', 'endsAt (unix timestamp)']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.POLL,
pubkey,
created_at: timestamp,
content: 'What is your favorite color?',
tags: [
['option', 'opt1', 'Red'],
['option', 'opt2', 'Blue'],
['option', 'opt3', 'Green'],
['relay', relay],
['polltype', 'singlechoice'],
['endsAt', (timestamp + 86400).toString()]
],
id: '...',
sig: '...'
})
},
[KIND.VOICE_NOTE]: {
...KIND_LOOKUP[KIND.VOICE_NOTE],
writable: true,
helpText: {
description: 'Voice Messages (NIP-A0). Root messages for short voice messages, typically up to 60 seconds in length. Content MUST be a URL pointing directly to an audio file (audio/mp4 recommended).',
suggestedTags: ['imeta (with url, waveform, duration)', 't (hashtags)', 'g (geohash)']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.VOICE_NOTE,
pubkey,
created_at: timestamp,
content: 'https://example.com/audio/voice-message.m4a',
tags: [
['imeta', 'url https://example.com/audio/voice-message.m4a', 'waveform 0 7 35 8 100 100 49', 'duration 8'],
['t', 'voice']
],
id: '...',
sig: '...'
})
},
[KIND.HIGHLIGHTED_ARTICLE]: {
...KIND_LOOKUP[KIND.HIGHLIGHTED_ARTICLE],
writable: true,
helpText: {
description: 'Highlights (NIP-84). A highlight event to signal content a user finds valuable. The content of these events is the highlighted portion of the text.',
suggestedTags: ['a (addressable event)', 'e (event reference)', 'r (URL reference)', 'p (author pubkeys)', 'context (surrounding text)', 'comment (for quote highlights)']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.HIGHLIGHTED_ARTICLE,
pubkey,
created_at: timestamp,
content: 'This is the highlighted text portion.',
tags: [
['e', eventId, relay],
['p', pubkey, '', 'author'],
['context', 'This is the highlighted text portion within the surrounding context text...']
],
id: '...',
sig: '...'
})
},
[KIND.RSS_FEED]: {
...KIND_LOOKUP[KIND.RSS_FEED],
writable: true,
requiresContent: false,
helpText: {
description: 'RSS Feed subscription event. Lists external RSS feeds to subscribe to. Content should be empty.',
suggestedTags: ['u (RSS feed URL, repeat for multiple feeds)']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.RSS_FEED,
pubkey,
created_at: timestamp,
content: '',
tags: [
['u', 'https://example.com/feed.rss'],
['u', 'https://another-site.com/rss.xml']
],
id: '...',
sig: '...'
})
},
[KIND.LONG_FORM_NOTE]: {
...KIND_LOOKUP[KIND.LONG_FORM_NOTE],
writable: true,
helpText: {
description: 'Long-form Content (NIP-23). Long-form text content, generally referred to as "articles" or "blog posts". The content should be a string text in Markdown syntax. Include a d tag for editability.',
suggestedTags: ['d (required for editability)', 'title', 'image', 'summary', 'published_at', 't (hashtags)']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.LONG_FORM_NOTE,
pubkey,
created_at: timestamp,
content: '# Long-form Article\n\nThis is a long-form article written in Markdown...',
tags: [
['d', 'article-slug'],
['title', 'My Long-form Article'],
['summary', 'A brief summary of the article'],
['published_at', timestamp.toString()],
['t', 'article'],
['t', 'longform']
],
id: '...',
sig: '...'
})
},
[KIND.PUBLICATION_INDEX]: {
...KIND_LOOKUP[KIND.PUBLICATION_INDEX],
writable: true,
requiresContent: false,
helpText: {
description: 'Publication Index (NKBIP-01). A publication index defines the structure and metadata of a publication. The content field MUST be empty.',
suggestedTags: ['d (required)', 'title (required)', 'a (referenced events)', 'auto-update (yes|ask|no)', 'p (original author)', 'E (original event)', 'source', 'version', 'type', 'author', 'i (ISBN)', 't (hashtags)', 'published_on', 'published_by', 'image', 'summary']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.PUBLICATION_INDEX,
pubkey,
created_at: timestamp,
content: '',
tags: [
['d', 'publication-slug'],
['title', 'My Publication'],
['author', 'Author Name'],
['summary', 'Publication summary'],
['type', 'book'],
['a', `30041:${pubkey}:chapter-1`, relay],
['a', `30041:${pubkey}:chapter-2`, relay],
['auto-update', 'ask']
],
id: '...',
sig: '...'
})
},
[KIND.PUBLICATION_CONTENT]: {
...KIND_LOOKUP[KIND.PUBLICATION_CONTENT],
writable: true,
helpText: {
description: 'Publication Content (NKBIP-01). Also known as sections, zettels, episodes, or chapters contain the actual content that makes up a publication. The content field MUST contain text meant for display to the end user and MAY contain AsciiDoc markup.',
suggestedTags: ['d (required)', 'title (required)', 'wikilink']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.PUBLICATION_CONTENT,
pubkey,
created_at: timestamp,
content: '= Chapter Title\n\nChapter content with [[wikilinks]]...',
tags: [
['d', 'publication-chapter-1'],
['title', 'Chapter 1: Introduction']
],
id: '...',
sig: '...'
})
},
[KIND.WIKI_MARKDOWN]: {
...KIND_LOOKUP[KIND.WIKI_MARKDOWN],
writable: true,
helpText: {
description: 'An AsciiDoc article. Similar to 30818 but may have different conventions.',
suggestedTags: ['d (identifier)', 'title', 'summary', 'a (addressable event)', 'e (event reference)']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.WIKI_MARKDOWN,
pubkey,
created_at: timestamp,
content: '= AsciiDoc Document\n\nContent in AsciiDoc format...',
tags: [
['d', 'asciidoc-doc'],
['title', 'AsciiDoc Document']
],
id: '...',
sig: '...'
})
},
[KIND.WIKI_ASCIIDOC]: {
...KIND_LOOKUP[KIND.WIKI_ASCIIDOC],
writable: true,
helpText: {
description: 'Wiki (NIP-54). Descriptions (or encyclopedia entries) of particular subjects. Articles are identified by lowercase, normalized d tags. The content should be Asciidoc with wikilinks and nostr:... links.',
suggestedTags: ['d (required, normalized)', 'title', 'summary', 'a (addressable event)', 'e (event reference)']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.WIKI_ASCIIDOC,
pubkey,
created_at: timestamp,
content: '= Wiki Article\n\nThis is a wiki article written in AsciiDoc.',
tags: [
['d', 'wiki-article'],
['title', 'Wiki Article'],
['summary', 'A brief summary']
],
id: '...',
sig: '...'
})
},
};
/**
* Get metadata for a kind, with fallback for unknown kinds
*/
export function getKindMetadata(kind: number): KindMetadata {
const metadata = KIND_METADATA[kind];
if (metadata) return metadata;
// Fallback for unknown kinds
const kindInfo = KIND_LOOKUP[kind] || { number: kind, description: `Kind ${kind}`, showInFeed: false };
return {
...kindInfo,
writable: false,
helpText: {
description: `Custom kind ${kind}. Refer to the relevant NIP specification for tag requirements.`,
suggestedTags: []
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind,
pubkey,
created_at: timestamp,
content: 'Custom event content',
tags: [['example', 'tag', 'value']],
id: '...',
sig: '...'
})
};
}
/**
* Get all writable kinds for the form
*/
export function getWritableKinds(): Array<{ value: number; label: string }> {
const writableKinds = WRITABLE_KINDS.map(kind => {
const metadata = KIND_METADATA[kind];
return {
value: kind,
label: `${kind} - ${metadata?.description || KIND_LOOKUP[kind]?.description || 'Unknown'}`
};
});
return [...writableKinds, { value: -1, label: 'Unknown Kind' }];
}

124
src/lib/utils/nostr-link-processor.ts

@ -0,0 +1,124 @@ @@ -0,0 +1,124 @@
/**
* Utility to process content and add "nostr:" prefix to valid Nostr bech32 addresses
*
* Processes naddr, nevent, note1, npub, and nprofile addresses that:
* - Are at the start of a line or preceded by whitespace
* - Are not part of a URL
* - Are valid, complete bech32 addresses (can be decoded)
*/
import { nip19 } from 'nostr-tools';
// Regex patterns for Nostr bech32 addresses
const NOSTR_ADDRESS_PATTERNS = {
naddr: /^naddr1[a-z0-9]+$/i,
nevent: /^nevent1[a-z0-9]+$/i,
note: /^note1[a-z0-9]+$/i,
npub: /^npub1[a-z0-9]+$/i,
nprofile: /^nprofile1[a-z0-9]+$/i,
};
// Combined pattern to match any Nostr address
// Matches addresses at start of line or after whitespace, followed by end of string, whitespace, or punctuation
const NOSTR_ADDRESS_REGEX = /(?:^|\s)(naddr1|nevent1|note1|npub1|nprofile1)([a-z0-9]+)(?=\s|$|[.,;:!?)\]}>])/gi;
// URL pattern to avoid matching addresses inside URLs
const URL_PATTERN = /https?:\/\/[^\s]+/gi;
/**
* Check if a bech32 string is valid and complete
*/
function isValidBech32(address: string): boolean {
try {
// Try to decode it - if it fails, it's invalid
const decoded = nip19.decode(address);
return decoded !== null;
} catch {
return false;
}
}
/**
* Check if a position in text is part of a URL
*/
function isInURL(text: string, matchStart: number, matchEnd: number): boolean {
// Find all URLs in the text
const urlMatches = Array.from(text.matchAll(URL_PATTERN));
for (const urlMatch of urlMatches) {
const urlStart = urlMatch.index!;
const urlEnd = urlStart + urlMatch[0].length;
// Check if our match overlaps with any URL
if (matchStart >= urlStart && matchStart < urlEnd) {
return true;
}
if (matchEnd > urlStart && matchEnd <= urlEnd) {
return true;
}
}
return false;
}
/**
* Process content to add "nostr:" prefix to valid Nostr addresses
*
* @param content - The content to process
* @returns Processed content with "nostr:" prefixes added
*/
export function processNostrLinks(content: string): string {
if (!content || typeof content !== 'string') {
return content;
}
// Find all potential Nostr addresses
const matches: Array<{ fullMatch: string; prefix: string; address: string; start: number; end: number }> = [];
let match;
const regex = new RegExp(NOSTR_ADDRESS_REGEX.source, NOSTR_ADDRESS_REGEX.flags);
while ((match = regex.exec(content)) !== null) {
const prefix = match[1]; // naddr1, nevent1, etc.
const bech32Data = match[2]; // The bech32 data part
const fullAddress = prefix + bech32Data;
const matchStart = match.index! + (match[0].indexOf(prefix)); // Start of the actual address
const matchEnd = matchStart + fullAddress.length;
// Check if it's already prefixed with "nostr:"
const beforeMatch = content.substring(Math.max(0, matchStart - 6), matchStart);
if (beforeMatch.toLowerCase().endsWith('nostr:')) {
continue; // Already has nostr: prefix
}
// Check if it's part of a URL
if (isInURL(content, matchStart, matchEnd)) {
continue; // Skip if it's in a URL
}
// Check if it's a valid, complete address
if (!isValidBech32(fullAddress)) {
continue; // Skip invalid addresses
}
matches.push({
fullMatch: match[0],
prefix,
address: fullAddress,
start: matchStart,
end: matchEnd,
});
}
// Process matches in reverse order to maintain correct indices
let processedContent = content;
for (let i = matches.length - 1; i >= 0; i--) {
const { address, start, end } = matches[i];
processedContent =
processedContent.substring(0, start) +
'nostr:' + address +
processedContent.substring(end);
}
return processedContent;
}

233
src/routes/find/+page.svelte

@ -13,13 +13,28 @@ @@ -13,13 +13,28 @@
let selectedKind = $state<number | null>(null);
let selectedKindString = $state<string>('');
let unifiedSearchComponent: { triggerSearch: () => void; getFilterResult: () => { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } } | null = $state(null);
let unifiedSearchComponent: { triggerSearch: () => void; getFilterResult: () => { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }; clearSearch: () => void } | null = $state(null);
let addressableSearchComponent: { clearSearch: () => void } | null = $state(null);
let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] });
let cacheResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
// Map to track which relay each event came from
const eventRelayMap = new Map<string, string>();
let searching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = $state(null);
// Check if there are any active searches or results
const hasActiveSearch = $derived.by(() => {
if (searching) return true;
if (searchResults.events.length > 0 || searchResults.profiles.length > 0) return true;
if (cacheResults.events.length > 0 || cacheResults.profiles.length > 0) return true;
const component = unifiedSearchComponent;
if (component) {
const filterResult = component.getFilterResult();
if (filterResult.value !== null) return true;
}
return false;
});
// Sync selectedKindString with selectedKind
$effect(() => {
selectedKindString = selectedKind?.toString() || '';
@ -62,27 +77,62 @@ @@ -62,27 +77,62 @@
}
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map<string, string> }) {
// Update results incrementally as they arrive
searchResults = results;
// Check if these are cache-only results (no relays or all events from cache)
const isCacheOnly = !results.relays || results.relays.length === 0 ||
(results.eventRelays && Array.from(results.eventRelays.values()).every(r => r === 'cache'));
if (isCacheOnly) {
// These are cache results
cacheResults = { events: results.events, profiles: results.profiles };
} else {
// These are relay results (from button click)
searchResults = results;
// Update eventRelayMap with relay information from results
if (results.eventRelays) {
for (const [eventId, relay] of results.eventRelays) {
eventRelayMap.set(eventId, relay);
}
}
// Update eventRelayMap with relay information from results
if (results.eventRelays) {
for (const [eventId, relay] of results.eventRelays) {
eventRelayMap.set(eventId, relay);
// Clear timeout when results arrive (search is complete)
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
searching = false;
}
}
}
// Get all kinds for dropdown (sorted by number)
const allKinds = Object.values(KIND_LOOKUP).sort((a, b) => a.number - b.number);
function clearAllSearches() {
// Clear UnifiedSearch
if (unifiedSearchComponent) {
unifiedSearchComponent.clearSearch();
}
// Clear SearchAddressableEvents
if (addressableSearchComponent) {
addressableSearchComponent.clearSearch();
}
// Clear local state
searchResults = { events: [], profiles: [] };
cacheResults = { events: [], profiles: [] };
eventRelayMap.clear();
searching = false;
selectedKind = null;
selectedKindString = '';
// Clear timeout when results arrive (search is complete)
// Clear timeouts
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
searching = false;
}
}
// Get all kinds for dropdown (sorted by number)
const allKinds = Object.values(KIND_LOOKUP).sort((a, b) => a.number - b.number);
onMount(async () => {
await nostrClient.initialize();
});
@ -92,7 +142,18 @@ @@ -92,7 +142,18 @@
<main class="container mx-auto px-4 py-8">
<div class="find-page">
<h1 class="font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Find</h1>
<div class="page-header">
<h1 class="font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Find</h1>
{#if hasActiveSearch}
<button
class="clear-button"
onclick={clearAllSearches}
aria-label="Clear all searches"
>
Clear
</button>
{/if}
</div>
<div class="find-sections">
<section class="find-section">
@ -140,48 +201,84 @@ @@ -140,48 +201,84 @@
</div>
</section>
{#if searchResults.events.length > 0 || searchResults.profiles.length > 0}
{#if cacheResults.events.length > 0 || cacheResults.profiles.length > 0 || searchResults.events.length > 0 || searchResults.profiles.length > 0}
<section class="results-section">
<h2>Search Results</h2>
{#if searchResults.profiles.length > 0}
{#if cacheResults.events.length > 0 || cacheResults.profiles.length > 0}
<div class="results-group">
<h3>Profiles</h3>
<div class="profile-results">
{#each searchResults.profiles as pubkey}
<a href="/profile/{pubkey}" class="profile-result-card">
<ProfileBadge pubkey={pubkey} />
</a>
{/each}
</div>
<h3>Found in Cache:</h3>
{#if cacheResults.profiles.length > 0}
<div class="profile-results">
{#each cacheResults.profiles as pubkey}
<a href="/profile/{pubkey}" class="profile-result-card">
<ProfileBadge pubkey={pubkey} />
<CacheBadge />
</a>
{/each}
</div>
{/if}
{#if cacheResults.events.length > 0}
<div class="event-results">
{#each cacheResults.events as event}
<div class="event-result-card">
<a href="/event/{event.id}" class="event-result-link">
<FeedPost post={event} fullView={false} />
</a>
<div class="event-relay-badge">
<CacheBadge />
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{#if searchResults.events.length > 0}
{#if searchResults.events.length > 0 || searchResults.profiles.length > 0}
<div class="results-group">
<h3>Events ({searchResults.events.length})</h3>
<div class="event-results">
{#each searchResults.events as event}
<div class="event-result-card">
<a href="/event/{event.id}" class="event-result-link">
<FeedPost post={event} fullView={false} />
{#if cacheResults.events.length > 0 || cacheResults.profiles.length > 0}
<h3>Results from Relays:</h3>
{:else}
<h3>Search Results</h3>
{/if}
{#if searchResults.profiles.length > 0}
<div class="profile-results">
{#each searchResults.profiles as pubkey}
<a href="/profile/{pubkey}" class="profile-result-card">
<ProfileBadge pubkey={pubkey} />
</a>
{#if eventRelayMap.has(event.id)}
<div class="event-relay-badge">
{#if eventRelayMap.get(event.id) === 'cache'}
<CacheBadge />
{:else}
<RelayBadge relayUrl={eventRelayMap.get(event.id)!} />
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/each}
</div>
{/if}
{#if searchResults.events.length > 0}
<div class="event-results">
{#each searchResults.events as event}
<div class="event-result-card">
<a href="/event/{event.id}" class="event-result-link">
<FeedPost post={event} fullView={false} />
</a>
{#if eventRelayMap.has(event.id)}
<div class="event-relay-badge">
{#if eventRelayMap.get(event.id) === 'cache'}
<CacheBadge />
{:else}
<RelayBadge relayUrl={eventRelayMap.get(event.id)!} />
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
</section>
{:else if !searching && (unifiedSearchComponent?.getFilterResult()?.value)}
{:else if !searching && unifiedSearchComponent && unifiedSearchComponent.getFilterResult().value}
<section class="results-section">
<div class="no-results">
No results found on the relays: {searchResults.relays && searchResults.relays.length > 0 ? searchResults.relays.join(', ') : 'No relays available'}
@ -190,7 +287,7 @@ @@ -190,7 +287,7 @@
{/if}
<section class="find-section">
<SearchAddressableEvents />
<SearchAddressableEvents bind:this={addressableSearchComponent} />
</section>
</div>
</div>
@ -203,6 +300,54 @@ @@ -203,6 +300,54 @@
padding: 0 1rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
}
.clear-button {
padding: 0.5rem 1rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #475569);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
transition: all 0.2s;
}
:global(.dark) .clear-button {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #cbd5e1);
border-color: var(--fog-dark-border, #475569);
}
.clear-button:hover {
background: var(--fog-border, #e5e7eb);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .clear-button:hover {
background: var(--fog-dark-border, #475569);
border-color: var(--fog-dark-accent, #94a3b8);
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
}
.clear-button {
width: 100%;
}
}
.find-sections {
display: flex;
flex-direction: column;

Loading…
Cancel
Save