Browse Source

Merge branch 'master' into issue#137#118-table-of-contents

master
buttercat1791 10 months ago
parent
commit
0de61fb83e
  1. 1
      .gitignore
  2. 3
      .onedev-buildspec.yml
  3. 18
      .vscode/settings.json
  4. 8
      README.md
  5. 403
      deno.lock
  6. 2
      import_map.json
  7. 2219
      package-lock.json
  8. 11
      package.json
  9. 62
      src/app.css
  10. 317
      src/lib/components/CommentBox.svelte
  11. 184
      src/lib/components/EventDetails.svelte
  12. 204
      src/lib/components/EventSearch.svelte
  13. 9
      src/lib/components/Login.svelte
  14. 41
      src/lib/components/Navigation.svelte
  15. 116
      src/lib/components/Preview.svelte
  16. 342
      src/lib/components/Publication.svelte
  17. 247
      src/lib/components/PublicationFeed.svelte
  18. 9
      src/lib/components/PublicationHeader.svelte
  19. 5
      src/lib/components/PublicationSection.svelte
  20. 190
      src/lib/components/RelayActions.svelte
  21. 59
      src/lib/components/RelayDisplay.svelte
  22. 70
      src/lib/components/cards/BlogHeader.svelte
  23. 120
      src/lib/components/cards/ProfileHeader.svelte
  24. 148
      src/lib/components/util/ArticleNav.svelte
  25. 228
      src/lib/components/util/CardActions.svelte
  26. 33
      src/lib/components/util/CopyToClipboard.svelte
  27. 110
      src/lib/components/util/Details.svelte
  28. 59
      src/lib/components/util/InlineProfile.svelte
  29. 93
      src/lib/components/util/Interactions.svelte
  30. 2
      src/lib/components/util/Profile.svelte
  31. 17
      src/lib/components/util/QrCode.svelte
  32. 143
      src/lib/components/util/TocToggle.svelte
  33. 19
      src/lib/components/util/ZapOutline.svelte
  34. 12
      src/lib/consts.ts
  35. 7
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  36. 8
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  37. 6
      src/lib/ndk.ts
  38. 87
      src/lib/parser.ts
  39. 19
      src/lib/snippets/UserSnippets.svelte
  40. 23
      src/lib/stores.ts
  41. 4
      src/lib/stores/relayStore.ts
  42. 41
      src/lib/utils.ts
  43. 2
      src/lib/utils/markup/MarkupInfo.md
  44. 2
      src/lib/utils/markup/basicMarkupParser.ts
  45. 2
      src/lib/utils/mime.ts
  46. 318
      src/lib/utils/nostrUtils.ts
  47. 4
      src/lib/utils/npubCache.ts
  48. 16
      src/routes/+layout.svelte
  49. 38
      src/routes/+page.svelte
  50. 1
      src/routes/[...catchall]/+page.svelte
  51. 181
      src/routes/about/+page.svelte
  52. 11
      src/routes/contact/+page.svelte
  53. 79
      src/routes/events/+page.svelte
  54. 3
      src/routes/new/compose/+page.svelte
  55. 3
      src/routes/new/edit/+page.svelte
  56. 63
      src/routes/publication/+page.svelte
  57. 3
      src/routes/publication/+page.ts
  58. 177
      src/routes/start/+page.svelte
  59. 3
      src/routes/visualize/+page.svelte
  60. 8
      src/styles/base.css
  61. 5
      src/styles/events.css
  62. 60
      src/styles/publications.css
  63. 20
      src/styles/scrollbar.css
  64. BIN
      static/screenshots/ToC_blog.png
  65. BIN
      static/screenshots/ToC_normal.png
  66. 18
      tailwind.config.cjs
  67. 2
      test_data/AsciidocFiles/21lessons.adoc
  68. 2
      test_data/AsciidocFiles/Rauhnaechte.adoc
  69. 8
      tests/integration/markupIntegration.test.ts
  70. 8
      tests/integration/markupTestfile.md
  71. 2
      tests/unit/advancedMarkupParser.test.ts
  72. 2
      tests/unit/basicMarkupParser.test.ts
  73. 5
      vite.config.ts

1
.gitignore vendored

@ -8,7 +8,6 @@ node_modules @@ -8,7 +8,6 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
package-lock.json
# tests
/tests/e2e/html-report/*.html

3
.onedev-buildspec.yml

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
version: 38
version: 39
jobs:
- name: Github Push
steps:
@ -10,6 +10,7 @@ jobs: @@ -10,6 +10,7 @@ jobs:
condition: ALL_PREVIOUS_STEPS_WERE_SUCCESSFUL
triggers:
- !BranchUpdateTrigger {}
- !TagCreateTrigger {}
retryCondition: never
maxRetries: 3
retryDelay: 30

18
.vscode/settings.json vendored

@ -1,6 +1,14 @@ @@ -1,6 +1,14 @@
{
"editor.tabSize": 2,
"files.associations": {
"*.css": "postcss"
}
}
"css.validate": false,
"tailwindCSS.includeLanguages": {
"svelte": "html",
"typescript": "javascript",
"javascript": "javascript"
},
"editor.quickSuggestions": {
"strings": true
},
"files.associations": {
"*.svelte": "svelte"
}
}

8
README.md

@ -5,11 +5,13 @@ @@ -5,11 +5,13 @@
Alexandria is a reader and writer for curated publications, including e-books.
For a thorough introduction, please refer to our [project documention](https://next-alexandria.gitcitadel.eu/publication?d=gitcitadel-project-documentation-by-stella-v-1), viewable on Alexandria, or to the Alexandria [About page](https://next-alexandria.gitcitadel.eu/about).
It also contains a [universal event viewer](https://next-alexandria.gitcitadel.eu/events), with which you can search our relays, some aggregator relays, and your own relay list, to find and view event data.
## Issues and Patches
If you would like to suggest a feature or report a bug, please use the [Alexandria Contact page](https://next-alexandria.gitcitadel.eu/contact).
You can also contact us [on Nostr](https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg), directly.
You can also contact us [on Nostr](https://next-alexandria.gitcitadel.eu/events?id=nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg), directly.
## Developing
@ -73,7 +75,7 @@ To run the container, in detached mode (-d): @@ -73,7 +75,7 @@ To run the container, in detached mode (-d):
docker run -d --rm --name=gc-alexandria -p 4174:80 gc-alexandria
```
The container is then viewable on your [local machine](http://localhost:4174).
The container is then viewable on your [local machine](http://localhost:4173).
If you want to see the container process (assuming it's the last process to start), enter:
@ -118,4 +120,4 @@ npx playwright test @@ -118,4 +120,4 @@ npx playwright test
## Markup Support
Alexandria supports both Markdown and AsciiDoc markup for different content types. For a detailed list of supported tags and features in the basic and advanced markdown parsers, as well as information about AsciiDoc usage for publications and wikis, see [MarkupInfo.md](src/lib/utils/markup/MarkupInfo.md).
Alexandria supports both Markdown and AsciiDoc markup for different content types. For a detailed list of supported tags and features in the basic and advanced markdown parsers, as well as information about AsciiDoc usage for publications and wikis, see [MarkupInfo.md](./src/lib/utils/markup/MarkupInfo.md).

403
deno.lock

@ -9,19 +9,26 @@ @@ -9,19 +9,26 @@
"npm:@sveltejs/adapter-node@^5.2.12": "5.2.12_@sveltejs+kit@2.17.3__@sveltejs+vite-plugin-svelte@4.0.4___svelte@5.21.0____acorn@8.14.0___vite@5.4.14____@types+node@22.13.9___@types+node@22.13.9__svelte@5.21.0___acorn@8.14.0__vite@5.4.14___@types+node@22.13.9__@types+node@22.13.9_rollup@4.34.9_@sveltejs+vite-plugin-svelte@4.0.4__svelte@5.21.0___acorn@8.14.0__vite@5.4.14___@types+node@22.13.9__@types+node@22.13.9_svelte@5.21.0__acorn@8.14.0_vite@5.4.14__@types+node@22.13.9_@types+node@22.13.9",
"npm:@sveltejs/adapter-static@3": "3.0.8_@sveltejs+kit@2.17.3__@sveltejs+vite-plugin-svelte@4.0.4___svelte@5.21.0____acorn@8.14.0___vite@5.4.14____@types+node@22.13.9___@types+node@22.13.9__svelte@5.21.0___acorn@8.14.0__vite@5.4.14___@types+node@22.13.9__@types+node@22.13.9_@sveltejs+vite-plugin-svelte@4.0.4__svelte@5.21.0___acorn@8.14.0__vite@5.4.14___@types+node@22.13.9__@types+node@22.13.9_svelte@5.21.0__acorn@8.14.0_vite@5.4.14__@types+node@22.13.9_@types+node@22.13.9",
"npm:@sveltejs/kit@2": "2.17.3_@sveltejs+vite-plugin-svelte@4.0.4__svelte@5.21.0___acorn@8.14.0__vite@5.4.14___@types+node@22.13.9__@types+node@22.13.9_svelte@5.21.0__acorn@8.14.0_vite@5.4.14__@types+node@22.13.9_@types+node@22.13.9",
"npm:@sveltejs/kit@^2.16.0": "2.17.3_@sveltejs+vite-plugin-svelte@4.0.4__svelte@5.21.0___acorn@8.14.0__vite@5.4.14___@types+node@22.13.9__@types+node@22.13.9_svelte@5.21.0__acorn@8.14.0_vite@5.4.14__@types+node@22.13.9_@types+node@22.13.9",
"npm:@sveltejs/vite-plugin-svelte@4": "4.0.4_svelte@5.21.0__acorn@8.14.0_vite@5.4.14__@types+node@22.13.9_@types+node@22.13.9",
"npm:@tailwindcss/forms@0.5": "0.5.10_tailwindcss@3.4.17__postcss@8.5.3",
"npm:@tailwindcss/typography@0.5": "0.5.16_tailwindcss@3.4.17__postcss@8.5.3",
"npm:@types/d3@^7.4.3": "7.4.3",
"npm:@types/he@1.2": "1.2.3",
"npm:@types/node@22": "22.13.9",
"npm:asciidoctor@3.0": "3.0.4_@asciidoctor+core@3.0.4",
"npm:autoprefixer@10": "10.4.20_postcss@8.5.3",
"npm:d3@7.9": "7.9.0_d3-selection@3.0.0",
"npm:d3@^7.9.0": "7.9.0_d3-selection@3.0.0",
"npm:eslint-plugin-svelte@2": "2.46.1_eslint@9.21.0_svelte@5.21.0__acorn@8.14.0_postcss@8.5.3",
"npm:flowbite-svelte-icons@2": "2.0.2_svelte@5.21.0__acorn@8.14.0_tailwind-merge@2.6.0_tailwindcss@3.4.17__postcss@8.5.3",
"npm:flowbite-svelte-icons@2.1": "2.1.1_svelte@5.0.5__acorn@8.14.0_tailwind-merge@3.3.0",
"npm:flowbite-svelte@0": "0.48.4_svelte@5.21.0__acorn@8.14.0",
"npm:flowbite-svelte@0.44": "0.44.24_svelte@4.2.19",
"npm:flowbite@2": "2.5.2",
"npm:flowbite@2.2": "2.2.1",
"npm:he@1.2": "1.2.0",
"npm:highlight.js@^11.11.1": "11.11.1",
"npm:node-emoji@^2.2.0": "2.2.0",
"npm:nostr-tools@2.10": "2.10.4_typescript@5.7.3",
"npm:playwright@^1.50.1": "1.50.1",
"npm:postcss-load-config@6": "6.0.1_postcss@8.5.3",
@ -30,12 +37,14 @@ @@ -30,12 +37,14 @@
"npm:prettier@3": "3.5.3",
"npm:svelte-check@4": "4.1.4_svelte@5.21.0__acorn@8.14.0_typescript@5.7.3",
"npm:svelte@5": "5.21.0_acorn@8.14.0",
"npm:tailwind-merge@^2.5.5": "2.6.0",
"npm:svelte@5.0": "5.0.5_acorn@8.14.0",
"npm:tailwind-merge@2.5": "2.5.5",
"npm:tailwind-merge@^3.3.0": "3.3.0",
"npm:tailwindcss@3": "3.4.17_postcss@8.5.3",
"npm:tslib@2.8": "2.8.1",
"npm:typescript@5.7": "5.7.3",
"npm:vite@5": "5.4.14_@types+node@22.13.9",
"npm:vitest@^3.0.5": "3.0.7_@types+node@22.13.9_vite@5.4.14__@types+node@22.13.9"
"npm:vitest@^3.1.3": "3.1.4_@types+node@22.13.9_vite@5.4.14__@types+node@22.13.9"
},
"npm": {
"@alloc/quick-lru@5.2.0": {
@ -391,7 +400,7 @@ @@ -391,7 +400,7 @@
"@rollup/pluginutils@5.1.4_rollup@4.34.9",
"commondir",
"estree-walker@2.0.2",
"fdir",
"fdir@6.4.3_picomatch@4.0.2",
"is-reference@1.2.1",
"magic-string",
"picomatch@4.0.2",
@ -521,6 +530,9 @@ @@ -521,6 +530,9 @@
"@scure/base@1.1.1"
]
},
"@sindresorhus/is@4.6.0": {
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="
},
"@sveltejs/adapter-auto@3.3.1_@sveltejs+kit@2.17.3__@sveltejs+vite-plugin-svelte@4.0.4___svelte@5.21.0____acorn@8.14.0___vite@5.4.14____@types+node@22.13.9___@types+node@22.13.9__svelte@5.21.0___acorn@8.14.0__vite@5.4.14___@types+node@22.13.9__@types+node@22.13.9_@sveltejs+vite-plugin-svelte@4.0.4__svelte@5.21.0___acorn@8.14.0__vite@5.4.14___@types+node@22.13.9__@types+node@22.13.9_svelte@5.21.0__acorn@8.14.0_vite@5.4.14__@types+node@22.13.9_@types+node@22.13.9": {
"integrity": "sha512-5Sc7WAxYdL6q9j/+D0jJKjGREGlfIevDyHSQ2eNETHcB1TKlQWHcAo8AS8H1QdjNvSXpvOwNjykDUHPEAyGgdQ==",
"dependencies": [
@ -559,7 +571,7 @@ @@ -559,7 +571,7 @@
"sade",
"set-cookie-parser",
"sirv",
"svelte",
"svelte@5.21.0_acorn@8.14.0",
"vite"
]
},
@ -568,7 +580,7 @@ @@ -568,7 +580,7 @@
"dependencies": [
"@sveltejs/vite-plugin-svelte",
"debug@4.4.0",
"svelte",
"svelte@5.21.0_acorn@8.14.0",
"vite"
]
},
@ -580,7 +592,7 @@ @@ -580,7 +592,7 @@
"deepmerge",
"kleur",
"magic-string",
"svelte",
"svelte@5.21.0_acorn@8.14.0",
"vite",
"vitefu"
]
@ -605,9 +617,172 @@ @@ -605,9 +617,172 @@
"@types/cookie@0.6.0": {
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
"@types/d3-array@3.2.1": {
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
},
"@types/d3-axis@3.0.6": {
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"dependencies": [
"@types/d3-selection"
]
},
"@types/d3-brush@3.0.6": {
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"dependencies": [
"@types/d3-selection"
]
},
"@types/d3-chord@3.0.6": {
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="
},
"@types/d3-color@3.1.3": {
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"@types/d3-contour@3.0.6": {
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"dependencies": [
"@types/d3-array",
"@types/geojson"
]
},
"@types/d3-delaunay@6.0.4": {
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="
},
"@types/d3-dispatch@3.0.6": {
"integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ=="
},
"@types/d3-drag@3.0.7": {
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"dependencies": [
"@types/d3-selection"
]
},
"@types/d3-dsv@3.0.7": {
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="
},
"@types/d3-ease@3.0.2": {
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"@types/d3-fetch@3.0.7": {
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"dependencies": [
"@types/d3-dsv"
]
},
"@types/d3-force@3.0.10": {
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="
},
"@types/d3-format@3.0.4": {
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="
},
"@types/d3-geo@3.1.0": {
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"dependencies": [
"@types/geojson"
]
},
"@types/d3-hierarchy@3.1.7": {
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="
},
"@types/d3-interpolate@3.0.4": {
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": [
"@types/d3-color"
]
},
"@types/d3-path@3.1.1": {
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"@types/d3-polygon@3.0.2": {
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="
},
"@types/d3-quadtree@3.0.6": {
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="
},
"@types/d3-random@3.0.3": {
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="
},
"@types/d3-scale-chromatic@3.1.0": {
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="
},
"@types/d3-scale@4.0.9": {
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": [
"@types/d3-time"
]
},
"@types/d3-selection@3.0.11": {
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="
},
"@types/d3-shape@3.1.7": {
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"dependencies": [
"@types/d3-path"
]
},
"@types/d3-time-format@4.0.3": {
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="
},
"@types/d3-time@3.0.4": {
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"@types/d3-timer@3.0.2": {
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"@types/d3-transition@3.0.9": {
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"dependencies": [
"@types/d3-selection"
]
},
"@types/d3-zoom@3.0.8": {
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"dependencies": [
"@types/d3-interpolate",
"@types/d3-selection"
]
},
"@types/d3@7.4.3": {
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"dependencies": [
"@types/d3-array",
"@types/d3-axis",
"@types/d3-brush",
"@types/d3-chord",
"@types/d3-color",
"@types/d3-contour",
"@types/d3-delaunay",
"@types/d3-dispatch",
"@types/d3-drag",
"@types/d3-dsv",
"@types/d3-ease",
"@types/d3-fetch",
"@types/d3-force",
"@types/d3-format",
"@types/d3-geo",
"@types/d3-hierarchy",
"@types/d3-interpolate",
"@types/d3-path",
"@types/d3-polygon",
"@types/d3-quadtree",
"@types/d3-random",
"@types/d3-scale",
"@types/d3-scale-chromatic",
"@types/d3-selection",
"@types/d3-shape",
"@types/d3-time",
"@types/d3-time-format",
"@types/d3-timer",
"@types/d3-transition",
"@types/d3-zoom"
]
},
"@types/estree@1.0.6": {
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="
},
"@types/geojson@7946.0.16": {
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="
},
"@types/he@1.2.3": {
"integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA=="
},
@ -623,8 +798,8 @@ @@ -623,8 +798,8 @@
"@types/resolve@1.20.2": {
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="
},
"@vitest/expect@3.0.7": {
"integrity": "sha512-QP25f+YJhzPfHrHfYHtvRn+uvkCFCqFtW9CktfBxmB+25QqWsx7VB2As6f4GmwllHLDhXNHvqedwhvMmSnNmjw==",
"@vitest/expect@3.1.4": {
"integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==",
"dependencies": [
"@vitest/spy",
"@vitest/utils",
@ -632,8 +807,8 @@ @@ -632,8 +807,8 @@
"tinyrainbow"
]
},
"@vitest/mocker@3.0.7_vite@5.4.14__@types+node@22.13.9_@types+node@22.13.9": {
"integrity": "sha512-qui+3BLz9Eonx4EAuR/i+QlCX6AUZ35taDQgwGkK/Tw6/WgwodSrjN1X2xf69IA/643ZX5zNKIn2svvtZDrs4w==",
"@vitest/mocker@3.1.4_vite@5.4.14__@types+node@22.13.9_@types+node@22.13.9": {
"integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==",
"dependencies": [
"@vitest/spy",
"estree-walker@3.0.3",
@ -641,35 +816,35 @@ @@ -641,35 +816,35 @@
"vite"
]
},
"@vitest/pretty-format@3.0.7": {
"integrity": "sha512-CiRY0BViD/V8uwuEzz9Yapyao+M9M008/9oMOSQydwbwb+CMokEq3XVaF3XK/VWaOK0Jm9z7ENhybg70Gtxsmg==",
"@vitest/pretty-format@3.1.4": {
"integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==",
"dependencies": [
"tinyrainbow"
]
},
"@vitest/runner@3.0.7": {
"integrity": "sha512-WeEl38Z0S2ZcuRTeyYqaZtm4e26tq6ZFqh5y8YD9YxfWuu0OFiGFUbnxNynwLjNRHPsXyee2M9tV7YxOTPZl2g==",
"@vitest/runner@3.1.4": {
"integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==",
"dependencies": [
"@vitest/utils",
"pathe"
]
},
"@vitest/snapshot@3.0.7": {
"integrity": "sha512-eqTUryJWQN0Rtf5yqCGTQWsCFOQe4eNz5Twsu21xYEcnFJtMU5XvmG0vgebhdLlrHQTSq5p8vWHJIeJQV8ovsA==",
"@vitest/snapshot@3.1.4": {
"integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==",
"dependencies": [
"@vitest/pretty-format",
"magic-string",
"pathe"
]
},
"@vitest/spy@3.0.7": {
"integrity": "sha512-4T4WcsibB0B6hrKdAZTM37ekuyFZt2cGbEGd2+L0P8ov15J1/HUsUaqkXEQPNAWr4BtPPe1gI+FYfMHhEKfR8w==",
"@vitest/spy@3.1.4": {
"integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==",
"dependencies": [
"tinyspy"
]
},
"@vitest/utils@3.0.7": {
"integrity": "sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg==",
"@vitest/utils@3.1.4": {
"integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==",
"dependencies": [
"@vitest/pretty-format",
"loupe",
@ -882,6 +1057,9 @@ @@ -882,6 +1057,9 @@
"supports-color"
]
},
"char-regex@1.0.2": {
"integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="
},
"character-parser@2.2.0": {
"integrity": "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==",
"dependencies": [
@ -921,6 +1099,16 @@ @@ -921,6 +1099,16 @@
"clsx@2.1.1": {
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
},
"code-red@1.0.4": {
"integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
"dependencies": [
"@jridgewell/sourcemap-codec",
"@types/estree",
"acorn@8.14.0",
"estree-walker@3.0.3",
"periscopic"
]
},
"color-convert@2.0.1": {
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": [
@ -963,6 +1151,13 @@ @@ -963,6 +1151,13 @@
"which"
]
},
"css-tree@2.3.1": {
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"dependencies": [
"mdn-data",
"source-map-js"
]
},
"cssesc@3.0.0": {
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="
},
@ -1243,14 +1438,17 @@ @@ -1243,14 +1438,17 @@
"emoji-regex@9.2.2": {
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
},
"emojilib@2.4.0": {
"integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="
},
"es-define-property@1.0.1": {
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
},
"es-errors@1.3.0": {
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
},
"es-module-lexer@1.6.0": {
"integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ=="
"es-module-lexer@1.7.0": {
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="
},
"es-object-atoms@1.1.1": {
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
@ -1337,7 +1535,7 @@ @@ -1337,7 +1535,7 @@
"postcss-safe-parser",
"postcss-selector-parser@6.1.2",
"semver",
"svelte",
"svelte@5.21.0_acorn@8.14.0",
"svelte-eslint-parser"
]
},
@ -1468,8 +1666,8 @@ @@ -1468,8 +1666,8 @@
"es5-ext"
]
},
"expect-type@1.2.0": {
"integrity": "sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA=="
"expect-type@1.2.1": {
"integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw=="
},
"ext@1.7.0": {
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
@ -1508,6 +1706,12 @@ @@ -1508,6 +1706,12 @@
"picomatch@4.0.2"
]
},
"fdir@6.4.4_picomatch@4.0.2": {
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
"dependencies": [
"picomatch@4.0.2"
]
},
"file-entry-cache@8.0.0": {
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dependencies": [
@ -1550,12 +1754,28 @@ @@ -1550,12 +1754,28 @@
"flowbite@2.5.2"
]
},
"flowbite-svelte-icons@2.0.2_svelte@5.21.0__acorn@8.14.0_tailwind-merge@2.6.0_tailwindcss@3.4.17__postcss@8.5.3": {
"integrity": "sha512-Vkmduy2867Rk8R7TziPirsWkixJnToFBEXRaN4ouJabOx62NQjiBbHFe+HTaMOQmdp4FNMI2Nhtk2I2CQ8r3RQ==",
"flowbite-svelte-icons@2.1.1_svelte@5.0.5__acorn@8.14.0_tailwind-merge@3.3.0": {
"integrity": "sha512-VNNMcekjbM1bQEGgbdGsdYR9mRdTj/L0A5ba0P1tiFv5QB9GvbvJMABJoiD80eqpZUkfR2QVOmiZfgCwHicT/Q==",
"dependencies": [
"svelte",
"tailwind-merge@2.6.0",
"tailwindcss"
"svelte@5.0.5_acorn@8.14.0",
"tailwind-merge@3.3.0"
]
},
"flowbite-svelte-icons@2.1.1_svelte@5.21.0__acorn@8.14.0_tailwind-merge@3.3.0": {
"integrity": "sha512-VNNMcekjbM1bQEGgbdGsdYR9mRdTj/L0A5ba0P1tiFv5QB9GvbvJMABJoiD80eqpZUkfR2QVOmiZfgCwHicT/Q==",
"dependencies": [
"svelte@5.21.0_acorn@8.14.0",
"tailwind-merge@3.3.0"
]
},
"flowbite-svelte@0.44.24_svelte@4.2.19": {
"integrity": "sha512-kXhJZHGpBVq5RFOoYnzRCEM8eFa81DVp4KjUbBsLJptKhizbSSBJuYApWIQb9pBCS8EBhX4PAX+RsgEDZfEqtA==",
"dependencies": [
"@floating-ui/dom",
"apexcharts",
"flowbite@2.5.2",
"svelte@4.2.19",
"tailwind-merge@2.5.5"
]
},
"flowbite-svelte@0.48.4_svelte@5.21.0__acorn@8.14.0": {
@ -1564,10 +1784,17 @@ @@ -1564,10 +1784,17 @@
"@floating-ui/dom",
"apexcharts",
"flowbite@3.1.2",
"svelte",
"svelte@5.21.0_acorn@8.14.0",
"tailwind-merge@3.0.2"
]
},
"flowbite@2.2.1": {
"integrity": "sha512-iiZyBTtriEDRHrqXZgpKHaxl4B2J8HZUP8Yn1RXozUDKszWHDVj4GxQqMMB9AJHRWOgXV/4E/LJZ/zqQgBUhWA==",
"dependencies": [
"@popperjs/core",
"mini-svg-data-uri"
]
},
"flowbite@2.5.2": {
"integrity": "sha512-kwFD3n8/YW4EG8GlY3Od9IoKND97kitO+/ejISHSqpn3vw2i5K/+ZI8Jm2V+KC4fGdnfi0XZ+TzYqQb4Q1LshA==",
"dependencies": [
@ -1702,6 +1929,9 @@ @@ -1702,6 +1929,9 @@
"he@1.2.0": {
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
},
"highlight.js@11.11.1": {
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="
},
"iconv-lite@0.6.3": {
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dependencies": [
@ -1915,6 +2145,9 @@ @@ -1915,6 +2145,9 @@
"math-intrinsics@1.1.0": {
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
},
"mdn-data@2.0.30": {
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
},
"merge2@1.4.1": {
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="
},
@ -1984,6 +2217,15 @@ @@ -1984,6 +2217,15 @@
"next-tick@1.1.0": {
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="
},
"node-emoji@2.2.0": {
"integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==",
"dependencies": [
"@sindresorhus/is",
"char-regex",
"emojilib",
"skin-tone"
]
},
"node-gyp-build@4.8.4": {
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="
},
@ -2086,6 +2328,14 @@ @@ -2086,6 +2328,14 @@
"pathval@2.0.0": {
"integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA=="
},
"periscopic@3.1.0": {
"integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
"dependencies": [
"@types/estree",
"estree-walker@3.0.3",
"is-reference@3.0.3"
]
},
"picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
@ -2201,7 +2451,7 @@ @@ -2201,7 +2451,7 @@
"integrity": "sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==",
"dependencies": [
"prettier",
"svelte"
"svelte@5.21.0_acorn@8.14.0"
]
},
"prettier@3.5.3": {
@ -2415,6 +2665,12 @@ @@ -2415,6 +2665,12 @@
"totalist"
]
},
"skin-tone@2.0.0": {
"integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==",
"dependencies": [
"unicode-emoji-modifier-base"
]
},
"source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
},
@ -2424,8 +2680,8 @@ @@ -2424,8 +2680,8 @@
"stackback@0.0.2": {
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="
},
"std-env@3.8.1": {
"integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA=="
"std-env@3.9.0": {
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="
},
"string-width@4.2.3": {
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
@ -2484,10 +2740,10 @@ @@ -2484,10 +2740,10 @@
"dependencies": [
"@jridgewell/trace-mapping",
"chokidar@4.0.3",
"fdir",
"fdir@6.4.3_picomatch@4.0.2",
"picocolors",
"sade",
"svelte",
"svelte@5.21.0_acorn@8.14.0",
"typescript"
]
},
@ -2499,7 +2755,44 @@ @@ -2499,7 +2755,44 @@
"espree@9.6.1_acorn@8.14.0",
"postcss",
"postcss-scss",
"svelte"
"svelte@5.21.0_acorn@8.14.0"
]
},
"svelte@4.2.19": {
"integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==",
"dependencies": [
"@ampproject/remapping",
"@jridgewell/sourcemap-codec",
"@jridgewell/trace-mapping",
"@types/estree",
"acorn@8.14.0",
"aria-query",
"axobject-query",
"code-red",
"css-tree",
"estree-walker@3.0.3",
"is-reference@3.0.3",
"locate-character",
"magic-string",
"periscopic"
]
},
"svelte@5.0.5_acorn@8.14.0": {
"integrity": "sha512-f4WBlP5g8W6pEoDfx741lewMlemy+LIGpEqjGPWqnHVP92wqlQXl87U5O5Bi2tkSUrO95OxOoqwU8qlqiHmFKA==",
"dependencies": [
"@ampproject/remapping",
"@jridgewell/sourcemap-codec",
"@types/estree",
"acorn@8.14.0",
"acorn-typescript",
"aria-query",
"axobject-query",
"esm-env",
"esrap",
"is-reference@3.0.3",
"locate-character",
"magic-string",
"zimmerframe"
]
},
"svelte@5.21.0_acorn@8.14.0": {
@ -2567,12 +2860,15 @@ @@ -2567,12 +2860,15 @@
"svg.js"
]
},
"tailwind-merge@2.6.0": {
"integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="
"tailwind-merge@2.5.5": {
"integrity": "sha512-0LXunzzAZzo0tEPxV3I297ffKZPlKDrjj7NXphC8V5ak9yHC5zRmxnOe2m/Rd/7ivsOMJe3JZ2JVocoDdQTRBA=="
},
"tailwind-merge@3.0.2": {
"integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw=="
},
"tailwind-merge@3.3.0": {
"integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ=="
},
"tailwindcss@3.4.17_postcss@8.5.3": {
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dependencies": [
@ -2618,6 +2914,13 @@ @@ -2618,6 +2914,13 @@
"tinyexec@0.3.2": {
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="
},
"tinyglobby@0.2.13_picomatch@4.0.2": {
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
"dependencies": [
"fdir@6.4.4_picomatch@4.0.2",
"picomatch@4.0.2"
]
},
"tinypool@1.0.2": {
"integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA=="
},
@ -2678,6 +2981,9 @@ @@ -2678,6 +2981,9 @@
"undici-types@6.20.0": {
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
},
"unicode-emoji-modifier-base@1.0.0": {
"integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="
},
"unxhr@1.2.0": {
"integrity": "sha512-6cGpm8NFXPD9QbSNx0cD2giy7teZ6xOkCUH3U89WKVkL9N9rBrWjlCwhR94Re18ZlAop4MOc3WU1M3Hv/bgpIw=="
},
@ -2707,8 +3013,8 @@ @@ -2707,8 +3013,8 @@
"util-deprecate@1.0.2": {
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"vite-node@3.0.7_@types+node@22.13.9": {
"integrity": "sha512-2fX0QwX4GkkkpULXdT1Pf4q0tC1i1lFOyseKoonavXUNlQ77KpW2XqBGGNIm/J4Ows4KxgGJzDguYVPKwG/n5A==",
"vite-node@3.1.4_@types+node@22.13.9": {
"integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==",
"dependencies": [
"cac",
"debug@4.4.0",
@ -2733,8 +3039,8 @@ @@ -2733,8 +3039,8 @@
"vite"
]
},
"vitest@3.0.7_@types+node@22.13.9_vite@5.4.14__@types+node@22.13.9": {
"integrity": "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg==",
"vitest@3.1.4_@types+node@22.13.9_vite@5.4.14__@types+node@22.13.9": {
"integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==",
"dependencies": [
"@types/node",
"@vitest/expect",
@ -2752,6 +3058,7 @@ @@ -2752,6 +3058,7 @@
"std-env",
"tinybench",
"tinyexec",
"tinyglobby",
"tinypool",
"tinyrainbow",
"vite",
@ -2870,7 +3177,7 @@ @@ -2870,7 +3177,7 @@
"npm:@tailwindcss/typography@0.5",
"npm:asciidoctor@3.0",
"npm:d3@7.9",
"npm:flowbite-svelte-icons@2.0",
"npm:flowbite-svelte-icons@2.1",
"npm:flowbite-svelte@0.44",
"npm:flowbite@2.2",
"npm:he@1.2",
@ -2898,7 +3205,7 @@ @@ -2898,7 +3205,7 @@
"npm:autoprefixer@10",
"npm:d3@^7.9.0",
"npm:eslint-plugin-svelte@2",
"npm:flowbite-svelte-icons@2",
"npm:flowbite-svelte-icons@2.1",
"npm:flowbite-svelte@0",
"npm:flowbite@2",
"npm:he@1.2",
@ -2912,12 +3219,12 @@ @@ -2912,12 +3219,12 @@
"npm:prettier@3",
"npm:svelte-check@4",
"npm:svelte@5",
"npm:tailwind-merge@^2.5.5",
"npm:tailwind-merge@^3.3.0",
"npm:tailwindcss@3",
"npm:tslib@2.8",
"npm:typescript@5.7",
"npm:vite@5",
"npm:vitest@^3.0.5"
"npm:vitest@^3.1.3"
]
}
}

2
import_map.json

@ -13,7 +13,7 @@ @@ -13,7 +13,7 @@
"svelte": "npm:svelte@5.0.x",
"flowbite": "npm:flowbite@2.2.x",
"flowbite-svelte": "npm:flowbite-svelte@0.44.x",
"flowbite-svelte-icons": "npm:flowbite-svelte-icons@2.0.x",
"flowbite-svelte-icons": "npm:flowbite-svelte-icons@2.1.x",
"child_process": "node:child_process"
}
}

2219
package-lock.json generated

File diff suppressed because it is too large Load Diff

11
package.json

@ -20,11 +20,13 @@ @@ -20,11 +20,13 @@
"@tailwindcss/forms": "0.5.x",
"@tailwindcss/typography": "0.5.x",
"asciidoctor": "3.0.x",
"bech32": "^2.0.0",
"d3": "^7.9.0",
"he": "1.2.x",
"highlight.js": "^11.11.1",
"node-emoji": "^2.2.0",
"nostr-tools": "2.10.x"
"nostr-tools": "2.10.x",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
@ -36,11 +38,12 @@ @@ -36,11 +38,12 @@
"@types/d3": "^7.4.3",
"@types/he": "1.2.x",
"@types/node": "22.x",
"@types/qrcode": "^1.5.5",
"autoprefixer": "10.x",
"eslint-plugin-svelte": "2.x",
"flowbite": "2.x",
"flowbite-svelte": "0.x",
"flowbite-svelte-icons": "2.x",
"flowbite-svelte-icons": "2.1.x",
"playwright": "^1.50.1",
"postcss": "8.x",
"postcss-load-config": "6.x",
@ -48,11 +51,11 @@ @@ -48,11 +51,11 @@
"prettier-plugin-svelte": "3.x",
"svelte": "5.x",
"svelte-check": "4.x",
"tailwind-merge": "^2.5.5",
"tailwind-merge": "^3.3.0",
"tailwindcss": "3.x",
"tslib": "2.8.x",
"typescript": "5.7.x",
"vite": "5.x",
"vitest": "^3.0.5"
"vitest": "^3.1.3"
}
}

62
src/app.css

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
@import './styles/base.css';
@import './styles/scrollbar.css';
@import './styles/publications.css';
@import './styles/visualize.css';
@import "./styles/events.css";
/* Custom styles */
@layer base {
@ -51,9 +53,18 @@ @@ -51,9 +53,18 @@
}
main {
@apply max-w-full;
@apply max-w-full flex;
}
main.publication {
@apply mt-[70px];
}
/* To scroll columns independently */
main.publication.blog {
@apply w-full sm:w-auto min-h-full;
}
main.main-leather,
article.article-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300;
@ -65,9 +76,9 @@ @@ -65,9 +76,9 @@
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 p-2 rounded;
}
div.note-leather:hover:not(:has(.note-leather:hover)),
p.note-leather:hover:not(:has(.note-leather:hover)),
section.note-leather:hover:not(:has(.note-leather:hover)) {
.edit div.note-leather:hover:not(:has(.note-leather:hover)),
.edit p.note-leather:hover:not(:has(.note-leather:hover)),
section.edit.note-leather:hover:not(:has(.note-leather:hover)) {
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
}
@ -121,6 +132,11 @@ @@ -121,6 +132,11 @@
@apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
/* Navbar */
nav.Navbar.navbar-main {
@apply z-30;
}
nav.navbar-leather {
@apply bg-primary-0 dark:bg-primary-1000 z-10;
}
@ -138,12 +154,18 @@ @@ -138,12 +154,18 @@
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
aside.sidebar-leather>div {
@apply bg-primary-0 dark:bg-primary-1000;
/* Sidebar */
aside.sidebar-leather {
@apply fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10;
@apply bg-primary-0 dark:bg-primary-1000 px-5 w-full sm:w-auto sm:max-w-xl;
}
aside.sidebar-leather > div {
@apply bg-primary-50 dark:bg-gray-800 h-full px-5 py-0;
}
a.sidebar-item-leather {
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
@apply hover:bg-primary-100 dark:hover:bg-gray-800;
}
div.skeleton-leather div {
@ -229,6 +251,28 @@ @@ -229,6 +251,28 @@
.link {
@apply underline cursor-pointer hover:text-primary-400 dark:hover:text-primary-500;
}
/* Card with transition */
.ArticleBox.grid .ArticleBoxImage {
@apply max-h-0;
transition: max-height 0.5s ease;
}
.ArticleBox.grid.active .ArticleBoxImage {
@apply max-h-72;
}
.tags span {
@apply bg-primary-50 text-primary-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-primary-900 dark:text-primary-200;
}
.npub-badge {
@apply inline-flex space-x-1 items-center text-primary-600 dark:text-primary-500 hover:underline me-2 px-2 py-0.5 rounded-sm border border-primary-600 dark:border-primary-500;
svg {
@apply fill-primary-600 dark:fill-primary-500;
}
}
}
@layer components {
@ -381,6 +425,10 @@ @@ -381,6 +425,10 @@
padding-left: 1rem;
}
.line-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
}
.footnotes li {
margin-bottom: 0.5rem;
}

317
src/lib/components/CommentBox.svelte

@ -0,0 +1,317 @@ @@ -0,0 +1,317 @@
<script lang="ts">
import { Button, Textarea, Alert } from 'flowbite-svelte';
import { parseBasicmarkup } from '$lib/utils/markup/basicMarkupParser';
import { nip19 } from 'nostr-tools';
import { getEventHash, signEvent, getUserMetadata, type NostrProfile } from '$lib/utils/nostrUtils';
import { standardRelays, fallbackRelays } from '$lib/consts';
import { userRelays } from '$lib/stores/relayStore';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { onMount } from 'svelte';
const props = $props<{
event: NDKEvent;
userPubkey: string;
userRelayPreference: boolean;
}>();
let content = $state('');
let preview = $state('');
let isSubmitting = $state(false);
let success = $state<{ relay: string; eventId: string } | null>(null);
let error = $state<string | null>(null);
let showOtherRelays = $state(false);
let showFallbackRelays = $state(false);
let userProfile = $state<NostrProfile | null>(null);
// Fetch user profile on mount
onMount(async () => {
if (props.userPubkey) {
const npub = nip19.npubEncode(props.userPubkey);
userProfile = await getUserMetadata(npub);
}
});
// Markup buttons
const markupButtons = [
{ label: 'Bold', action: () => insertMarkup('**', '**') },
{ label: 'Italic', action: () => insertMarkup('_', '_') },
{ label: 'Strike', action: () => insertMarkup('~~', '~~') },
{ label: 'Link', action: () => insertMarkup('[', '](url)') },
{ label: 'Image', action: () => insertMarkup('![', '](url)') },
{ label: 'Quote', action: () => insertMarkup('> ', '') },
{ label: 'List', action: () => insertMarkup('- ', '') },
{ label: 'Numbered List', action: () => insertMarkup('1. ', '') },
{ label: 'Hashtag', action: () => insertMarkup('#', '') }
];
function insertMarkup(prefix: string, suffix: string) {
const textarea = document.querySelector('textarea');
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
content = content.substring(0, start) + prefix + selectedText + suffix + content.substring(end);
updatePreview();
// Set cursor position after the inserted markup
setTimeout(() => {
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + prefix.length + selectedText.length + suffix.length;
}, 0);
}
async function updatePreview() {
preview = await parseBasicmarkup(content);
}
function clearForm() {
content = '';
preview = '';
error = null;
success = null;
showOtherRelays = false;
showFallbackRelays = false;
}
function removeFormatting() {
content = content
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/_(.*?)_/g, '$1')
.replace(/~~(.*?)~~/g, '$1')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/!\[(.*?)\]\(.*?\)/g, '$1')
.replace(/^>\s*/gm, '')
.replace(/^[-*]\s*/gm, '')
.replace(/^\d+\.\s*/gm, '')
.replace(/#(\w+)/g, '$1');
updatePreview();
}
async function handleSubmit(useOtherRelays = false, useFallbackRelays = false) {
isSubmitting = true;
error = null;
success = null;
try {
if (!props.event.kind) {
throw new Error('Invalid event: missing kind');
}
const kind = props.event.kind === 1 ? 1 : 1111;
const tags: string[][] = [];
if (kind === 1) {
// NIP-10 reply
tags.push(['e', props.event.id, '', 'reply']);
tags.push(['p', props.event.pubkey]);
if (props.event.tags) {
const rootTag = props.event.tags.find((t: string[]) => t[0] === 'e' && t[3] === 'root');
if (rootTag) {
tags.push(['e', rootTag[1], '', 'root']);
}
// Add all p tags from the parent event
props.event.tags.filter((t: string[]) => t[0] === 'p').forEach((t: string[]) => {
if (!tags.some((pt: string[]) => pt[1] === t[1])) {
tags.push(['p', t[1]]);
}
});
}
} else {
// NIP-22 comment
tags.push(['E', props.event.id, '', props.event.pubkey]);
tags.push(['K', props.event.kind.toString()]);
tags.push(['P', props.event.pubkey]);
tags.push(['e', props.event.id, '', props.event.pubkey]);
tags.push(['k', props.event.kind.toString()]);
tags.push(['p', props.event.pubkey]);
}
const eventToSign = {
kind,
created_at: Math.floor(Date.now() / 1000),
tags,
content,
pubkey: props.userPubkey
};
const id = getEventHash(eventToSign);
const sig = await signEvent(eventToSign);
const signedEvent = {
...eventToSign,
id,
sig
};
// Determine which relays to use
let relays = props.userRelayPreference ? get(userRelays) : standardRelays;
if (useOtherRelays) {
relays = props.userRelayPreference ? standardRelays : get(userRelays);
}
if (useFallbackRelays) {
relays = fallbackRelays;
}
// Try to publish to relays
let published = false;
for (const relayUrl of relays) {
try {
const ws = new WebSocket(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error('Timeout'));
}, 5000);
ws.onopen = () => {
ws.send(JSON.stringify(['EVENT', signedEvent]));
};
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === 'OK' && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
published = true;
success = { relay: relayUrl, eventId: signedEvent.id };
ws.close();
resolve();
} else {
ws.close();
reject(new Error(message));
}
}
};
ws.onerror = () => {
clearTimeout(timeout);
ws.close();
reject(new Error('WebSocket error'));
};
});
if (published) break;
} catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e);
}
}
if (!published) {
if (!useOtherRelays && !useFallbackRelays) {
showOtherRelays = true;
error = 'Failed to publish to primary relays. Would you like to try the other relays?';
} else if (useOtherRelays && !useFallbackRelays) {
showFallbackRelays = true;
error = 'Failed to publish to other relays. Would you like to try the fallback relays?';
} else {
error = 'Failed to publish to any relays. Please try again later.';
}
} else {
// Navigate to the event page
const nevent = nip19.neventEncode({ id: signedEvent.id });
goto(`/events?id=${nevent}`);
}
} catch (e) {
error = e instanceof Error ? e.message : 'An error occurred';
} finally {
isSubmitting = false;
}
}
</script>
<div class="w-full space-y-4">
<div class="flex flex-wrap gap-2">
{#each markupButtons as button}
<Button size="xs" on:click={button.action}>{button.label}</Button>
{/each}
<Button size="xs" color="alternative" on:click={removeFormatting}>Remove Formatting</Button>
<Button size="xs" color="alternative" on:click={clearForm}>Clear</Button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Textarea
bind:value={content}
on:input={updatePreview}
placeholder="Write your comment..."
rows={10}
class="w-full"
/>
</div>
<div class="prose dark:prose-invert max-w-none p-4 border rounded-lg">
{@html preview}
</div>
</div>
{#if error}
<Alert color="red" dismissable>
{error}
{#if showOtherRelays}
<Button size="xs" class="mt-2" on:click={() => handleSubmit(true)}>Try Other Relays</Button>
{/if}
{#if showFallbackRelays}
<Button size="xs" class="mt-2" on:click={() => handleSubmit(false, true)}>Try Fallback Relays</Button>
{/if}
</Alert>
{/if}
{#if success}
<Alert color="green" dismissable>
Comment published successfully to {success.relay}!
<a href="/events?id={nip19.neventEncode({ id: success.eventId })}" class="text-primary-600 dark:text-primary-500 hover:underline">
View your comment
</a>
</Alert>
{/if}
<div class="flex justify-end items-center gap-4">
{#if userProfile}
<div class="flex items-center gap-2 text-sm">
{#if userProfile.picture}
<img
src={userProfile.picture}
alt={userProfile.name || 'Profile'}
class="w-8 h-8 rounded-full"
onerror={(e) => {
const img = e.target as HTMLImageElement;
img.src = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(img.alt)}`;
}}
/>
{/if}
<span class="text-gray-700 dark:text-gray-300">
{userProfile.displayName || userProfile.name || nip19.npubEncode(props.userPubkey).slice(0, 8) + '...'}
</span>
</div>
{/if}
<Button
on:click={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !props.userPubkey}
class="w-full md:w-auto"
>
{#if !props.userPubkey}
Not Signed In
{:else if isSubmitting}
Publishing...
{:else}
Post Comment
{/if}
</Button>
</div>
{#if !props.userPubkey}
<Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your current account.
</Alert>
{/if}
</div>
<style>
/* Add styles for disabled state */
:global(.disabled) {
opacity: 0.6;
cursor: not-allowed;
}
</style>

184
src/lib/components/EventDetails.svelte

@ -0,0 +1,184 @@ @@ -0,0 +1,184 @@
<script lang="ts">
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { getMimeTags } from "$lib/utils/mime";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { toNpub } from "$lib/utils/nostrUtils";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { standardRelays } from "$lib/consts";
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { getMatchingTags } from '$lib/utils/nostrUtils';
import ProfileHeader from "$components/cards/ProfileHeader.svelte";
const { event, profile = null, searchValue = null } = $props<{
event: NDKEvent;
profile?: {
name?: string;
display_name?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
lud16?: string;
nip05?: string;
} | null;
searchValue?: string | null;
}>();
let showFullContent = $state(false);
let parsedContent = $state('');
let contentPreview = $state('');
function getEventTitle(event: NDKEvent): string {
return getMatchingTags(event, 'title')[0]?.[1] || 'Untitled';
}
function getEventSummary(event: NDKEvent): string {
return getMatchingTags(event, 'summary')[0]?.[1] || '';
}
function getEventHashtags(event: NDKEvent): string[] {
return getMatchingTags(event, 't').map((tag: string[]) => tag[1]);
}
function getEventTypeDisplay(event: NDKEvent): string {
const [mTag, MTag] = getMimeTags(event.kind || 0);
return MTag[1].split('/')[1] || `Event Kind ${event.kind}`;
}
function renderTag(tag: string[]): string {
if (tag[0] === 'a' && tag.length > 1) {
const [kind, pubkey, d] = tag[1].split(':');
return `<a href='/events?id=${naddrEncode({kind: +kind, pubkey, tags: [['d', d]], content: '', id: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>a:${tag[1]}</a>`;
} else if (tag[0] === 'e' && tag.length > 1) {
return `<a href='/events?id=${neventEncode({id: tag[1], kind: 1, content: '', tags: [], pubkey: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>e:${tag[1]}</a>`;
} else {
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>${tag[0]}:${tag[1]}</span>`;
}
}
$effect(() => {
if (event && event.kind !== 0 && event.content) {
parseBasicmarkup(event.content).then(html => {
parsedContent = html;
contentPreview = html.slice(0, 250);
});
}
});
// --- Identifier helpers ---
function getIdentifiers(event: NDKEvent, profile: any): { label: string, value: string, link?: string }[] {
const ids: { label: string, value: string, link?: string }[] = [];
if (event.kind === 0) {
// NIP-05
const nip05 = profile?.nip05 || getMatchingTags(event, 'nip05')[0]?.[1];
// npub
const npub = toNpub(event.pubkey);
if (npub) ids.push({ label: 'npub', value: npub, link: `/events?id=${npub}` });
// nprofile
ids.push({ label: 'nprofile', value: nprofileEncode(event.pubkey, standardRelays), link: `/events?id=${nprofileEncode(event.pubkey, standardRelays)}` });
// nevent
ids.push({ label: 'nevent', value: neventEncode(event, standardRelays), link: `/events?id=${neventEncode(event, standardRelays)}` });
// hex pubkey
ids.push({ label: 'pubkey', value: event.pubkey });
} else {
// nevent
ids.push({ label: 'nevent', value: neventEncode(event, standardRelays), link: `/events?id=${neventEncode(event, standardRelays)}` });
// naddr (if addressable)
try {
const naddr = naddrEncode(event, standardRelays);
ids.push({ label: 'naddr', value: naddr, link: `/events?id=${naddr}` });
} catch {}
// hex id
ids.push({ label: 'id', value: event.id });
}
return ids;
}
function isCurrentSearch(value: string): boolean {
if (!searchValue) return false;
// Compare ignoring case and possible nostr: prefix
const norm = (s: string) => s.replace(/^nostr:/, '').toLowerCase();
return norm(value) === norm(searchValue);
}
</script>
<div class="flex flex-col space-y-4">
{#if event.kind !== 0 && getEventTitle(event)}
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{getEventTitle(event)}</h2>
{/if}
<div class="flex items-center space-x-2">
{#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400">Author: {@render userBadge(toNpub(event.pubkey) as string, profile?.display_name || event.pubkey)}</span>
{:else}
<span class="text-gray-600 dark:text-gray-400">Author: {profile?.display_name || event.pubkey}</span>
{/if}
</div>
<div class="flex items-center space-x-2">
<span class="text-gray-600 dark:text-gray-400">Kind:</span>
<span class="font-mono">{event.kind}</span>
<span class="text-gray-600 dark:text-gray-400">({getEventTypeDisplay(event)})</span>
</div>
{#if getEventSummary(event)}
<div class="flex flex-col space-y-1">
<span class="text-gray-600 dark:text-gray-400">Summary:</span>
<p class="text-gray-800 dark:text-gray-200">{getEventSummary(event)}</p>
</div>
{/if}
{#if getEventHashtags(event).length}
<div class="flex flex-col space-y-1">
<span class="text-gray-600 dark:text-gray-400">Tags:</span>
<div class="flex flex-wrap gap-2">
{#each getEventHashtags(event) as tag}
<span class="px-2 py-1 rounded bg-primary-100 text-primary-700 text-sm font-medium">#{tag}</span>
{/each}
</div>
</div>
{/if}
<!-- Content -->
<div class="flex flex-col space-y-1">
{#if event.kind !== 0}
<span class="text-gray-600 dark:text-gray-400">Content:</span>
<div class="prose dark:prose-invert max-w-none">
{@html showFullContent ? parsedContent : contentPreview}
{#if !showFullContent && parsedContent.length > 250}
<button class="mt-2 text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300" onclick={() => showFullContent = true}>Show more</button>
{/if}
</div>
{/if}
</div>
<!-- If event is profile -->
{#if event.kind === 0}
<ProfileHeader {event} {profile} identifiers={getIdentifiers(event, profile)} />
{/if}
<!-- Tags Array -->
{#if event.tags && event.tags.length}
<div class="flex flex-col space-y-1">
<span class="text-gray-600 dark:text-gray-400">Event Tags:</span>
<div class="flex flex-wrap gap-2">
{#each event.tags as tag}
{@html renderTag(tag)}
{/each}
</div>
</div>
{/if}
<!-- Raw Event JSON -->
<details class="bg-primary-50 dark:bg-primary-900 rounded p-4">
<summary class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2">
Show Raw Event JSON
</summary>
<pre
class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-4 mt-2 font-mono"
style="line-height: 1.7; font-size: 1rem;"
>
{JSON.stringify(event.rawEvent(), null, 2)}
</pre>
</details>
</div>

204
src/lib/components/EventSearch.svelte

@ -0,0 +1,204 @@ @@ -0,0 +1,204 @@
<script lang="ts">
import { Input, Button } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
import { nip19 } from '$lib/utils/nostrUtils';
import { goto } from '$app/navigation';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import RelayDisplay from './RelayDisplay.svelte';
const { loading, error, searchValue, onEventFound, event } = $props<{
loading: boolean;
error: string | null;
searchValue: string | null;
onEventFound: (event: NDKEvent) => void;
event: NDKEvent | null;
}>();
let searchQuery = $state("");
let localError = $state<string | null>(null);
let relayStatuses = $state<Record<string, 'pending' | 'found' | 'notfound'>>({});
let foundEvent = $state<NDKEvent | null>(null);
let searching = $state(false);
$effect(() => {
if (searchValue) {
searchEvent(false, searchValue);
}
});
$effect(() => {
foundEvent = event;
});
async function searchEvent(clearInput: boolean = true, queryOverride?: string) {
localError = null;
const query = (queryOverride !== undefined ? queryOverride : searchQuery).trim();
if (!query) return;
// Only update the URL if this is a manual search
if (clearInput) {
const encoded = encodeURIComponent(query);
goto(`?id=${encoded}`, { replaceState: false, keepFocus: true, noScroll: true });
}
if (clearInput) {
searchQuery = '';
}
// Clean the query
let cleanedQuery = query.replace(/^nostr:/, '');
let filterOrId: any = cleanedQuery;
console.log('[Events] Cleaned query:', cleanedQuery);
// NIP-05 address pattern: user@domain
if (/^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(cleanedQuery)) {
try {
const [name, domain] = cleanedQuery.split('@');
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`);
const data = await res.json();
const pubkey = data.names?.[name];
if (pubkey) {
filterOrId = { kinds: [0], authors: [pubkey] };
const profileEvent = await fetchEventWithFallback($ndkInstance, filterOrId, 10000);
if (profileEvent) {
handleFoundEvent(profileEvent);
return;
} else {
localError = 'No profile found for this NIP-05 address.';
return;
}
} else {
localError = 'NIP-05 address not found.';
return;
}
} catch (e) {
localError = 'Error resolving NIP-05 address.';
return;
}
}
// If it's a 64-char hex, try as event id first, then as pubkey (profile)
if (/^[a-f0-9]{64}$/i.test(cleanedQuery)) {
// Try as event id
filterOrId = cleanedQuery;
const eventResult = await fetchEventWithFallback($ndkInstance, filterOrId, 10000);
// Always try as pubkey (profile event) as well
const profileFilter = { kinds: [0], authors: [cleanedQuery] };
const profileEvent = await fetchEventWithFallback($ndkInstance, profileFilter, 10000);
// Prefer profile if found and pubkey matches query
if (profileEvent && profileEvent.pubkey.toLowerCase() === cleanedQuery.toLowerCase()) {
handleFoundEvent(profileEvent);
} else if (eventResult) {
handleFoundEvent(eventResult);
}
return;
} else if (/^(nevent|note|naddr|npub|nprofile)[a-z0-9]+$/i.test(cleanedQuery)) {
try {
const decoded = nip19.decode(cleanedQuery);
if (!decoded) throw new Error('Invalid identifier');
console.log('[Events] Decoded NIP-19:', decoded);
switch (decoded.type) {
case 'nevent':
filterOrId = decoded.data.id;
break;
case 'note':
filterOrId = decoded.data;
break;
case 'naddr':
filterOrId = {
kinds: [decoded.data.kind],
authors: [decoded.data.pubkey],
'#d': [decoded.data.identifier],
};
break;
case 'nprofile':
filterOrId = {
kinds: [0],
authors: [decoded.data.pubkey],
};
break;
case 'npub':
filterOrId = {
kinds: [0],
authors: [decoded.data],
};
break;
default:
filterOrId = cleanedQuery;
}
console.log('[Events] Using filterOrId:', filterOrId);
} catch (e) {
console.error('[Events] Invalid Nostr identifier:', cleanedQuery, e);
localError = 'Invalid Nostr identifier.';
return;
}
}
try {
console.log('Searching for event:', filterOrId);
const event = await fetchEventWithFallback($ndkInstance, filterOrId, 10000);
if (!event) {
console.warn('[Events] Event not found for filterOrId:', filterOrId);
localError = 'Event not found';
} else {
console.log('[Events] Event found:', event);
handleFoundEvent(event);
}
} catch (err) {
console.error('[Events] Error fetching event:', err, 'Query:', query);
localError = 'Error fetching event. Please check the ID and try again.';
}
}
function handleFoundEvent(event: NDKEvent) {
foundEvent = event;
onEventFound(event);
}
</script>
<div class="flex flex-col space-y-6">
<div class="flex gap-2">
<Input
bind:value={searchQuery}
placeholder="Enter event ID, nevent, or naddr..."
class="flex-grow"
on:keydown={(e: KeyboardEvent) => e.key === 'Enter' && searchEvent(true)}
/>
<Button on:click={() => searchEvent(true)} disabled={loading}>
{loading ? 'Searching...' : 'Search'}
</Button>
</div>
{#if localError || error}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
{localError || error}
{#if searchQuery.trim()}
<div class="mt-2">
You can also try viewing this event on
<a
class="underline text-primary-700"
href={"https://njump.me/" + encodeURIComponent(searchQuery.trim())}
target="_blank"
rel="noopener"
>Njump</a>.
</div>
{/if}
</div>
{/if}
<div class="mt-4">
<div class="flex flex-wrap gap-2">
{#each Object.entries(relayStatuses) as [relay, status]}
<RelayDisplay {relay} showStatus={true} status={status} />
{/each}
</div>
{#if !foundEvent && Object.values(relayStatuses).some(s => s === 'pending')}
<div class="text-gray-500 mt-2">Searching relays...</div>
{/if}
{#if !foundEvent && !searching && Object.values(relayStatuses).every(s => s !== 'pending')}
<div class="text-red-500 mt-2">Event not found on any relay.</div>
{/if}
</div>
</div>

9
src/lib/components/Login.svelte

@ -1,13 +1,10 @@ @@ -1,13 +1,10 @@
<script lang='ts'>
import { type NDKUserProfile } from '@nostr-dev-kit/ndk';
import { activePubkey, loginWithExtension, logout, ndkInstance, ndkSignedIn, persistLogin } from '$lib/ndk';
import { Avatar, Button, Popover, Tooltip } from 'flowbite-svelte';
import { activePubkey, loginWithExtension, ndkInstance, ndkSignedIn, persistLogin } from '$lib/ndk';
import { Avatar, Button, Popover } from 'flowbite-svelte';
import Profile from "$components/util/Profile.svelte";
let profile = $state<NDKUserProfile | null>(null);
let pfp = $derived(profile?.image);
let username = $derived(profile?.name);
let tag = $derived(profile?.name);
let npub = $state<string | undefined >(undefined);
let signInFailed = $state<boolean>(false);
@ -50,7 +47,7 @@ @@ -50,7 +47,7 @@
{#if $ndkSignedIn}
<Profile pubkey={$activePubkey} isNav={true} />
{:else}
<Avatar rounded class='h-6 w-6 cursor-pointer' id='avatar' />
<Avatar rounded class='h-6 w-6 cursor-pointer bg-transparent' id='avatar' />
<Popover
class='popover-leather w-fit'
placement='bottom'

41
src/lib/components/Navigation.svelte

@ -1,29 +1,36 @@ @@ -1,29 +1,36 @@
<script lang="ts">
import { DarkMode, Navbar, NavLi, NavUl, NavHamburger, NavBrand } from 'flowbite-svelte';
import Login from './Login.svelte';
import {
DarkMode,
Navbar,
NavLi,
NavUl,
NavHamburger,
NavBrand,
} from "flowbite-svelte";
import Login from "./Login.svelte";
let { class: className = '' } = $props();
let leftMenuOpen = $state(false);
let { class: className = "" } = $props();
</script>
<Navbar class={`Navbar navbar-leather ${className}`}>
<div class='flex flex-grow justify-between'>
<NavBrand href='/'>
<Navbar class={`Navbar navbar-leather navbar-main ${className}`}>
<div class="flex flex-grow justify-between">
<NavBrand href="/">
<h1>Alexandria</h1>
</NavBrand>
</div>
<div class='flex md:order-2'>
<div class="flex md:order-2">
<Login />
<NavHamburger class='btn-leather' />
<NavHamburger class="btn-leather" />
</div>
<NavUl class='ul-leather'>
<NavLi href='/new/edit'>Publish</NavLi>
<NavLi href='/visualize'>Visualize</NavLi>
<NavLi href='/about'>About</NavLi>
<NavLi href='/contact'>Contact</NavLi>
<NavLi>
<DarkMode btnClass='btn-leather p-0'/>
<NavUl class="ul-leather">
<NavLi href="/">Publications</NavLi>
<NavLi href="/visualize">Visualize</NavLi>
<NavLi href="/start">Getting Started</NavLi>
<NavLi href="/events">Events</NavLi>
<NavLi href="/about">About</NavLi>
<NavLi href="/contact">Contact</NavLi>
<NavLi>
<DarkMode btnClass="btn-leather p-0" />
</NavLi>
</NavUl>
</Navbar>

116
src/lib/components/Preview.svelte

@ -4,6 +4,8 @@ @@ -4,6 +4,8 @@
import { CaretDownSolid, CaretUpSolid, EditOutline } from 'flowbite-svelte-icons';
import Self from './Preview.svelte';
import { contentParagraph, sectionHeading } from '$lib/snippets/PublicationSnippets.svelte';
import BlogHeader from "$components/cards/BlogHeader.svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils';
// TODO: Fix move between parents.
@ -16,8 +18,10 @@ @@ -16,8 +18,10 @@
oncursorrelease,
parentId,
rootId,
index,
sectionClass,
publicationType,
onBlogUpdate
} = $props<{
allowEditing?: boolean;
depth?: number;
@ -27,14 +31,19 @@ @@ -27,14 +31,19 @@
oncursorrelease?: (e: MouseEvent) => void;
parentId?: string | null | undefined;
rootId: string;
index: number;
sectionClass?: string;
publicationType?: string;
onBlogUpdate?: any;
}>();
let currentContent: string = $state($pharosInstance.getContent(rootId));
let title: string | undefined = $state($pharosInstance.getIndexTitle(rootId));
let orderedChildren: string[] = $state($pharosInstance.getOrderedChildIds(rootId));
let blogEntries = $state(Array.from($pharosInstance.getBlogEntries()));
let metadata = $state($pharosInstance.getIndexMetadata());
let isEditing: boolean = $state(false);
let hasCursor: boolean = $state(false);
let childHasCursor: boolean = $state(false);
@ -86,6 +95,48 @@ @@ -86,6 +95,48 @@
}
});
function getBlogEvent(index: number) {
return blogEntries[index][1];
}
function byline(rootId: string, index: number) {
console.log(rootId, index, blogEntries);
const event = blogEntries[index][1];
const author = event ? getMatchingTags(event, 'author')[0][1] : '';
return author ?? "";
}
function hasCoverImage(rootId: string, index: number) {
console.log(rootId);
const event = blogEntries[index][1];
const image = event && getMatchingTags(event, 'image')[0] ? getMatchingTags(event, 'image')[0][1] : '';
return image ?? '';
}
function publishedAt(rootId: string, index: number) {
console.log(rootId, index);
console.log(blogEntries[index]);
const event = blogEntries[index][1];
const date = event.created_at ? new Date(event.created_at * 1000) : '';
if (date !== '') {
const formattedDate = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "2-digit",
}).format(date);
return formattedDate ?? "";
}
return '';
}
function readBlog(rootId:string) {
onBlogUpdate?.(rootId);
}
function propagateBlogUpdate(rootId:string) {
onBlogUpdate?.(rootId);
}
function handleMouseEnter(e: MouseEvent) {
hasCursor = true;
if (oncursorcapture) {
@ -153,17 +204,38 @@ @@ -153,17 +204,38 @@
{#snippet sectionHeading(title: string, depth: number)}
{@const headingLevel = Math.min(depth + 1, 6)}
{@const className = $pharosInstance.isFloatingTitle(rootId) ? 'discrete' : 'h-leather'}
<svelte:element this={`h${headingLevel}`} class={className}>
{title}
</svelte:element>
{/snippet}
{#snippet coverImage(rootId: string, index: number, depth: number)}
{#if hasCoverImage(rootId, index)}
<div class="coverImage depth-{depth}">
<img src={hasCoverImage(rootId, index)} alt={title} />
</div>
{/if}
{/snippet}
{#snippet blogMetadata(rootId: string, index: number)}
<p class='h-leather'>
by {byline(rootId, index)}
</p>
<p class='h-leather italic text-sm'>
{publishedAt(rootId, index)}
</p>
{/snippet}
{#snippet contentParagraph(content: string, publicationType: string)}
{#if publicationType === 'novel'}
<P class='whitespace-normal' firstupper={isSectionStart}>
{@html content}
</P>
{:else if publicationType === 'blog'}
<P class='whitespace-normal' firstupper={false}>
{@html content}
</P>
{:else}
<P class='whitespace-normal' firstupper={false}>
{@html content}
@ -222,25 +294,33 @@ @@ -222,25 +294,33 @@
</Button>
</ButtonGroup>
{:else}
{@render sectionHeading(title!, depth)}
{#if !(publicationType === 'blog' && depth === 1)}
{@render sectionHeading(title!, depth)}
{/if}
{/if}
<!-- Recurse on child indices and zettels -->
{#key subtreeUpdateCount}
{#each orderedChildren as id, index}
<Self
rootId={id}
parentId={rootId}
publicationType={publicationType}
depth={depth + 1}
{allowEditing}
{sectionClass}
isSectionStart={index === 0}
bind:needsUpdate={subtreeNeedsUpdate}
oncursorcapture={handleChildCursorCaptured}
oncursorrelease={handleChildCursorReleased}
/>
{/each}
{/key}
{#if publicationType === 'blog' && depth === 1}
<BlogHeader event={getBlogEvent(index)} rootId={rootId} onBlogUpdate={readBlog} active={true} />
{:else }
{#key subtreeUpdateCount}
{#each orderedChildren as id, index}
<Self
rootId={id}
parentId={rootId}
index={index}
publicationType={publicationType}
depth={depth + 1}
{allowEditing}
{sectionClass}
isSectionStart={index === 0}
bind:needsUpdate={subtreeNeedsUpdate}
oncursorcapture={handleChildCursorCaptured}
oncursorrelease={handleChildCursorReleased}
onBlogUpdate={propagateBlogUpdate}
/>
{/each}
{/key}
{/if}
</div>
{/if}
{#if allowEditing && depth > 0}

342
src/lib/components/Publication.svelte

@ -2,26 +2,34 @@ @@ -2,26 +2,34 @@
import {
Alert,
Button,
Card,
Sidebar,
SidebarGroup,
SidebarItem,
SidebarWrapper,
Skeleton,
TextPlaceholder,
Tooltip,
Heading,
} from "flowbite-svelte";
import { getContext, onMount } from "svelte";
import { BookOutline, ExclamationCircleOutline } from "flowbite-svelte-icons";
import { page } from "$app/state";
import { getContext, onDestroy, onMount } from "svelte";
import {
CloseOutline,
ExclamationCircleOutline,
} from "flowbite-svelte-icons";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import PublicationSection from "./PublicationSection.svelte";
import type { PublicationTree } from "$lib/data_structures/publication_tree";
let { rootAddress } = $props<{
rootAddress: string,
import Details from "$components/util/Details.svelte";
import { publicationColumnVisibility } from "$lib/stores";
import BlogHeader from "$components/cards/BlogHeader.svelte";
import Interactions from "$components/util/Interactions.svelte";
import TocToggle from "$components/util/TocToggle.svelte";
import { pharosInstance } from '$lib/parser';
let { rootAddress, publicationType, indexEvent } = $props<{
rootAddress: string;
publicationType: string;
indexEvent: NDKEvent;
}>();
const publicationTree = getContext('publicationTree') as PublicationTree;
const publicationTree = getContext("publicationTree") as PublicationTree;
// #region Loading
@ -74,155 +82,221 @@ @@ -74,155 +82,221 @@
// #endregion
// #region ToC
const tocBreakpoint = 1140;
// region Columns visibility
let currentBlog: null | string = $state(null);
let currentBlogEvent: null | NDKEvent = $state(null);
const isLeaf = $derived(indexEvent.kind === 30041);
let activeHash = $state(page.url.hash);
let showToc: boolean = $state(true);
let showTocButton: boolean = $state(false);
function isInnerActive() {
return currentBlog !== null && $publicationColumnVisibility.inner;
}
function normalizeHashPath(str: string): string {
return str
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^\w-]/g, "");
function closeDiscussion() {
publicationColumnVisibility.update((v) => ({ ...v, discussion: false }));
}
function scrollToElementWithOffset() {
const hash = window.location.hash;
if (hash) {
const targetElement = document.querySelector(hash);
if (targetElement) {
const headerOffset = 80;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.scrollY - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: "auto",
});
function loadBlog(rootId: string) {
// depending on the size of the screen, also toggle blog list & discussion visibility
publicationColumnVisibility.update((current) => {
const updated = current;
if (window.innerWidth < 1024) {
updated.blog = false;
updated.discussion = false;
}
updated.inner = true;
return updated;
});
currentBlog = rootId;
// set current blog values for publication render
if (leaves.length > 0) {
currentBlogEvent =
leaves.find((i) => i && i.tagAddress() === currentBlog) ?? null;
}
}
/**
* Hides the table of contents sidebar when the window shrinks below a certain size. This
* prevents the sidebar from occluding the article content.
*/
function setTocVisibilityOnResize() {
showToc = window.innerWidth >= tocBreakpoint;
showTocButton = window.innerWidth < tocBreakpoint;
function showBlogHeader() {
return currentBlog && currentBlogEvent && window.innerWidth < 1140;
}
/**
* Hides the table of contents sidebar when the user clicks outside of it.
*/
function hideTocOnClick(ev: MouseEvent) {
const target = ev.target as HTMLElement;
if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) {
return;
}
if (showToc) {
showToc = false;
}
}
// #endregion
onDestroy(() => {
// reset visibility
publicationColumnVisibility.reset();
});
onMount(() => {
// Always check whether the TOC sidebar should be visible.
setTocVisibilityOnResize();
window.addEventListener("hashchange", scrollToElementWithOffset);
// Also handle the case where the user lands on the page with a hash in the URL
scrollToElementWithOffset();
window.addEventListener("resize", setTocVisibilityOnResize);
window.addEventListener("click", hideTocOnClick);
// Set current columns depending on the publication type
const isBlog = publicationType === "blog";
publicationColumnVisibility.update((v) => ({
...v,
main: !isBlog,
blog: isBlog,
}));
if (isLeaf || isBlog) {
publicationColumnVisibility.update((v) => ({ ...v, toc: false }));
}
// Set up the intersection observer.
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading && !isDone) {
loadMore(1);
}
});
}, { threshold: 0.5 });
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading && !isDone) {
loadMore(1);
}
});
},
{ threshold: 0.5 },
);
loadMore(8);
return () => {
window.removeEventListener("hashchange", scrollToElementWithOffset);
window.removeEventListener("resize", setTocVisibilityOnResize);
window.removeEventListener("click", hideTocOnClick);
observer.disconnect();
};
});
// Whenever the publication changes, update rootId
let rootId = $derived($pharosInstance.getRootIndexId());
</script>
<!-- TODO: Handle entering mid-document and scrolling up. -->
<!-- Table of contents -->
{#if publicationType !== "blog" || !isLeaf}
<TocToggle {rootId} />
{/if}
<!-- Default publications -->
{#if $publicationColumnVisibility.main}
<div class="flex flex-col p-4 space-y-4 overflow-auto max-w-2xl flex-grow-2">
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details event={indexEvent} />
</div>
<!-- Publication sections/cards -->
{#each leaves as leaf, i}
{#if leaf == null}
<Alert class="flex space-x-2">
<ExclamationCircleOutline class="w-5 h-5" />
Error loading content. One or more events could not be loaded.
</Alert>
{:else}
<PublicationSection
{rootAddress}
{leaves}
address={leaf.tagAddress()}
ref={(el) => setLastElementRef(el, i)}
/>
{/if}
{/each}
<div class="flex justify-center my-4">
{#if isLoading}
<Button disabled color="primary">Loading...</Button>
{:else if !isDone}
<Button color="primary" on:click={() => loadMore(1)}>Show More</Button>
{:else}
<p class="text-gray-500 dark:text-gray-400">
You've reached the end of the publication.
</p>
{/if}
</div>
</div>
{/if}
{#if showTocButton && !showToc}
<Button
class="btn-leather fixed top-20 left-4 h-6 w-6"
outline={true}
on:click={(ev) => {
showToc = true;
ev.stopPropagation();
}}
<!-- Blog list -->
{#if $publicationColumnVisibility.blog}
<div
class="flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1
{isInnerActive() ? 'discreet' : ''}
"
>
<BookOutline />
</Button>
<Tooltip>Show Table of Contents</Tooltip>
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details event={indexEvent} />
</div>
<!-- List blog excerpts -->
{#each leaves as leaf, i}
{#if leaf}
<BlogHeader
rootId={leaf.tagAddress()}
event={leaf}
onBlogUpdate={loadBlog}
active={!isInnerActive()}
/>
{/if}
{/each}
</div>
{/if}
<!-- TODO: Use loader to build ToC. -->
<!-- {#if showToc}
<Sidebar class='sidebar-leather fixed top-20 left-0 px-4 w-60' {activeHash}>
<SidebarWrapper>
<SidebarGroup class='sidebar-group-leather overflow-y-scroll'>
{#each events as event}
<SidebarItem
class='sidebar-item-leather'
label={event.getMatchingTags('title')[0][1]}
href={`${$page.url.pathname}#${normalizeHashPath(event.getMatchingTags('title')[0][1])}`}
{#if isInnerActive()}
{#key currentBlog}
<div
class="flex flex-col p-4 max-w-3xl overflow-auto flex-grow-2 max-h-[calc(100vh-146px)] sticky top-[146px]"
>
{#each leaves as leaf, i}
{#if leaf && leaf.tagAddress() === currentBlog}
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details event={leaf} />
</div>
<PublicationSection
{rootAddress}
{leaves}
address={leaf.tagAddress()}
ref={(el) => setLastElementRef(el, i)}
/>
{/each}
<Card class="ArticleBox !hidden card-leather min-w-full mt-4">
<Interactions rootId={currentBlog} />
</Card>
{/if}
{/each}
</div>
{/key}
{/if}
{#if $publicationColumnVisibility.discussion}
<Sidebar class="sidebar-leather right-0 md:!pl-8">
<SidebarWrapper>
<SidebarGroup class="sidebar-group-leather">
<div class="flex justify-between items-baseline">
<Heading tag="h1" class="h-leather !text-lg">Discussion</Heading>
<Button
class="btn-leather hidden sm:flex z-30 !p-1 bg-primary-50 dark:bg-gray-800"
outline
onclick={closeDiscussion}
>
<CloseOutline />
</Button>
</div>
<div class="flex flex-col space-y-4">
<!-- TODO
alternative for other publications and
when blog is not opened, but discussion is opened from the list
-->
{#if showBlogHeader() && currentBlog && currentBlogEvent}
<BlogHeader
rootId={currentBlog}
event={currentBlogEvent}
onBlogUpdate={loadBlog}
active={true}
/>
{/if}
<div class="flex flex-col w-full space-y-4">
<Card class="ArticleBox card-leather w-full grid max-w-xl">
<div class="flex flex-col my-2">
<span>Unknown</span>
<span class="text-gray-500">1.1.1970</span>
</div>
<div class="flex flex-col flex-grow space-y-4">
This is a very intelligent comment placeholder that applies to
all the content equally well.
</div>
</Card>
</div>
</div>
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if} -->
<div class="flex flex-col space-y-4 max-w-2xl">
{#each leaves as leaf, i}
{#if leaf == null}
<Alert class='flex space-x-2'>
<ExclamationCircleOutline class='w-5 h-5' />
Error loading content. One or more events could not be loaded.
</Alert>
{:else}
<PublicationSection
rootAddress={rootAddress}
leaves={leaves}
address={leaf.tagAddress()}
ref={(el) => setLastElementRef(el, i)}
/>
{/if}
{/each}
<div class="flex justify-center my-4">
{#if isLoading}
<Button disabled color="primary">
Loading...
</Button>
{:else if !isDone}
<Button color="primary" on:click={() => loadMore(1)}>
Show More
</Button>
{/if}
</div>
</div>
<style>
:global(.sidebar-group-leather) {
max-height: calc(100vh - 8rem);
}
</style>
{/if}

247
src/lib/components/PublicationFeed.svelte

@ -1,67 +1,202 @@ @@ -1,67 +1,202 @@
<script lang='ts'>
import { indexKind } from '$lib/consts';
import { ndkInstance } from '$lib/ndk';
import { filterValidIndexEvents } from '$lib/utils';
import { NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk';
import { filterValidIndexEvents, debounce } from '$lib/utils';
import { Button, P, Skeleton, Spinner } from 'flowbite-svelte';
import ArticleHeader from './PublicationHeader.svelte';
import { onMount } from 'svelte';
import { getMatchingTags, NDKRelaySetFromNDK, type NDKEvent, type NDKRelaySet } from '$lib/utils/nostrUtils';
let { relays } = $props<{ relays: string[] }>();
let { relays, fallbackRelays, searchQuery = '' } = $props<{ relays: string[], fallbackRelays: string[], searchQuery?: string }>();
let eventsInView: NDKEvent[] = $state([]);
let loadingMore: boolean = $state(false);
let endOfFeed: boolean = $state(false);
let relayStatuses = $state<Record<string, 'pending' | 'found' | 'notfound'>>({});
let loading: boolean = $state(true);
let cutoffTimestamp: number = $derived(
eventsInView?.at(eventsInView.length - 1)?.created_at ?? new Date().getTime()
);
async function getEvents(
before: number | undefined = undefined,
): Promise<void> {
let eventSet = await $ndkInstance.fetchEvents(
{
kinds: [indexKind],
limit: 16,
until: before,
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
NDKRelaySet.fromRelayUrls(relays, $ndkInstance)
);
eventSet = filterValidIndexEvents(eventSet);
let eventArray = Array.from(eventSet);
eventArray?.sort((a, b) => b.created_at! - a.created_at!);
if (!eventArray) {
return;
// Debounced search function
const debouncedSearch = debounce(async (query: string) => {
console.debug('[PublicationFeed] Search query changed:', query);
if (query.trim()) {
console.debug('[PublicationFeed] Clearing events and searching with query:', query);
eventsInView = [];
await getEvents(undefined, query, true);
} else {
console.debug('[PublicationFeed] Clearing events and resetting search');
eventsInView = [];
await getEvents(undefined, '', true);
}
}, 300);
endOfFeed = eventArray?.at(eventArray.length - 1)?.id === eventsInView?.at(eventsInView.length - 1)?.id;
$effect(() => {
console.debug('[PublicationFeed] Search query effect triggered:', searchQuery);
debouncedSearch(searchQuery);
});
if (endOfFeed) {
return;
}
async function getEvents(before: number | undefined = undefined, search: string = '', reset: boolean = false) {
loading = true;
const ndk = $ndkInstance;
const primaryRelays: string[] = relays;
const fallback: string[] = fallbackRelays.filter((r: string) => !primaryRelays.includes(r));
relayStatuses = Object.fromEntries(primaryRelays.map((r: string) => [r, 'pending']));
let allEvents: NDKEvent[] = [];
let fetchedCount = 0; // Track number of new events
console.debug('[getEvents] Called with before:', before, 'search:', search);
// Function to filter events based on search query
const filterEventsBySearch = (events: NDKEvent[]) => {
if (!search) return events;
const query = search.toLowerCase();
console.debug('[PublicationFeed] Filtering events with query:', query, 'Total events before filter:', events.length);
// Check if the query is a NIP-05 address
const isNip05Query = /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(query);
console.debug('[PublicationFeed] Is NIP-05 query:', isNip05Query);
const filtered = events.filter(event => {
const title = getMatchingTags(event, 'title')[0]?.[1]?.toLowerCase() ?? '';
const authorName = getMatchingTags(event, 'author')[0]?.[1]?.toLowerCase() ?? '';
const authorPubkey = event.pubkey.toLowerCase();
const nip05 = getMatchingTags(event, 'nip05')[0]?.[1]?.toLowerCase() ?? '';
// For NIP-05 queries, only match against NIP-05 tags
if (isNip05Query) {
const matches = nip05 === query;
if (matches) {
console.debug('[PublicationFeed] Event matches NIP-05 search:', {
id: event.id,
nip05,
authorPubkey
});
}
return matches;
}
const eventMap = new Map([...eventsInView, ...eventArray].map(event => [event.id, event]));
const allEvents = Array.from(eventMap.values());
const uniqueIds = new Set(allEvents.map(event => event.id));
eventsInView = Array.from(uniqueIds)
.map(id => eventMap.get(id))
.filter(event => event != null) as NDKEvent[];
// For regular queries, match against all fields
const matches = (
title.includes(query) ||
authorName.includes(query) ||
authorPubkey.includes(query) ||
nip05.includes(query)
);
if (matches) {
console.debug('[PublicationFeed] Event matches search:', {
id: event.id,
title,
authorName,
authorPubkey,
nip05
});
}
return matches;
});
console.debug('[PublicationFeed] Events after filtering:', filtered.length);
return filtered;
};
// First, try primary relays
let foundEventsInPrimary = false;
await Promise.all(
primaryRelays.map(async (relay: string) => {
try {
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk.fetchEvents(
{
kinds: [indexKind],
limit: 30,
until: before,
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
relaySet
).withTimeout(2500);
eventSet = filterValidIndexEvents(eventSet);
const eventArray = filterEventsBySearch(Array.from(eventSet));
fetchedCount += eventArray.length; // Count new events
if (eventArray.length > 0) {
allEvents = allEvents.concat(eventArray);
relayStatuses = { ...relayStatuses, [relay]: 'found' };
foundEventsInPrimary = true;
} else {
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
console.debug(`[getEvents] Fetched ${eventArray.length} events from relay: ${relay} (search: "${search}")`);
} catch (err) {
console.error(`Error fetching from primary relay ${relay}:`, err);
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
})
);
// Only try fallback relays if no events were found in primary relays
if (!foundEventsInPrimary && fallback.length > 0) {
console.debug('[getEvents] No events found in primary relays, trying fallback relays');
relayStatuses = { ...relayStatuses, ...Object.fromEntries(fallback.map((r: string) => [r, 'pending'])) };
await Promise.all(
fallback.map(async (relay: string) => {
try {
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk.fetchEvents(
{
kinds: [indexKind],
limit: 18,
until: before,
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
relaySet
).withTimeout(2500);
eventSet = filterValidIndexEvents(eventSet);
const eventArray = filterEventsBySearch(Array.from(eventSet));
fetchedCount += eventArray.length; // Count new events
if (eventArray.length > 0) {
allEvents = allEvents.concat(eventArray);
relayStatuses = { ...relayStatuses, [relay]: 'found' };
} else {
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
console.debug(`[getEvents] Fetched ${eventArray.length} events from relay: ${relay} (search: "${search}")`);
} catch (err) {
console.error(`Error fetching from fallback relay ${relay}:`, err);
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
})
);
}
// Deduplicate and sort
const eventMap = reset
? new Map(allEvents.map(event => [event.tagAddress(), event]))
: new Map([...eventsInView, ...allEvents].map(event => [event.tagAddress(), event]));
const uniqueEvents = Array.from(eventMap.values());
uniqueEvents.sort((a, b) => b.created_at! - a.created_at!);
eventsInView = uniqueEvents;
const pageSize = fallback.length > 0 ? 18 : 30;
if (fetchedCount < pageSize) {
endOfFeed = true;
} else {
endOfFeed = false;
}
console.debug(`[getEvents] Total unique events after deduplication: ${uniqueEvents.length}`);
console.debug(`[getEvents] endOfFeed set to: ${endOfFeed} (fetchedCount: ${fetchedCount}, pageSize: ${pageSize})`);
loading = false;
console.debug('Relay statuses:', relayStatuses);
}
const getSkeletonIds = (): string[] => {
const skeletonHeight = 124; // The height of the skeleton component in pixels.
// Determine the number of skeletons to display based on the height of the screen.
const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2;
const skeletonIds = [];
for (let i = 0; i < skeletonCount; i++) {
skeletonIds.push(`skeleton-${i}`);
@ -71,7 +206,7 @@ @@ -71,7 +206,7 @@
async function loadMorePublications() {
loadingMore = true;
await getEvents(cutoffTimestamp);
await getEvents(cutoffTimestamp, searchQuery, false);
loadingMore = false;
}
@ -80,21 +215,25 @@ @@ -80,21 +215,25 @@
});
</script>
<div class='leather flex flex-col space-y-4'>
{#if eventsInView.length === 0}
{#each getSkeletonIds() as id}
<Skeleton divClass='skeleton-leather w-full' size='lg' />
{/each}
{:else if eventsInView.length > 0}
{#each eventsInView as event}
<ArticleHeader {event} />
{/each}
{:else}
<p class='text-center'>No publications found.</p>
{/if}
<div class='leather'>
<div class='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id}
<Skeleton divClass='skeleton-leather w-full' size='lg' />
{/each}
{:else if eventsInView.length > 0}
{#each eventsInView as event}
<ArticleHeader {event} />
{/each}
{:else}
<div class='col-span-full'>
<p class='text-center'>No publications found.</p>
</div>
{/if}
</div>
{#if !loadingMore && !endOfFeed}
<div class='flex justify-center mt-4 mb-8'>
<Button outline class="w-full" onclick={async () => {
<Button outline class="w-full max-w-md" onclick={async () => {
await loadMorePublications();
}}>
Show more publications
@ -102,7 +241,7 @@ @@ -102,7 +241,7 @@
</div>
{:else if loadingMore}
<div class='flex justify-center mt-4 mb-8'>
<Button outline disabled class="w-full">
<Button outline disabled class="w-full max-w-md">
<Spinner class='mr-3 text-gray-300' size='4' />
Loading...
</Button>

9
src/lib/components/PublicationHeader.svelte

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
import { standardRelays } from '../consts';
import { Card, Img } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte";
import InlineProfile from "$components/util/InlineProfile.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
const { event } = $props<{ event: NDKEvent }>();
@ -24,15 +24,16 @@ @@ -24,15 +24,16 @@
);
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? 'unknown');
let author: string = $derived(event.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
console.log("PublicationHeader event:", event);
</script>
{#if title != null && href != null}
<Card class='ArticleBox card-leather w-lg flex flex-row space-x-2'>
<Card class='ArticleBox card-leather max-w-md flex flex-row space-x-2'>
{#if image}
<div class="flex col justify-center align-middle max-h-36 max-w-24 overflow-hidden">
<Img src={image} class="rounded w-full h-full object-cover"/>
@ -45,7 +46,7 @@ @@ -45,7 +46,7 @@
<h3 class='text-base font-normal'>
by
{#if authorPubkey != null}
<InlineProfile pubkey={authorPubkey} title={author} />
{@render userBadge(authorPubkey, author)}
{:else}
{author}
{/if}

5
src/lib/components/PublicationSection.svelte

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import { TextPlaceholder } from "flowbite-svelte";
import { getContext } from "svelte";
import type { Asciidoctor, Document } from "asciidoctor";
import { getMatchingTags } from '$lib/utils/nostrUtils';
let {
address,
@ -104,12 +105,12 @@ @@ -104,12 +105,12 @@
});
</script>
<section bind:this={sectionRef} class='publication-leather content-visibility-auto'>
<section id={address} bind:this={sectionRef} class='publication-leather content-visibility-auto'>
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])}
<TextPlaceholder size='xxl' />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)}
{@render sectionHeading(getMatchingTags(branch, 'title')[0]?.[1] ?? '', depth)}
{/each}
{#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1}

190
src/lib/components/RelayActions.svelte

@ -0,0 +1,190 @@ @@ -0,0 +1,190 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk";
import { get } from 'svelte/store';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { createRelaySetFromUrls, createNDKEvent } from '$lib/utils/nostrUtils';
import RelayDisplay, { getConnectedRelays, getEventRelays } from './RelayDisplay.svelte';
import { standardRelays, fallbackRelays } from "$lib/consts";
const { event } = $props<{
event: NDKEvent;
}>();
let searchingRelays = $state(false);
let foundRelays = $state<string[]>([]);
let broadcasting = $state(false);
let broadcastSuccess = $state(false);
let broadcastError = $state<string | null>(null);
let showRelayModal = $state(false);
let relaySearchResults = $state<Record<string, 'pending' | 'found' | 'notfound'>>({});
let allRelays = $state<string[]>([]);
// Magnifying glass icon SVG
const searchIcon = `<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg>`;
// Broadcast icon SVG
const broadcastIcon = `<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3"/>
</svg>`;
async function broadcastEvent() {
if (!event || !$ndkInstance?.activeUser) return;
broadcasting = true;
broadcastSuccess = false;
broadcastError = null;
try {
const connectedRelays = getConnectedRelays();
if (connectedRelays.length === 0) {
throw new Error('No connected relays available');
}
// Create a new event with the same content
const newEvent = createNDKEvent($ndkInstance, {
...event.rawEvent(),
pubkey: $ndkInstance.activeUser.pubkey,
created_at: Math.floor(Date.now() / 1000),
sig: ''
});
// Publish to all relays
await newEvent.publish();
broadcastSuccess = true;
} catch (err) {
console.error('Error broadcasting event:', err);
broadcastError = err instanceof Error ? err.message : 'Failed to broadcast event';
} finally {
broadcasting = false;
}
}
function openRelayModal() {
showRelayModal = true;
relaySearchResults = {};
searchAllRelaysLive();
}
async function searchAllRelaysLive() {
if (!event) return;
relaySearchResults = {};
const ndk = get(ndkInstance);
const userRelays = Array.from(ndk?.pool?.relays.values() || []).map(r => r.url);
allRelays = [
...standardRelays,
...userRelays,
...fallbackRelays
].filter((url, idx, arr) => arr.indexOf(url) === idx);
relaySearchResults = Object.fromEntries(allRelays.map((r: string) => [r, 'pending']));
await Promise.all(
allRelays.map(async (relay: string) => {
try {
const relaySet = createRelaySetFromUrls([relay], ndk);
const found = await ndk.fetchEvent(
{ ids: [event?.id || ''] },
undefined,
relaySet
).withTimeout(3000);
relaySearchResults = { ...relaySearchResults, [relay]: found ? 'found' : 'notfound' };
} catch {
relaySearchResults = { ...relaySearchResults, [relay]: 'notfound' };
}
})
);
}
function closeRelayModal() {
showRelayModal = false;
}
</script>
<div class="mt-4 flex flex-wrap gap-2">
<Button
on:click={openRelayModal}
class="flex items-center"
>
{@html searchIcon}
Where can I find this event?
</Button>
{#if $ndkInstance?.activeUser}
<Button
on:click={broadcastEvent}
disabled={broadcasting}
class="flex items-center"
>
{@html broadcastIcon}
{broadcasting ? 'Broadcasting...' : 'Broadcast'}
</Button>
{/if}
</div>
{#if foundRelays.length > 0}
<div class="mt-2">
<span class="font-semibold">Found on {foundRelays.length} relay(s):</span>
<div class="flex flex-wrap gap-2 mt-1">
{#each foundRelays as relay}
<RelayDisplay {relay} />
{/each}
</div>
</div>
{/if}
{#if broadcastSuccess}
<div class="mt-2 p-2 bg-green-100 text-green-700 rounded">
Event broadcast successfully to:
<div class="flex flex-wrap gap-2 mt-1">
{#each getConnectedRelays() as relay}
<RelayDisplay {relay} />
{/each}
</div>
</div>
{/if}
{#if broadcastError}
<div class="mt-2 p-2 bg-red-100 text-red-700 rounded">
{broadcastError}
</div>
{/if}
<div class="mt-2">
<span class="font-semibold">Found on:</span>
<div class="flex flex-wrap gap-2 mt-1">
{#each getEventRelays(event) as relay}
<RelayDisplay {relay} />
{/each}
</div>
</div>
{#if showRelayModal}
<div class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-lg relative">
<button class="absolute top-2 right-2 text-gray-500 hover:text-gray-800" onclick={closeRelayModal}>&times;</button>
<h2 class="text-lg font-semibold mb-4">Relay Search Results</h2>
<div class="flex flex-col gap-4 max-h-96 overflow-y-auto">
{#each Object.entries({
'Standard Relays': standardRelays,
'User Relays': Array.from($ndkInstance?.pool?.relays.values() || []).map(r => r.url),
'Fallback Relays': fallbackRelays
}) as [groupName, groupRelays]}
{#if groupRelays.length > 0}
<div class="flex flex-col gap-2">
<h3 class="font-medium text-gray-700 dark:text-gray-300 sticky top-0 bg-white dark:bg-gray-900 py-2">
{groupName}
</h3>
{#each groupRelays as relay}
<RelayDisplay {relay} showStatus={true} status={relaySearchResults[relay] || null} />
{/each}
</div>
{/if}
{/each}
</div>
<div class="mt-4 flex justify-end">
<Button onclick={closeRelayModal}>Close</Button>
</div>
</div>
</div>
{/if}

59
src/lib/components/RelayDisplay.svelte

@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
<script lang="ts" context="module">
import type { NDKEvent } from '$lib/utils/nostrUtils';
// Get relays from event (prefer event.relay or event.relays, fallback to standardRelays)
export function getEventRelays(event: NDKEvent): string[] {
if (event && (event as any).relay) {
const relay = (event as any).relay;
return [typeof relay === 'string' ? relay : relay.url];
}
if (event && (event as any).relays && (event as any).relays.length) {
return (event as any).relays.map((r: any) => typeof r === 'string' ? r : r.url);
}
return standardRelays;
}
export function getConnectedRelays(): string[] {
const ndk = get(ndkInstance);
return Array.from(ndk?.pool?.relays.values() || [])
.filter(r => r.status === 1) // Only use connected relays
.map(r => r.url);
}
</script>
<script lang="ts">
import { get } from 'svelte/store';
import { ndkInstance } from "$lib/ndk";
import { standardRelays } from "$lib/consts";
export let relay: string;
export let showStatus = false;
export let status: 'pending' | 'found' | 'notfound' | null = null;
// Use a static fallback icon for all relays
function relayFavicon(relay: string): string {
return '/favicon.png';
}
</script>
<div class="flex items-center gap-2 p-2 rounded border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
<img
src={relayFavicon(relay)}
alt="relay icon"
class="w-5 h-5 object-contain"
onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }}
/>
<span class="font-mono text-xs flex-1">{relay}</span>
{#if showStatus && status}
{#if status === 'pending'}
<svg class="w-4 h-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
{:else if status === 'found'}
<span class="text-green-600">&#10003;</span>
{:else}
<span class="text-red-500">&#10007;</span>
{/if}
{/if}
</div>

70
src/lib/components/cards/BlogHeader.svelte

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
<script lang="ts">
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { scale } from 'svelte/transition';
import { Card, Img } from "flowbite-svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import Interactions from "$components/util/Interactions.svelte";
import { quintOut } from "svelte/easing";
import CardActions from "$components/util/CardActions.svelte";
const { rootId, event, onBlogUpdate, active = true } = $props<{ rootId: string, event: NDKEvent, onBlogUpdate?: any, active: boolean }>();
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
let hashtags: string = $derived(event.getMatchingTags('t') ?? null);
function publishedAt() {
const date = event.created_at ? new Date(event.created_at * 1000) : '';
if (date !== '') {
const formattedDate = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "2-digit",
}).format(date);
return formattedDate ?? "";
}
return '';
}
function showBlog() {
onBlogUpdate?.(rootId);
}
</script>
{#if title != null}
<Card class="ArticleBox card-leather w-full grid max-w-xl {active ? 'active' : ''}">
<div class='space-y-4'>
<div class="flex flex-row justify-between my-2">
<div class="flex flex-col">
{@render userBadge(authorPubkey, author)}
<span class='text-gray-500'>{publishedAt()}</span>
</div>
<CardActions event={event} />
</div>
{#if image && active}
<div class="ArticleBoxImage flex col justify-center"
in:scale={{ start: 0.8, duration: 500, delay: 100, easing: quintOut }}
>
<Img src={image} class="rounded w-full max-h-72 object-cover"/>
</div>
{/if}
<div class='flex flex-col flex-grow space-y-4'>
<button onclick={() => showBlog()} class='text-left'>
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2>
</button>
{#if hashtags}
<div class="tags">
{#each hashtags as tag}
<span>{tag}</span>
{/each}
</div>
{/if}
</div>
{#if active}
<Interactions rootId={rootId} event={event} />
{/if}
</div>
</Card>
{/if}

120
src/lib/components/cards/ProfileHeader.svelte

@ -0,0 +1,120 @@ @@ -0,0 +1,120 @@
<script lang="ts">
import { Card, Img, Modal, Button, P } from "flowbite-svelte";
import { onMount } from "svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { type NostrProfile, toNpub } from "$lib/utils/nostrUtils.ts";
import QrCode from "$components/util/QrCode.svelte";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
// @ts-ignore
import { bech32 } from 'https://esm.sh/bech32';
import type { NDKEvent } from "@nostr-dev-kit/ndk";
const { event, profile, identifiers = [] } = $props<{ event: NDKEvent, profile: NostrProfile, identifiers?: { label: string, value: string, link?: string }[] }>();
let lnModalOpen = $state(false);
let lnurl = $state<string | null>(null);
onMount(async () => {
if (profile?.lud16) {
try {
// Convert LN address to LNURL
const [name, domain] = profile?.lud16.split('@');
const url = `https://${domain}/.well-known/lnurlp/${name}`;
const words = bech32.toWords(new TextEncoder().encode(url));
lnurl = bech32.encode('lnurl', words);
} catch {
console.log('Error converting LN address to LNURL');
}
}
});
</script>
{#if profile}
<Card class="ArticleBox card-leather w-full max-w-2xl">
<div class='space-y-4'>
{#if profile.banner}
<div class="ArticleBoxImage flex col justify-center">
<Img src={profile.banner} class="rounded w-full max-h-72 object-cover" alt="Profile banner" onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none';}} />
</div>
{/if}
<div class='flex flex-row space-x-4 items-center'>
{#if profile.picture}
<img src={profile.picture} alt="Profile avatar" class="w-16 h-16 rounded-full border" onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }} />
{/if}
{@render userBadge(toNpub(event.pubkey) as string, profile.displayName || profile.name || event.pubkey)}
</div>
<div>
<div class="mt-2 flex flex-col gap-4">
<dl class="grid grid-cols-1 gap-y-2">
{#if profile.name}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Name:</dt>
<dd>{profile.name}</dd>
</div>
{/if}
{#if profile.displayName}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Display Name:</dt>
<dd>{profile.displayName}</dd>
</div>
{/if}
{#if profile.about}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">About:</dt>
<dd class="whitespace-pre-line">{profile.about}</dd>
</div>
{/if}
{#if profile.website}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Website:</dt>
<dd>
<a href={profile.website} target="_blank" class="underline text-primary-700 dark:text-primary-200">{profile.website}</a>
</dd>
</div>
{/if}
{#if profile.lud16}
<div class="flex items-center gap-2 mt-4">
<dt class="font-semibold min-w-[120px]">Lightning Address:</dt>
<dd><Button class="btn-leather" color="primary" outline onclick={() => lnModalOpen = true}>{profile.lud16}</Button> </dd>
</div>
{/if}
{#if profile.nip05}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">NIP-05:</dt>
<dd>{profile.nip05}</dd>
</div>
{/if}
{#each identifiers as id}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">{id.label}:</dt>
<dd class="break-all">{#if id.link}<a href={id.link} class="underline text-primary-700 dark:text-primary-200 break-all">{id.value}</a>{:else}{id.value}{/if}</dd>
</div>
{/each}
</dl>
</div>
</div>
</div>
</Card>
<Modal class='modal-leather' title='Lightning Address' bind:open={lnModalOpen} outsideclose size='sm'>
{#if profile.lud16}
<div>
<div class='flex flex-col items-center'>
{@render userBadge(toNpub(event.pubkey) as string, profile?.displayName || profile.name || event.pubkey)}
<P>{profile.lud16}</P>
</div>
<div class="flex flex-col items-center mt-3 space-y-4">
<P>Scan the QR code or copy the address</P>
{#if lnurl}
<P style="overflow-wrap: anywhere">
<CopyToClipboard icon={false} displayText={lnurl}></CopyToClipboard>
</P>
<QrCode value={lnurl} />
{:else}
<P>Couldn't generate address.</P>
{/if}
</div>
</div>
{/if}
</Modal>
{/if}

148
src/lib/components/util/ArticleNav.svelte

@ -0,0 +1,148 @@ @@ -0,0 +1,148 @@
<script lang="ts">
import { BookOutline, CaretLeftOutline, CloseOutline, GlobeOutline } from "flowbite-svelte-icons";
import { Button } from "flowbite-svelte";
import { publicationColumnVisibility } from "$lib/stores";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onDestroy, onMount } from "svelte";
let {
publicationType,
indexEvent
} = $props<{
rootId: any,
publicationType: string,
indexEvent: NDKEvent
}>();
let title: string = $derived(indexEvent.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(indexEvent.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let pubkey: string = $derived(indexEvent.getMatchingTags('p')[0]?.[1] ?? null);
let isLeaf: boolean = $derived(indexEvent.kind === 30041);
let lastScrollY = $state(0);
let isVisible = $state(true);
// Function to toggle column visibility
function toggleColumn(column: 'toc' | 'blog' | 'inner' | 'discussion') {
publicationColumnVisibility.update(current => {
const newValue = !current[column];
const updated = { ...current, [column]: newValue };
if (window.innerWidth < 1400 && column === 'blog' && newValue) {
updated.discussion = false;
}
return updated;
});
}
function shouldShowBack() {
const vis = $publicationColumnVisibility;
return ['discussion', 'toc', 'inner'].some(key => vis[key as keyof typeof vis]);
}
function backToMain() {
publicationColumnVisibility.update(current => {
const updated = { ...current };
// if current is 'inner', just go back to blog
if (current.inner && !(current.discussion || current.toc)) {
updated.inner = false;
updated.blog = true;
return updated;
}
updated.discussion = false;
updated.toc = false;
if (publicationType === 'blog') {
updated.inner = true;
updated.blog = false;
} else {
updated.main = true;
}
return updated;
});
}
function backToBlog() {
publicationColumnVisibility.update(current => {
const updated = { ...current };
updated.inner = false;
updated.discussion = false;
updated.blog = true;
return updated;
})
}
function handleScroll() {
if (window.innerWidth < 768) {
const currentScrollY = window.scrollY;
// Hide on scroll down
if (currentScrollY > lastScrollY && currentScrollY > 50) {
isVisible = false;
}
// Show on scroll up
else if (currentScrollY < lastScrollY) {
isVisible = true;
}
lastScrollY = currentScrollY;
}
}
let unsubscribe: () => void;
onMount(() => {
window.addEventListener('scroll', handleScroll);
unsubscribe = publicationColumnVisibility.subscribe(() => {
isVisible = true; // show navbar when store changes
});
});
onDestroy(() => {
window.removeEventListener('scroll', handleScroll);
unsubscribe();
});
</script>
<nav class="Navbar navbar-leather flex fixed top-[60px] sm:top-[76px] w-full min-h-[70px] px-2 sm:px-4 py-2.5 z-10 transition-transform duration-300 {isVisible ? 'translate-y-0' : '-translate-y-full'}">
<div class="mx-auto flex space-x-2 container">
<div class="flex items-center space-x-2 md:min-w-52 min-w-8">
{#if shouldShowBack()}
<Button class='btn-leather !w-auto sm:hidden' outline={true} onclick={backToMain}>
<CaretLeftOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Back</span>
</Button>
{/if}
{#if !isLeaf}
{#if publicationType === 'blog'}
<Button class="btn-leather hidden sm:flex !w-auto {$publicationColumnVisibility.blog ? 'active' : ''}"
outline={true} onclick={() => toggleColumn('blog')} >
<BookOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Table of Contents</span>
</Button>
{:else if !$publicationColumnVisibility.discussion && !$publicationColumnVisibility.toc}
<Button class='btn-leather !w-auto' outline={true} onclick={() => toggleColumn('toc')}>
<BookOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Table of Contents</span>
</Button>
{/if}
{/if}
</div>
<div class="flex flex-grow text justify-center items-center">
<p class="max-w-[60vw] line-ellipsis"><b class="text-nowrap">{title}</b> <span class="whitespace-nowrap">by {@render userBadge(pubkey, author)}</span></p>
</div>
<div class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8">
{#if $publicationColumnVisibility.inner}
<Button class='btn-leather !w-auto hidden sm:flex' outline={true} onclick={backToBlog}>
<CloseOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Close</span>
</Button>
{/if}
{#if publicationType !== 'blog' && !$publicationColumnVisibility.discussion}
<Button class="btn-leather !hidden sm:flex !w-auto" outline={true} onclick={() => toggleColumn('discussion')} >
<GlobeOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Discussion</span>
</Button>
{/if}
</div>
</div>
</nav>

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

@ -1,90 +1,115 @@ @@ -1,90 +1,115 @@
<script lang="ts">
import {
ClipboardCheckOutline,
ClipboardCleanOutline,
CodeOutline,
DotsVerticalOutline,
EyeOutline,
ShareNodesOutline
} from "flowbite-svelte-icons";
import { Button, Modal, Popover } from "flowbite-svelte";
import { standardRelays } from "$lib/consts";
import { standardRelays, FeedType } from "$lib/consts";
import { neventEncode, naddrEncode } from "$lib/utils";
import InlineProfile from "$components/util/InlineProfile.svelte";
let { event } = $props();
let jsonModalOpen: boolean = $state(false);
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { feedType } from "$lib/stores";
import { inboxRelays, ndkSignedIn } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
// Component props
let { event } = $props<{ event: NDKEvent }>();
// Derive metadata from event
let title = $derived(event.tags.find((t: string[]) => t[0] === 'title')?.[1] ?? '');
let summary = $derived(event.tags.find((t: string[]) => t[0] === 'summary')?.[1] ?? '');
let image = $derived(event.tags.find((t: string[]) => t[0] === 'image')?.[1] ?? null);
let author = $derived(event.tags.find((t: string[]) => t[0] === 'author')?.[1] ?? '');
let originalAuthor = $derived(event.tags.find((t: string[]) => t[0] === 'original_author')?.[1] ?? null);
let version = $derived(event.tags.find((t: string[]) => t[0] === 'version')?.[1] ?? '');
let source = $derived(event.tags.find((t: string[]) => t[0] === 'source')?.[1] ?? null);
let type = $derived(event.tags.find((t: string[]) => t[0] === 'type')?.[1] ?? null);
let language = $derived(event.tags.find((t: string[]) => t[0] === 'language')?.[1] ?? null);
let publisher = $derived(event.tags.find((t: string[]) => t[0] === 'publisher')?.[1] ?? null);
let identifier = $derived(event.tags.find((t: string[]) => t[0] === 'identifier')?.[1] ?? null);
// UI state
let detailsModalOpen: boolean = $state(false);
let eventIdCopied: boolean = $state(false);
let shareLinkCopied: boolean = $state(false);
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? 'unknown');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let originalAuthor: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
let summary: string = $derived(event.getMatchingTags('summary')[0]?.[1] ?? null);
let type: string = $derived(event.getMatchingTags('type')[0]?.[1] ?? null);
let language: string = $derived(event.getMatchingTags('l')[0]?.[1] ?? null);
let source: string = $derived(event.getMatchingTags('source')[0]?.[1] ?? null);
let publisher: string = $derived(event.getMatchingTags('published_by')[0]?.[1] ?? null);
let identifier: string = $derived(event.getMatchingTags('i')[0]?.[1] ?? null);
let isOpen = $state(false);
let isOpen: boolean = $state(false);
/**
* Selects the appropriate relay set based on user state and feed type
* - Uses user's inbox relays when signed in and viewing personal feed
* - Falls back to standard relays for anonymous users or standard feed
*/
let activeRelays = $derived(
(() => {
const isUserFeed = $ndkSignedIn && $feedType === FeedType.UserRelays;
const relays = isUserFeed ? $inboxRelays : standardRelays;
console.debug("[CardActions] Selected relays:", {
eventId: event.id,
isSignedIn: $ndkSignedIn,
feedType: $feedType,
isUserFeed,
relayCount: relays.length,
relayUrls: relays
});
return relays;
})()
);
/**
* Opens the actions popover menu
*/
function openPopover() {
console.debug("[CardActions] Opening menu", { eventId: event.id });
isOpen = true;
}
/**
* Closes the actions popover menu and removes focus
*/
function closePopover() {
console.debug("[CardActions] Closing menu", { eventId: event.id });
isOpen = false;
const menu = document.getElementById('dots-' + event.id);
if (menu) menu.blur();
}
function shareNjump() {
const relays: string[] = standardRelays;
try {
const naddr = naddrEncode(event, relays);
console.debug(naddr);
navigator.clipboard.writeText(`https://njump.me/${naddr}`);
shareLinkCopied = true;
setTimeout(() => {
shareLinkCopied = false;
}, 4000);
}
catch (e) {
console.error('Failed to encode naddr:', e);
}
}
function copyEventId() {
console.debug("copyEventID");
const relays: string[] = standardRelays;
const nevent = neventEncode(event, relays);
navigator.clipboard.writeText(nevent);
eventIdCopied = true;
setTimeout(() => {
eventIdCopied = false;
}, 4000);
}
function viewJson() {
console.debug("viewJSON");
jsonModalOpen = true;
/**
* Gets the appropriate identifier (nevent or naddr) for copying
* @param type - The type of identifier to get ('nevent' or 'naddr')
* @returns The encoded identifier string
*/
function getIdentifier(type: 'nevent' | 'naddr'): string {
const encodeFn = type === 'nevent' ? neventEncode : naddrEncode;
const identifier = encodeFn(event, activeRelays);
console.debug("[CardActions] ${type} identifier for event ${event.id}:", identifier);
return identifier;
}
/**
* Opens the event details modal
*/
function viewDetails() {
console.log('Details');
console.debug("[CardActions] Opening details modal", {
eventId: event.id,
title: event.title,
author: event.author
});
detailsModalOpen = true;
}
// Log component initialization
console.debug("[CardActions] Initialized", {
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
title: event.title,
author: event.author
});
</script>
<div class="group" role="group" onmouseenter={openPopover}>
<div class="group bg-highlight dark:bg-primary-1000 rounded" role="group" onmouseenter={openPopover}>
<!-- Main button -->
<Button type="button"
id="dots-{event.id}"
@ -110,88 +135,79 @@ @@ -110,88 +135,79 @@
</button>
</li>
<li>
<button class='btn-leather w-full text-left' onclick={shareNjump}>
{#if shareLinkCopied}
<ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else}
<ShareNodesOutline class="inline mr-2" /> Share via NJump
{/if}
</button>
<CopyToClipboard
displayText="Copy naddr address"
copyText={getIdentifier('naddr')}
icon={ShareNodesOutline}
/>
</li>
<li>
<button class='btn-leather w-full text-left' onclick={copyEventId}>
{#if eventIdCopied}
<ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else}
<ClipboardCleanOutline class="inline mr-2" /> Copy event ID
{/if}
</button>
</li>
<li>
<button class='btn-leather w-full text-left' onclick={viewJson}>
<CodeOutline class="inline mr-2" /> View JSON
</button>
<CopyToClipboard
displayText="Copy nevent address"
copyText={getIdentifier('nevent')}
icon={ClipboardCleanOutline}
/>
</li>
</ul>
</div>
</div>
</Popover>
{/if}
<!-- Event JSON -->
<Modal class='modal-leather' title='Event JSON' bind:open={jsonModalOpen} autoclose outsideclose size='lg'>
<div class="overflow-auto bg-highlight dark:bg-primary-900 text-sm rounded p-1" style="max-height: 70vh;">
<pre><code>{JSON.stringify(event.rawEvent(), null, 2)}</code></pre>
</div>
</Modal>
<!-- Event details -->
<Modal class='modal-leather' title='Publication details' bind:open={detailsModalOpen} autoclose outsideclose size='sm'>
<div class="flex flex-row space-x-4">
{#if image}
<div class="flex col">
<img class="max-w-48" src={image} alt="Publication cover" />
</div>
<div class="flex col">
<img src={image} alt="Publication cover" class="w-32 h-32 object-cover rounded" />
</div>
{/if}
<div class="flex flex-col col space-y-5 justify-center align-middle">
<h1 class="text-3xl font-bold mt-5">{title}</h1>
<div class="flex flex-col col space-y-5 justify-center align-middle">
<h1 class="text-3xl font-bold mt-5">{title || 'Untitled'}</h1>
<h2 class="text-base font-bold">by
{#if originalAuthor !== null}
<InlineProfile pubkey={originalAuthor} title={author} />
{#if originalAuthor}
{@render userBadge(originalAuthor, author)}
{:else}
{author}
{author || 'Unknown'}
{/if}
</h2>
<h4 class='text-base font-thin mt-2'>Version: {version}</h4>
{#if version}
<h4 class='text-base font-thin mt-2'>Version: {version}</h4>
{/if}
</div>
</div>
{#if summary}
<div class="flex flex-row ">
<div class="flex flex-row">
<p class='text-base text-primary-900 dark:text-highlight'>{summary}</p>
</div>
{/if}
<div class="flex flex-row ">
<h4 class='text-base font-normal mt-2'>Index author: <InlineProfile pubkey={event.pubkey} /></h4>
<div class="flex flex-row">
<h4 class='text-base font-normal mt-2'>Index author: {@render userBadge(event.pubkey, author)}</h4>
</div>
<div class="flex flex-col pb-4 space-y-1">
{#if source !== null}
<h5 class="text-sm">Source: <a class="underline" href={source} target="_blank">{source}</a></h5>
{#if source}
<h5 class="text-sm">Source: <a class="underline" href={source} target="_blank" rel="noopener noreferrer">{source}</a></h5>
{/if}
{#if type !== null}
{#if type}
<h5 class="text-sm">Publication type: {type}</h5>
{/if}
{#if language !== null}
{#if language}
<h5 class="text-sm">Language: {language}</h5>
{/if}
{#if publisher !== null}
{#if publisher}
<h5 class="text-sm">Published by: {publisher}</h5>
{/if}
{#if identifier !== null}
<h5 class="text-sm">{identifier}</h5>
{#if identifier}
<h5 class="text-sm">Identifier: {identifier}</h5>
{/if}
<a
href="/events?id={getIdentifier('nevent')}"
class="mt-4 btn-leather text-center text-primary-700 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 font-semibold"
>
View Event Details
</a>
</div>
</Modal>
</div>
</div>

33
src/lib/components/util/CopyToClipboard.svelte

@ -1,27 +1,44 @@ @@ -1,27 +1,44 @@
<script lang='ts'>
import { ClipboardCheckOutline, ClipboardCleanOutline } from "flowbite-svelte-icons";
import { withTimeout } from "$lib/utils/nostrUtils";
import type { Component } from "svelte";
let { displayText, copyText = displayText} = $props();
let { displayText, copyText = displayText, icon = ClipboardCleanOutline } = $props<{
displayText: string;
copyText?: string;
icon?: Component | false;
}>();
let copied: boolean = $state(false);
async function copyToClipboard() {
try {
await navigator.clipboard.writeText(copyText);
await withTimeout(navigator.clipboard.writeText(copyText), 2000);
copied = true;
setTimeout(() => {
await withTimeout(
new Promise(resolve => setTimeout(resolve, 4000)),
4000
).then(() => {
copied = false;
}, 4000);
}).catch(() => {
// If timeout occurs, still reset the state
copied = false;
});
} catch (err) {
console.error("Failed to copy: ", err);
console.error("[CopyToClipboard] Failed to copy:", err instanceof Error ? err.message : err);
}
}
</script>
<button class='btn-leather text-nowrap' onclick={copyToClipboard}>
<button class='btn-leather w-full text-left' onclick={copyToClipboard}>
{#if copied}
<ClipboardCheckOutline class="!fill-none dark:!fill-none inline mr-1" /> Copied!
<ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else}
<ClipboardCleanOutline class="!fill-none dark:!fill-none inline mr-1" /> {displayText}
{#if icon === ClipboardCleanOutline}
<ClipboardCleanOutline class="inline mr-2" />
{:else if icon === ClipboardCheckOutline}
<ClipboardCheckOutline class="inline mr-2" />
{/if}
{displayText}
{/if}
</button>

110
src/lib/components/util/Details.svelte

@ -0,0 +1,110 @@ @@ -0,0 +1,110 @@
<script lang="ts">
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import CardActions from "$components/util/CardActions.svelte";
import Interactions from "$components/util/Interactions.svelte";
import { P } from "flowbite-svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils';
// isModal
// - don't show interactions in modal view
// - don't show all the details when _not_ in modal view
let { event, isModal = false } = $props();
let title: string = $derived(getMatchingTags(event, 'title')[0]?.[1]);
let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let version: string = $derived(getMatchingTags(event, 'version')[0]?.[1] ?? '1');
let image: string = $derived(getMatchingTags(event, 'image')[0]?.[1] ?? null);
let originalAuthor: string = $derived(getMatchingTags(event, 'p')[0]?.[1] ?? null);
let summary: string = $derived(getMatchingTags(event, 'summary')[0]?.[1] ?? null);
let type: string = $derived(getMatchingTags(event, 'type')[0]?.[1] ?? null);
let language: string = $derived(getMatchingTags(event, 'l')[0]?.[1] ?? null);
let source: string = $derived(getMatchingTags(event, 'source')[0]?.[1] ?? null);
let publisher: string = $derived(getMatchingTags(event, 'published_by')[0]?.[1] ?? null);
let identifier: string = $derived(getMatchingTags(event, 'i')[0]?.[1] ?? null);
let hashtags: string[] = $derived(getMatchingTags(event, 't').map(tag => tag[1]));
let rootId: string = $derived(getMatchingTags(event, 'd')[0]?.[1] ?? null);
let kind = $derived(event.kind);
</script>
<div class="flex flex-col relative mb-2">
{#if !isModal}
<div class="flex flex-row justify-between items-center">
<P class='text-base font-normal'>{@render userBadge(event.pubkey, author)}</P>
<CardActions event={event}></CardActions>
</div>
{/if}
<div class="flex-grow grid grid-cols-1 md:grid-cols-[auto_1fr] gap-4 items-center">
{#if image}
<div class="my-2">
<img class="w-full md:max-w-48 object-contain rounded" alt={title} src={image} />
</div>
{/if}
<div class="space-y-4 my-4">
<h1 class="text-3xl font-bold">{title}</h1>
<h2 class="text-base font-bold">
by
{#if originalAuthor !== null}
{@render userBadge(originalAuthor, author)}
{:else}
{author}
{/if}
</h2>
{#if version !== '1' }
<h4 class="text-base font-thin">Version: {version}</h4>
{/if}
</div>
</div>
</div>
{#if summary}
<div class="flex flex-row my-2">
<p class='text-base text-primary-900 dark:text-highlight'>{summary}</p>
</div>
{/if}
{#if hashtags.length}
<div class="tags my-2">
{#each hashtags as tag}
<span class="text-sm">#{tag}</span>
{/each}
</div>
{/if}
{#if isModal}
<div class="flex flex-row my-4">
<h4 class='text-base font-normal mt-2'>
{#if kind === 30040}
<span>Index author:</span>
{:else}
<span>Author:</span>
{/if}
{@render userBadge(event.pubkey, author)}
</h4>
</div>
<div class="flex flex-col pb-4 space-y-1">
{#if source !== null}
<h5 class="text-sm">Source: <a class="underline break-all" href={source} target="_blank">{source}</a></h5>
{/if}
{#if type !== null}
<h5 class="text-sm">Publication type: {type}</h5>
{/if}
{#if language !== null}
<h5 class="text-sm">Language: {language}</h5>
{/if}
{#if publisher !== null}
<h5 class="text-sm">Published by: {publisher}</h5>
{/if}
{#if identifier !== null}
<h5 class="text-sm">{identifier}</h5>
{/if}
</div>
{/if}
{#if !isModal}
<Interactions event={event} rootId={rootId} direction="row"/>
{/if}

59
src/lib/components/util/InlineProfile.svelte

@ -1,59 +0,0 @@ @@ -1,59 +0,0 @@
<script lang='ts'>
import { Avatar } from 'flowbite-svelte';
import { type NDKUserProfile } from "@nostr-dev-kit/ndk";
import { ndkInstance } from '$lib/ndk';
let { pubkey, title = null } = $props();
const externalProfileDestination = 'https://njump.me/'
let loading = $state(true);
let anon = $state(false);
let npub = $state('');
let profile = $state<NDKUserProfile | null>(null);
let pfp = $derived(profile?.image);
let username = $derived(profile?.name);
async function fetchUserData(pubkey: string) {
let user;
user = $ndkInstance
.getUser({ pubkey: pubkey ?? undefined });
npub = user.npub;
user.fetchProfile()
.then(userProfile => {
profile = userProfile;
if (!profile?.name) anon = true;
loading = false;
});
}
// Fetch data when component mounts
$effect(() => {
if (pubkey) {
fetchUserData(pubkey);
}
});
function shortenNpub(long: string|undefined) {
if (!long) return '';
return long.slice(0, 8) + '…' + long.slice(-4);
}
</script>
{#if loading}
{title ?? '…'}
{:else if anon }
<a class='underline' href='{externalProfileDestination}{npub}' title={title ?? npub} target='_blank'>{shortenNpub(npub)}</a>
{:else if npub }
<a href='{externalProfileDestination}{npub}' title={title ?? username} target='_blank'>
<Avatar rounded
class='h-6 w-6 mx-1 cursor-pointer inline'
src={pfp}
alt={username} />
<span class='underline'>{username ?? shortenNpub(npub)}</span>
</a>
{:else}
{title ?? pubkey}
{/if}

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

@ -0,0 +1,93 @@ @@ -0,0 +1,93 @@
<script lang="ts">
import {
Button, Modal, P
} from "flowbite-svelte";
import { HeartOutline, FilePenOutline, AnnotationOutline } from 'flowbite-svelte-icons';
import ZapOutline from "$components/util/ZapOutline.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte";
import { ndkInstance } from '$lib/ndk';
import { publicationColumnVisibility } from "$lib/stores";
const { rootId, event, direction = 'row' } = $props<{ rootId: string, event?: NDKEvent, direction?: string }>();
// Reactive arrays to hold incoming events
let likes: NDKEvent[] = [];
let zaps: NDKEvent[] = [];
let highlights: NDKEvent[] = [];
let comments: NDKEvent[] = [];
let interactionOpen: boolean = $state(false);
// Reactive counts derived from array lengths
// Derived counts from store values
const likeCount = $derived(likes.length);
const zapCount = $derived(zaps.length);
const highlightCount = $derived(highlights.length);
const commentCount = $derived(comments.length);
/**
* Subscribe to Nostr events of a given kind that reference our root event via e-tag.
* Push new events into the provided array if not already present.
* Returns the subscription for later cleanup.
*/
function subscribeCount(kind: number, targetArray: NDKEvent[]) {
const sub = $ndkInstance.subscribe({
kinds: [kind],
'#a': [rootId] // Will this work?
});
sub.on('event', (evt: NDKEvent) => {
// Only add if we haven't seen this event ID yet
if (!targetArray.find(e => e.id === evt.id)) {
targetArray.push(evt);
}
});
return sub;
}
let subs: any[] = [];
onMount(() => {
// Subscribe to each kind; store subs for cleanup
subs.push(subscribeCount(7, likes)); // likes (Reaction)
subs.push(subscribeCount(9735, zaps)); // zaps (Zap Receipts)
subs.push(subscribeCount(30023, highlights)); // highlights (custom kind)
subs.push(subscribeCount(1, comments)); // comments (Text Notes)
});
function showDiscussion() {
publicationColumnVisibility.update(v => {
const updated = { ...v, discussion: true};
// hide blog, unless the only column
if (v.inner) {
updated.blog = (v.blog && window.innerWidth >= 1400 );
}
return updated;
});
}
function doLike() {
interactionOpen = true;
}
function doHighlight() {
interactionOpen = true;
}
function doZap() {
interactionOpen = true;
}
</script>
<div class='InteractiveMenu !hidden flex-{direction} justify-around align-middle text-primary-700 dark:text-gray-500'>
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doLike}><HeartOutline class="mx-2" size="lg" /><span>{likeCount}</span></Button>
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doZap}><ZapOutline className="mx-2" /><span>{zapCount}</span></Button>
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doHighlight}><FilePenOutline class="mx-2" size="lg"/><span>{highlightCount}</span></Button>
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={showDiscussion}><AnnotationOutline class="mx-2" size="lg"/><span>{commentCount}</span></Button>
</div>
<Modal class='modal-leather' title='Interaction' bind:open={interactionOpen} autoclose outsideclose size='sm'>
<P>Can't like, zap or highlight yet.</P>
<P>You should totally check out the discussion though.</P>
</Modal>

2
src/lib/components/util/Profile.svelte

@ -5,7 +5,7 @@ import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flow @@ -5,7 +5,7 @@ import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flow
import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
const externalProfileDestination = 'https://njump.me/'
const externalProfileDestination = './events?id='
let { pubkey, isNav = false } = $props();

17
src/lib/components/util/QrCode.svelte

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
import QRCode from 'qrcode';
export let value: string;
let canvas: HTMLCanvasElement;
async function renderQR() {
if (canvas && value) {
await QRCode.toCanvas(canvas, value, { width: 240 });
}
}
onMount(renderQR);
</script>
<canvas class="qr-code" bind:this={canvas}></canvas>

143
src/lib/components/util/TocToggle.svelte

@ -0,0 +1,143 @@ @@ -0,0 +1,143 @@
<script lang="ts">
import {
Heading,
Sidebar,
SidebarGroup,
SidebarItem,
SidebarWrapper,
} from "flowbite-svelte";
import { onMount } from "svelte";
import { pharosInstance, tocUpdate } from "$lib/parser";
import { publicationColumnVisibility } from "$lib/stores";
let { rootId } = $props<{ rootId: string }>();
if (rootId !== $pharosInstance.getRootIndexId()) {
console.error("Root ID does not match parser root index ID");
}
const tocBreakpoint = 1140;
let activeHash = $state(window.location.hash);
interface TocItem {
label: string;
hash: string;
}
// Get TOC items from parser
let tocItems = $state<TocItem[]>([]);
$effect(() => {
// This will re-run whenever tocUpdate changes
tocUpdate;
const items: TocItem[] = [];
const childIds = $pharosInstance.getChildIndexIds(rootId);
console.log('TOC rootId:', rootId, 'childIds:', childIds);
const processNode = (nodeId: string) => {
const title = $pharosInstance.getIndexTitle(nodeId);
if (title) {
items.push({
label: title,
hash: `#${nodeId}`
});
}
const children = $pharosInstance.getChildIndexIds(nodeId);
children.forEach(processNode);
};
childIds.forEach(processNode);
tocItems = items;
});
function normalizeHashPath(str: string): string {
return str
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^\w-]/g, "");
}
function scrollToElementWithOffset() {
const hash = window.location.hash;
if (hash) {
const targetElement = document.querySelector(hash);
if (targetElement) {
const headerOffset = 80;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.scrollY - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: "auto",
});
}
}
}
function updateActiveHash() {
activeHash = window.location.hash;
}
/**
* Hides the table of contents sidebar when the window shrinks below a certain size. This
* prevents the sidebar from occluding the article content.
*/
function setTocVisibilityOnResize() {
// Always show TOC on laptop and larger screens, collapsible only on small/medium
publicationColumnVisibility.update(v => ({ ...v, toc: window.innerWidth >= tocBreakpoint }));
}
/**
* Hides the table of contents sidebar when the user clicks outside of it.
*/
function hideTocOnClick(ev: MouseEvent) {
const target = ev.target as HTMLElement;
if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) {
return;
}
// Only allow hiding TOC on screens smaller than tocBreakpoint
if (window.innerWidth < tocBreakpoint && $publicationColumnVisibility.toc) {
publicationColumnVisibility.update(v => ({ ...v, toc: false}));
}
}
onMount(() => {
// Always check whether the TOC sidebar should be visible.
setTocVisibilityOnResize();
window.addEventListener("hashchange", updateActiveHash);
window.addEventListener("hashchange", scrollToElementWithOffset);
// Also handle the case where the user lands on the page with a hash in the URL
scrollToElementWithOffset();
window.addEventListener("resize", setTocVisibilityOnResize);
window.addEventListener("click", hideTocOnClick);
return () => {
window.removeEventListener("hashchange", updateActiveHash);
window.removeEventListener("hashchange", scrollToElementWithOffset);
window.removeEventListener("resize", setTocVisibilityOnResize);
window.removeEventListener("click", hideTocOnClick);
};
});
</script>
<!-- TODO: Get TOC from parser. -->
{#if $publicationColumnVisibility.toc}
<Sidebar class='sidebar-leather left-0'>
<SidebarWrapper>
<SidebarGroup class='sidebar-group-leather'>
<Heading tag="h1" class="h-leather !text-lg">Table of contents</Heading>
<p>(This ToC is only for demo purposes, and is not fully-functional.)</p>
{#each tocItems as item}
<SidebarItem
class="sidebar-item-leather {activeHash === item.hash ? 'bg-primary-200 font-bold' : ''}"
label={item.label}
href={item.hash}
/>
{/each}
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if}

19
src/lib/components/util/ZapOutline.svelte

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
<script>
export let size = 24; // default size
export let className = '';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
viewBox="0 0 24 24"
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
</svg>

12
src/lib/consts.ts

@ -1,8 +1,18 @@ @@ -1,8 +1,18 @@
export const wikiKind = 30818;
export const indexKind = 30040;
export const zettelKinds = [ 30041, 30818 ];
export const communityRelay = [ 'wss://theforest.nostr1.com' ];
export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://theforest.nostr1.com' ];
export const bootstrapRelays = [ 'wss://purplepag.es', 'wss://relay.noswhere.com' ];
export const fallbackRelays = [
'wss://purplepag.es',
'wss://indexer.coracle.social',
'wss://relay.noswhere.com',
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://relay.lumina.rocks',
'wss://nostr.wine',
'wss://nostr.land'
];
export enum FeedType {
StandardRelays = 'standard',

7
src/lib/navigator/EventNetwork/NodeTooltip.svelte

@ -7,6 +7,7 @@ @@ -7,6 +7,7 @@
<script lang="ts">
import type { NetworkNode } from "./types";
import { onMount } from "svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils';
// Component props
let { node, selected = false, x, y, onclose } = $props<{
@ -30,7 +31,7 @@ @@ -30,7 +31,7 @@
*/
function getAuthorTag(node: NetworkNode): string {
if (node.event) {
const authorTags = node.event.getMatchingTags("author");
const authorTags = getMatchingTags(node.event, "author");
if (authorTags.length > 0) {
return authorTags[0][1];
}
@ -43,7 +44,7 @@ @@ -43,7 +44,7 @@
*/
function getSummaryTag(node: NetworkNode): string | null {
if (node.event) {
const summaryTags = node.event.getMatchingTags("summary");
const summaryTags = getMatchingTags(node.event, "summary");
if (summaryTags.length > 0) {
return summaryTags[0][1];
}
@ -56,7 +57,7 @@ @@ -56,7 +57,7 @@
*/
function getDTag(node: NetworkNode): string {
if (node.event) {
const dTags = node.event.getMatchingTags("d");
const dTags = getMatchingTags(node.event, "d");
if (dTags.length > 0) {
return dTags[0][1];
}

8
src/lib/navigator/EventNetwork/utils/networkBuilder.ts

@ -9,6 +9,7 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk"; @@ -9,6 +9,7 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types";
import { nip19 } from "nostr-tools";
import { standardRelays } from "$lib/consts";
import { getMatchingTags } from '$lib/utils/nostrUtils';
// Configuration
const DEBUG = false; // Set to true to enable debug logging
@ -158,7 +159,7 @@ export function initializeGraphState(events: NDKEvent[]): GraphState { @@ -158,7 +159,7 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
// Build set of referenced event IDs to identify root events
const referencedIds = new Set<string>();
events.forEach((event) => {
const aTags = event.getMatchingTags("a");
const aTags = getMatchingTags(event, "a");
debug("Processing a-tags for event", {
eventId: event.id,
aTagCount: aTags.length
@ -279,8 +280,7 @@ export function processIndexEvent( @@ -279,8 +280,7 @@ export function processIndexEvent(
if (level >= maxLevel) return;
// Extract the sequence of nodes referenced by this index
const sequence = indexEvent
.getMatchingTags("a")
const sequence = getMatchingTags(indexEvent, "a")
.map((tag) => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null)
.map((id) => state.nodeMap.get(id))
@ -321,7 +321,7 @@ export function generateGraph( @@ -321,7 +321,7 @@ export function generateGraph(
rootIndices.forEach((rootIndex) => {
debug("Processing root index", {
rootId: rootIndex.id,
aTags: rootIndex.getMatchingTags("a").length
aTags: getMatchingTags(rootIndex, "a").length
});
processIndexEvent(rootIndex, 0, state, maxLevel);
});

6
src/lib/ndk.ts

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import NDK, { NDKNip07Signer, NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser } from '@nostr-dev-kit/ndk';
import { get, writable, type Writable } from 'svelte/store';
import { bootstrapRelays, FeedType, loginStorageKey, standardRelays } from './consts';
import { fallbackRelays, FeedType, loginStorageKey, standardRelays } from './consts';
import { feedType } from './stores';
export const ndkInstance: Writable<NDK> = writable();
@ -199,7 +199,7 @@ export function logout(user: NDKUser): void { @@ -199,7 +199,7 @@ export function logout(user: NDKUser): void {
async function getUserPreferredRelays(
ndk: NDK,
user: NDKUser,
bootstraps: readonly string[] = bootstrapRelays
fallbacks: readonly string[] = fallbackRelays
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent(
{
@ -211,7 +211,7 @@ async function getUserPreferredRelays( @@ -211,7 +211,7 @@ async function getUserPreferredRelays(
skipVerification: false,
skipValidation: false,
},
NDKRelaySet.fromRelayUrls(bootstraps, ndk),
NDKRelaySet.fromRelayUrls(fallbacks, ndk),
);
const inboxRelays = new Set<NDKRelay>();

87
src/lib/parser.ts

@ -13,6 +13,7 @@ import type { @@ -13,6 +13,7 @@ import type {
import he from 'he';
import { writable, type Writable } from 'svelte/store';
import { zettelKinds } from './consts.ts';
import { getMatchingTags } from '$lib/utils/nostrUtils';
interface IndexMetadata {
authors?: string[];
@ -123,6 +124,11 @@ export default class Pharos { @@ -123,6 +124,11 @@ export default class Pharos {
*/
private eventsByLevelMap: Map<number, string[]> = new Map<number, string[]>();
/**
* A map of blog entries
*/
private blogEntries: Map<string, NDKEvent> = new Map<string, NDKEvent>();
/**
* When `true`, `getEvents()` should regenerate the event tree to propagate updates.
*/
@ -147,6 +153,10 @@ export default class Pharos { @@ -147,6 +153,10 @@ export default class Pharos {
}
parse(content: string, options?: ProcessorOptions | undefined): void {
// Ensure the content is valid AsciiDoc and has a header and the doctype book
content = ensureAsciiDocHeader(content);
try {
this.html = this.asciidoctor.convert(content, {
'extension_registry': this.pharosExtensions,
@ -180,6 +190,14 @@ export default class Pharos { @@ -180,6 +190,14 @@ export default class Pharos {
this.parse(content);
}
getBlogEntries() {
return this.blogEntries;
}
getIndexMetadata(): IndexMetadata {
return this.rootIndexMetadata;
}
/**
* Generates and stores Nostr events from the parsed AsciiDoc document. The events can be
* modified via the parser's API and retrieved via the `getEvents()` method.
@ -611,7 +629,7 @@ export default class Pharos { @@ -611,7 +629,7 @@ export default class Pharos {
let content: string = '';
// Format title into AsciiDoc header.
const title = event.getMatchingTags('title')[0][1];
const title = getMatchingTags(event, 'title')[0][1];
let titleLevel = '';
for (let i = 0; i <= depth; i++) {
titleLevel += '=';
@ -619,9 +637,9 @@ export default class Pharos { @@ -619,9 +637,9 @@ export default class Pharos {
content += `${titleLevel} ${title}\n\n`;
// TODO: Deprecate `e` tags in favor of `a` tags required by NIP-62.
let tags = event.getMatchingTags('a');
let tags = getMatchingTags(event, 'a');
if (tags.length === 0) {
tags = event.getMatchingTags('e');
tags = getMatchingTags(event, 'e');
}
// Base case: The event is a zettel.
@ -635,6 +653,23 @@ export default class Pharos { @@ -635,6 +653,23 @@ export default class Pharos {
tags.map(tag => this.ndk.fetchEventFromTag(tag, event))
);
// if a blog, save complete events for later
if (getMatchingTags(event, 'type').length > 0 && getMatchingTags(event, 'type')[0][1] === 'blog') {
childEvents.forEach(child => {
if (child) {
this.blogEntries.set(getMatchingTags(child, 'd')?.[0]?.[1], child);
}
})
}
// populate metadata
if (event.created_at) {
this.rootIndexMetadata.publicationDate = new Date(event.created_at * 1000).toDateString();
}
if (getMatchingTags(event, 'image').length > 0) {
this.rootIndexMetadata.coverImage = getMatchingTags(event, 'image')[0][1];
}
// Michael J - 15 December 2024 - This could be further parallelized by recursively fetching
// children of index events before processing them for content. We won't make that change now,
// as it would increase complexity, but if performance suffers, we can revisit this option.
@ -1084,3 +1119,49 @@ export default class Pharos { @@ -1084,3 +1119,49 @@ export default class Pharos {
}
export const pharosInstance: Writable<Pharos> = writable();
export const tocUpdate = writable(0);
// Whenever you update the publication tree, call:
tocUpdate.update(n => n + 1);
function ensureAsciiDocHeader(content: string): string {
const lines = content.split(/\r?\n/);
let headerIndex = -1;
let hasDoctype = false;
// Find the first non-empty line as header
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '') continue;
if (lines[i].trim().startsWith('=')) {
headerIndex = i;
console.debug('[Pharos] AsciiDoc document header:', lines[i].trim());
break;
} else {
throw new Error('AsciiDoc document is missing a header at the top.');
}
}
if (headerIndex === -1) {
throw new Error('AsciiDoc document is missing a header.');
}
// Check for doctype in the next non-empty line after header
let nextLine = headerIndex + 1;
while (nextLine < lines.length && lines[nextLine].trim() === '') {
nextLine++;
}
if (nextLine < lines.length && lines[nextLine].trim().startsWith(':doctype:')) {
hasDoctype = true;
}
// Insert doctype immediately after header if not present
if (!hasDoctype) {
lines.splice(headerIndex + 1, 0, ':doctype: book');
}
// Log the state of the lines before returning
console.debug('[Pharos] AsciiDoc lines after header/doctype normalization:', lines.slice(0, 5));
return lines.join('\n');
}

19
src/lib/snippets/UserSnippets.svelte

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
<script module lang='ts'>
import { createProfileLink, createProfileLinkWithVerification, toNpub } from '$lib/utils/nostrUtils';
export { userBadge };
</script>
{#snippet userBadge(identifier: string, displayText: string | undefined)}
{#if toNpub(identifier)}
{#await createProfileLinkWithVerification(toNpub(identifier) as string, displayText)}
{@html createProfileLink(toNpub(identifier) as string, displayText)}
{:then html}
{@html html}
{:catch}
{@html createProfileLink(toNpub(identifier) as string, displayText)}
{/await}
{:else}
{displayText ?? ''}
{/if}
{/snippet}

23
src/lib/stores.ts

@ -6,3 +6,26 @@ export let idList = writable<string[]>([]); @@ -6,3 +6,26 @@ export let idList = writable<string[]>([]);
export let alexandriaKinds = readable<number[]>([30040, 30041, 30818]);
export let feedType = writable<FeedType>(FeedType.StandardRelays);
const defaultVisibility = {
toc: false,
blog: true,
main: true,
inner: false,
discussion: false,
editing: false
};
function createVisibilityStore() {
const { subscribe, set, update } = writable({ ...defaultVisibility });
return {
subscribe,
set,
update,
reset: () => set({ ...defaultVisibility })
};
}
export const publicationColumnVisibility = createVisibilityStore();

4
src/lib/stores/relayStore.ts

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
// Initialize with empty array, will be populated from user preferences
export const userRelays = writable<string[]>([]);

41
src/lib/utils.ts

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import { getMatchingTags } from "./utils/nostrUtils";
export function neventEncode(event: NDKEvent, relays: string[]) {
return nip19.neventEncode({
@ -11,7 +12,7 @@ export function neventEncode(event: NDKEvent, relays: string[]) { @@ -11,7 +12,7 @@ export function neventEncode(event: NDKEvent, relays: string[]) {
}
export function naddrEncode(event: NDKEvent, relays: string[]) {
const dTag = event.getMatchingTags('d')[0]?.[1];
const dTag = getMatchingTags(event, 'd')[0]?.[1];
if (!dTag) {
throw new Error('Event does not have a d tag');
}
@ -24,6 +25,10 @@ export function naddrEncode(event: NDKEvent, relays: string[]) { @@ -24,6 +25,10 @@ export function naddrEncode(event: NDKEvent, relays: string[]) {
});
}
export function nprofileEncode(pubkey: string, relays: string[]) {
return nip19.nprofileEncode({ pubkey, relays });
}
export function formatDate(unixtimestamp: number) {
const months = [
"Jan",
@ -109,11 +114,11 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> { @@ -109,11 +114,11 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
// Index events have no content, and they must have `title`, `d`, and `e` tags.
if (
(event.content != null && event.content.length > 0)
|| event.getMatchingTags('title').length === 0
|| event.getMatchingTags('d').length === 0
|| getMatchingTags(event, 'title').length === 0
|| getMatchingTags(event, 'd').length === 0
|| (
event.getMatchingTags('a').length === 0
&& event.getMatchingTags('e').length === 0
getMatchingTags(event, 'a').length === 0
&& getMatchingTags(event, 'e').length === 0
)
) {
events.delete(event);
@ -158,3 +163,29 @@ Array.prototype.findIndexAsync = function<T>( @@ -158,3 +163,29 @@ Array.prototype.findIndexAsync = function<T>(
): Promise<number> {
return findIndexAsync(this, predicate);
};
/**
* Creates a debounced function that delays invoking func until after wait milliseconds have elapsed
* since the last time the debounced function was invoked.
* @param func The function to debounce
* @param wait The number of milliseconds to delay
* @returns A debounced version of the function
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | undefined;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = undefined;
func(...args);
};
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}

2
src/lib/utils/markup/MarkupInfo.md

@ -30,7 +30,7 @@ The **advanced markup parser** includes all features of the basic parser, plus: @@ -30,7 +30,7 @@ The **advanced markup parser** includes all features of the basic parser, plus:
- **Tables:** Pipe-delimited tables with or without headers
- **Footnotes:** `[^1]` or `[^Smith]`, which should appear where the footnote shall be placed, and will be displayed as unique, consecutive numbers
- **Footnote References:** `[^1]: footnote text` or `[^Smith]: Smith, Adam. 1984 "The Wiggle Mysteries`, which will be listed in order, at the bottom of the event, with back-reference links to the footnote, and text footnote labels appended
- **Wikilinks:** `[[NIP-54]]` will render as a hyperlink and goes to [NIP-54](https://next-alexandria.gitcitadel.eu/publication?d=nip-54) (Will later go to our new disambiguation page.)
- **Wikilinks:** `[[NIP-54]]` will render as a hyperlink and goes to [NIP-54](./wiki?d=nip-54)
## Publications and Wikis

2
src/lib/utils/markup/basicMarkupParser.ts

@ -142,7 +142,7 @@ function replaceWikilinks(text: string): string { @@ -142,7 +142,7 @@ function replaceWikilinks(text: string): string {
return text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `./publication?d=${normalized}`;
const url = `./wiki?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
});

2
src/lib/utils/mime.ts

@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
* - Addressable: 30000-39999 (latest per d-tag stored)
* - Regular: all other kinds (stored by relays)
*/
function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' {
export function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' {
// Check special ranges first
if (kind >= 30000 && kind < 40000) {
return 'addressable';

318
src/lib/utils/nostrUtils.ts

@ -2,11 +2,33 @@ import { get } from 'svelte/store'; @@ -2,11 +2,33 @@ import { get } from 'svelte/store';
import { nip19 } from 'nostr-tools';
import { ndkInstance } from '$lib/ndk';
import { npubCache } from './npubCache';
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { standardRelays, fallbackRelays } from "$lib/consts";
import { NDKRelaySet as NDKRelaySetFromNDK } from '@nostr-dev-kit/ndk';
import { sha256 } from '@noble/hashes/sha256';
import { schnorr } from '@noble/curves/secp256k1';
import { bytesToHex } from '@noble/hashes/utils';
const badgeCheckSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>'
const graduationCapSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M12.4472 4.10557c-.2815-.14076-.6129-.14076-.8944 0L2.76981 8.49706l9.21949 4.39024L21 8.38195l-8.5528-4.27638Z"/><path d="M5 17.2222v-5.448l6.5701 3.1286c.278.1325.6016.1293.8771-.0084L19 11.618v5.6042c0 .2857-.1229.5583-.3364.7481l-.0025.0022-.0041.0036-.0103.009-.0119.0101-.0181.0152c-.024.02-.0562.0462-.0965.0776-.0807.0627-.1942.1465-.3405.2441-.2926.195-.7171.4455-1.2736.6928C15.7905 19.5208 14.1527 20 12 20c-2.15265 0-3.79045-.4792-4.90614-.9751-.5565-.2473-.98098-.4978-1.27356-.6928-.14631-.0976-.2598-.1814-.34049-.2441-.04036-.0314-.07254-.0576-.09656-.0776-.01201-.01-.02198-.0185-.02991-.0253l-.01038-.009-.00404-.0036-.00174-.0015-.0008-.0007s-.00004 0 .00978-.0112l-.00009-.0012-.01043.0117C5.12215 17.7799 5 17.5079 5 17.2222Zm-3-6.8765 2 .9523V17c0 .5523-.44772 1-1 1s-1-.4477-1-1v-6.6543Z"/></svg>';
// Regular expressions for Nostr identifiers - match the entire identifier including any prefix
export const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g;
export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
export interface NostrProfile {
name?: string;
displayName?: string;
nip05?: string;
picture?: string;
about?: string;
banner?: string;
website?: string;
lud16?: string;
}
/**
* HTML escape a string
*/
@ -24,7 +46,7 @@ function escapeHtml(text: string): string { @@ -24,7 +46,7 @@ function escapeHtml(text: string): string {
/**
* Get user metadata for a nostr identifier (npub or nprofile)
*/
export async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string}> {
export async function getUserMetadata(identifier: string): Promise<NostrProfile> {
// Remove nostr: prefix if present
const cleanId = identifier.replace(/^nostr:/, '');
@ -58,30 +80,22 @@ export async function getUserMetadata(identifier: string): Promise<{name?: strin @@ -58,30 +80,22 @@ export async function getUserMetadata(identifier: string): Promise<{name?: strin
return fallback;
}
const user = ndk.getUser({ pubkey: pubkey });
if (!user) {
npubCache.set(cleanId, fallback);
return fallback;
}
try {
const profile = await user.fetchProfile();
if (!profile) {
npubCache.set(cleanId, fallback);
return fallback;
}
const profileEvent = await fetchEventWithFallback(ndk, { kinds: [0], authors: [pubkey] });
const profile = profileEvent && profileEvent.content ? JSON.parse(profileEvent.content) : null;
const metadata = {
name: profile.name || fallback.name,
displayName: profile.displayName
};
npubCache.set(cleanId, metadata);
return metadata;
} catch (e) {
npubCache.set(cleanId, fallback);
return fallback;
}
const metadata: NostrProfile = {
name: profile?.name || fallback.name,
displayName: profile?.displayName,
nip05: profile?.nip05,
picture: profile?.image,
about: profile?.about,
banner: profile?.banner,
website: profile?.website,
lud16: profile?.lud16
};
npubCache.set(cleanId, metadata);
return metadata;
} catch (e) {
npubCache.set(cleanId, fallback);
return fallback;
@ -91,15 +105,73 @@ export async function getUserMetadata(identifier: string): Promise<{name?: strin @@ -91,15 +105,73 @@ export async function getUserMetadata(identifier: string): Promise<{name?: strin
/**
* Create a profile link element
*/
function createProfileLink(identifier: string, displayText: string | undefined): string {
export function createProfileLink(identifier: string, displayText: string | undefined): string {
const cleanId = identifier.replace(/^nostr:/, '');
const escapedId = escapeHtml(cleanId);
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText);
return `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline" target="_blank">@${escapedText}</a>`;
return `<a href="./events?id=${escapedId}" class="npub-badge" target="_blank">@${escapedText}</a>`;
}
/**
* Create a profile link element with a NIP-05 verification indicator.
*/
export async function createProfileLinkWithVerification(identifier: string, displayText: string | undefined): Promise<string> {
const ndk = get(ndkInstance) as NDK;
if (!ndk) {
return createProfileLink(identifier, displayText);
}
const cleanId = identifier.replace(/^nostr:/, '');
const escapedId = escapeHtml(cleanId);
const isNpub = cleanId.startsWith('npub');
let user: NDKUser;
if (isNpub) {
user = ndk.getUser({ npub: cleanId });
} else {
user = ndk.getUser({ pubkey: cleanId });
}
const userRelays = Array.from(ndk.pool?.relays.values() || []).map(r => r.url);
const allRelays = [
...standardRelays,
...userRelays,
...fallbackRelays
].filter((url, idx, arr) => arr.indexOf(url) === idx);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [user.pubkey] },
undefined,
relaySet
);
const profile = profileEvent?.content ? JSON.parse(profileEvent.content) : null;
const nip05 = profile?.nip05;
if (!nip05) {
return createProfileLink(identifier, displayText);
}
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText);
const displayIdentifier = profile?.displayName ?? profile?.name ?? escapedText;
const isVerified = await user.validateNip05(nip05);
if (!isVerified) {
return createProfileLink(identifier, displayText);
}
// TODO: Make this work with an enum in case we add more types.
const type = nip05.endsWith('edu') ? 'edu' : 'standard';
switch (type) {
case 'edu':
return `<span class="npub-badge"><a href="./events?id=${escapedId}" target="_blank">@${displayIdentifier}</a>${graduationCapSvg}</span>`;
case 'standard':
return `<span class="npub-badge"><a href="./events?id=${escapedId}" target="_blank">@${displayIdentifier}</a>${badgeCheckSvg}</span>`;
}
}
/**
* Create a note link element
*/
@ -109,7 +181,7 @@ function createNoteLink(identifier: string): string { @@ -109,7 +181,7 @@ function createNoteLink(identifier: string): string {
const escapedId = escapeHtml(cleanId);
const escapedText = escapeHtml(shortId);
return `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`;
return `<a href="./events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`;
}
/**
@ -179,4 +251,196 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> { @@ -179,4 +251,196 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
console.error('Error getting npub from nip05:', error);
return null;
}
}
/**
* Generic utility function to add a timeout to any promise
* Can be used in two ways:
* 1. Method style: promise.withTimeout(5000)
* 2. Function style: withTimeout(promise, 5000)
*
* @param thisOrPromise Either the promise to timeout (function style) or the 'this' context (method style)
* @param timeoutMsOrPromise Timeout duration in milliseconds (function style) or the promise (method style)
* @returns The promise result if completed before timeout, otherwise throws an error
* @throws Error with message 'Timeout' if the promise doesn't resolve within timeoutMs
*/
export function withTimeout<T>(
thisOrPromise: Promise<T> | number,
timeoutMsOrPromise?: number | Promise<T>
): Promise<T> {
// Handle method-style call (promise.withTimeout(5000))
if (typeof thisOrPromise === 'number') {
const timeoutMs = thisOrPromise;
const promise = timeoutMsOrPromise as Promise<T>;
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
)
]);
}
// Handle function-style call (withTimeout(promise, 5000))
const promise = thisOrPromise;
const timeoutMs = timeoutMsOrPromise as number;
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
)
]);
}
// Add the method to Promise prototype
declare global {
interface Promise<T> {
withTimeout(timeoutMs: number): Promise<T>;
}
}
Promise.prototype.withTimeout = function<T>(this: Promise<T>, timeoutMs: number): Promise<T> {
return withTimeout(timeoutMs, this);
};
/**
* Fetches an event using a two-step relay strategy:
* 1. First tries standard relays with timeout
* 2. Falls back to all relays if not found
* Always wraps result as NDKEvent
*/
export async function fetchEventWithFallback(
ndk: NDK,
filterOrId: string | NDKFilter<NDKKind>,
timeoutMs: number = 3000
): Promise<NDKEvent | null> {
// Get user relays if logged in
const userRelays = ndk.activeUser ?
Array.from(ndk.pool?.relays.values() || [])
.filter(r => r.status === 1) // Only use connected relays
.map(r => r.url) :
[];
// Create three relay sets in priority order
const relaySets = [
NDKRelaySetFromNDK.fromRelayUrls(standardRelays, ndk), // 1. Standard relays
NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk) // 3. fallback relays (last resort)
];
try {
let found: NDKEvent | null = null;
const triedRelaySets: string[] = [];
// Helper function to try fetching from a relay set
async function tryFetchFromRelaySet(relaySet: NDKRelaySetFromNDK, setName: string): Promise<NDKEvent | null> {
if (relaySet.relays.size === 0) return null;
triedRelaySets.push(setName);
if (typeof filterOrId === 'string' && /^[0-9a-f]{64}$/i.test(filterOrId)) {
return await ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySet).withTimeout(timeoutMs);
} else {
const filter = typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId;
const results = await ndk.fetchEvents(filter, undefined, relaySet).withTimeout(timeoutMs);
return results instanceof Set ? Array.from(results)[0] as NDKEvent : null;
}
}
// Try each relay set in order
for (const [index, relaySet] of relaySets.entries()) {
const setName = index === 0 ? 'standard relays' :
index === 1 ? 'user relays' :
'fallback relays';
found = await tryFetchFromRelaySet(relaySet, setName);
if (found) break;
}
if (!found) {
const timeoutSeconds = timeoutMs / 1000;
const relayUrls = relaySets.map((set, i) => {
const setName = i === 0 ? 'standard relays' :
i === 1 ? 'user relays' :
'fallback relays';
const urls = Array.from(set.relays).map(r => r.url);
return urls.length > 0 ? `${setName} (${urls.join(', ')})` : null;
}).filter(Boolean).join(', then ');
console.warn(`Event not found after ${timeoutSeconds}s timeout. Tried ${relayUrls}. Some relays may be offline or slow.`);
return null;
}
// Always wrap as NDKEvent
return found instanceof NDKEvent ? found : new NDKEvent(ndk, found);
} catch (err) {
console.error('Error in fetchEventWithFallback:', err);
return null;
}
}
/**
* Converts a hex pubkey to npub, or returns npub if already encoded.
*/
export function toNpub(pubkey: string | undefined): string | null {
if (!pubkey) return null;
try {
if (/^[a-f0-9]{64}$/i.test(pubkey)) {
return nip19.npubEncode(pubkey);
}
if (pubkey.startsWith('npub1')) return pubkey;
return null;
} catch {
return null;
}
}
export type { NDKEvent, NDKRelaySet, NDKUser };
export { NDKRelaySetFromNDK };
export { nip19 };
export function createRelaySetFromUrls(relayUrls: string[], ndk: NDK) {
return NDKRelaySetFromNDK.fromRelayUrls(relayUrls, ndk);
}
export function createNDKEvent(ndk: NDK, rawEvent: any) {
return new NDKEvent(ndk, rawEvent);
}
/**
* Returns all tags from the event that match the given tag name.
* @param event The NDKEvent object.
* @param tagName The tag name to match (e.g., 'a', 'd', 'title').
* @returns An array of matching tags.
*/
export function getMatchingTags(event: NDKEvent, tagName: string): string[][] {
return event.tags.filter((tag: string[]) => tag[0] === tagName);
}
export function getEventHash(event: {
kind: number;
created_at: number;
tags: string[][];
content: string;
pubkey: string;
}): string {
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
return bytesToHex(sha256(serialized));
}
export async function signEvent(event: {
kind: number;
created_at: number;
tags: string[][];
content: string;
pubkey: string;
}): Promise<string> {
const id = getEventHash(event);
const sig = await schnorr.sign(id, event.pubkey);
return bytesToHex(sig);
}

4
src/lib/utils/npubCache.ts

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
export type NpubMetadata = { name?: string; displayName?: string };
import type { NostrProfile } from './nostrUtils';
export type NpubMetadata = NostrProfile;
class NpubCache {
private cache: Record<string, NpubMetadata> = {};

16
src/routes/+layout.svelte

@ -6,9 +6,6 @@ @@ -6,9 +6,6 @@
import { Alert } from "flowbite-svelte";
import { HammerSolid } from "flowbite-svelte-icons";
// Compute viewport height.
$: displayHeight = window.innerHeight;
// Get standard metadata for OpenGraph tags
let title = 'Library of Alexandria';
let currentUrl = $page.url.href;
@ -18,7 +15,8 @@ @@ -18,7 +15,8 @@
let summary = 'Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.';
onMount(() => {
document.body.style.height = `${displayHeight}px`;
const rect = document.body.getBoundingClientRect();
// document.body.style.height = `${rect.height}px`;
});
</script>
@ -42,13 +40,7 @@ @@ -42,13 +40,7 @@
<meta name="twitter:image" content="{image}" />
</svelte:head>
<div class={'leather min-h-full w-full flex flex-col items-center'}>
<Navigation class='sticky top-0' />
<Alert rounded={false} class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left'>
<HammerSolid class='mr-2 h-5 w-5 text-primary-500 dark:text-primary-500' />
<span class='font-medium'>
Pardon our dust! The publication view is currently using an experimental loader, and may be unstable.
</span>
</Alert>
<div class={'leather mt-[76px] h-full w-full flex flex-col items-center'}>
<Navigation class='fixed top-0' />
<slot />
</div>

38
src/routes/+page.svelte

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<script lang='ts'>
import { FeedType, feedTypeStorageKey, standardRelays } from '$lib/consts';
import { Button, Dropdown, Radio } from 'flowbite-svelte';
import { ChevronDownOutline } from 'flowbite-svelte-icons';
import { FeedType, feedTypeStorageKey, standardRelays, fallbackRelays } from '$lib/consts';
import { Alert, Button, Dropdown, Radio, Input } from "flowbite-svelte";
import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons";
import { inboxRelays, ndkSignedIn } from '$lib/ndk';
import PublicationFeed from '$lib/components/PublicationFeed.svelte';
import { feedType } from '$lib/stores';
@ -20,17 +20,35 @@ @@ -20,17 +20,35 @@
return '';
}
};
let searchQuery = $state('');
</script>
<Alert rounded={false} id="alert-experimental" class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2'>
<HammerSolid class='mr-2 h-5 w-5 text-primary-500 dark:text-primary-500' />
<span class='font-medium'>
Pardon our dust! The publication view is currently using an experimental loader, and may be unstable.
</span>
</Alert>
<main class='leather flex flex-col flex-grow-0 space-y-4 p-4'>
{#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} />
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else}
<div class='leather w-full flex justify-end'>
<Button>
{`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`}<ChevronDownOutline class='w-6 h-6' />
<div class='leather w-full flex flex-row items-center justify-center gap-4 mb-4'>
<Button id="feed-toggle-btn" class="min-w-[220px] max-w-sm">
{`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`}
<ChevronDownOutline class='w-6 h-6' />
</Button>
<Dropdown class='w-fit p-2 space-y-2 text-sm'>
<Input
bind:value={searchQuery}
placeholder="Search publications by title or author..."
class="flex-grow max-w-2xl min-w-[300px] text-base"
/>
<Dropdown
class='w-fit p-2 space-y-2 text-sm'
triggeredBy="#feed-toggle-btn"
>
<li>
<Radio name='relays' bind:group={$feedType} value={FeedType.StandardRelays}>Alexandria's Relays</Radio>
</li>
@ -40,9 +58,9 @@ @@ -40,9 +58,9 @@
</Dropdown>
</div>
{#if $feedType === FeedType.StandardRelays}
<PublicationFeed relays={standardRelays} />
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else if $feedType === FeedType.UserRelays}
<PublicationFeed relays={$inboxRelays} />
<PublicationFeed relays={$inboxRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{/if}
{/if}
</main>

1
src/routes/[...catchall]/+page.svelte

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button, P } from 'flowbite-svelte';
</script>

181
src/routes/about/+page.svelte

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
<script lang="ts">
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { Heading, Img, P, A } from "flowbite-svelte";
// Get the git tag version from environment variables
@ -19,23 +20,22 @@ @@ -19,23 +20,22 @@
>
{/if}
</div>
<Img src="/screenshots/old_books.jpg" alt="Alexandria icon" />
<Img src="./screenshots/old_books.jpg" alt="Alexandria icon" />
<P class="mb-3">
Alexandria is a reader and writer for <A
href="/publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1"
href="./publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1"
>curated publications</A
> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form
articles (markup). It is produced by the <A
href="/publication?d=gitcitadel-project-documentation-gitcitadel-project-1-by-stella-v-1"
href="./publication?d=gitcitadel-project-documentation-by-stella-v-1"
>GitCitadel project team</A
>.
</P>
<P class="mb-3">
Please submit support issues on the <A
href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues"
target="_blank">Alexandria repo page</A
Please submit support issues on the <A href="/contact"
>Alexandria contact page</A
> and follow us on <A
href="https://github.com/ShadowySupercode/gitcitadel"
target="_blank">GitHub</A
@ -45,170 +45,11 @@ @@ -45,170 +45,11 @@
</P>
<P>
We are easiest to contact over our Nostr address <A
href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg"
title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz"
target="_blank">npub1s3h…75wz</A
>.
</P>
<Heading tag="h2" class="h-leather mt-4 mb-2">Overview</Heading>
<P class="mb-4">
Alexandria opens up to the <A href="./">landing page</A>, where the user
can: login (top-right), select whether to only view the publications
hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank"
>thecitadel document relay</A
> or add in their own relays, and scroll/search the publications.
</P>
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/LandingPage.png"
alt="Landing page"
class="image-border rounded-lg"
width="400"
/>
<Img
src="/screenshots/YourRelays.png"
alt="Relay selection"
class="image-border rounded-lg"
width="400"
/>
</div>
<P class="mb-3">
There is also the ability to view the publications as a diagram, if you
click on "Visualize", and to publish an e-book or other document (coming
soon).
</P>
<P class="mb-3">
If you click on a card, which represents a 30040 index event, the
associated reading view opens to the publication. The app then pulls all
of the content events (30041s and 30818s for wiki pages), in the order in
which they are indexed, and displays them as a single document.
</P>
<P class="mb-3">
Each content section (30041 or 30818) is also a level in the table of
contents, which can be accessed from the floating icon top-left in the
reading view. This allows for navigation within the publication. (This
functionality has been temporarily disabled.)
</P>
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/ToC_icon.png"
alt="ToC icon"
class="image-border rounded-lg"
width="400"
/>
<Img
src="/screenshots/TableOfContents.png"
alt="Table of contents example"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h2" class="h-leather mt-4 mb-2">Typical use cases</Heading>
<Heading tag="h3" class="h-leather mb-3">For e-books</Heading>
<P class="mb-3">
The most common use for Alexandria is for e-books: both those users have
written themselves and those uploaded to Nostr from other sources. The
first minor version of the app, Gutenberg, is focused on displaying and
producing these publications.
</P>
<P class="mb-3">
An example of a book is <A
href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition"
>Jane Eyre</A
>
</P>
<div class="flex justify-center my-4">
<Img
src="/screenshots/JaneEyre.png"
alt="Jane Eyre, by Charlotte Brontë"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h3" class="h-leather mb-3">For scientific papers</Heading>
<P class="mb-3">
Alexandria will also display research papers with Asciimath and LaTeX
embedding, and the normal advanced formatting options available for
Asciidoc. In addition, we will be implementing special citation events,
which will serve as an alternative or addition to the normal footnotes.
</P>
<P class="mb-3">
Correctly displaying such papers, integrating citations, and allowing them
to be reviewed (with kind 1111 comments), and annotated (with highlights)
by users, is the focus of the second minor version, Euler.
</P>
<P class="mb-3">
Euler will also pioneer the HTTP-based (rather than websocket-based)
e-paper compatible version of the web app.
</P>
<P class="mb-3">
An example of a research paper is <A
href="/publication?d=less-partnering-less-children-or-both-by-julia-hellstrand-v-1"
>Less Partnering, Less Children, or Both?</A
>
</P>
<div class="flex justify-center my-4">
<Img
src="/screenshots/ResearchPaper.png"
alt="Research paper"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h3" class="h-leather mb-3">For documentation</Heading>
<P class="mb-3">
Our own team uses Alexandria to document the app, to display our <A
href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A
>, as well as to store copies of our most interesting <A
href="/publication?d=gitcitadel-project-documentation-by-stella-v-1"
>technical specifications</A
>.
</P>
<div class="flex justify-center my-4">
<Img
src="/screenshots/Documentation.png"
alt="Documentation"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h3" class="h-leather mb-3">For wiki pages</Heading>
<P class="mb-3">
Alexandria now supports wiki pages (kind 30818), allowing for
collaborative knowledge bases and documentation. Wiki pages, such as this
one about the <A href="/publication?d=sybil">Sybil utility</A> use the same
Asciidoc format as other publications but are specifically designed for interconnected,
evolving content.
</P>
<P class="mb-3">
Wiki pages can be linked to from other publications and can contain links
to other wiki pages, creating a web of knowledge that can be navigated and
explored.
We are easiest to contact over our Nostr address {@render userBadge("npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz", "GitCitadel")}. Or, you can visit us on our <A
href="https://gitcitadel.com"
title="GitCitadel Homepage"
target="_blank">homepage</A
> and find out more about us, and the many projects we are working on.
</P>
</main>
</div>

11
src/routes/contact/+page.svelte

@ -9,6 +9,7 @@ @@ -9,6 +9,7 @@
import { parseAdvancedmarkup } from '$lib/utils/markup/advancedMarkupParser';
import { nip19 } from 'nostr-tools';
import { getMimeTags } from '$lib/utils/mime';
import { userBadge } from '$lib/snippets/UserSnippets.svelte';
// Function to close the success message
function closeSuccessMessage() {
@ -280,7 +281,7 @@ @@ -280,7 +281,7 @@
</P>
<P class="mb-3">
You can contact us on Nostr <A href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">GitCitadel</A> or you can view submitted issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page.</A>
You can contact us on Nostr {@render userBadge("npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz", "GitCitadel")} or you can view submitted issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page.</A>
</P>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Submit an issue</Heading>
@ -289,7 +290,7 @@ @@ -289,7 +290,7 @@
If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page.
</P>
<form class="space-y-4" on:submit={handleSubmit} autocomplete="off">
<form class="space-y-4" onsubmit={handleSubmit} autocomplete="off">
<div>
<Label for="subject" class="mb-2">Subject</Label>
<Input id="subject" class="w-full bg-white dark:bg-gray-800" placeholder="Issue subject" bind:value={subject} required autofocus />
@ -305,7 +306,7 @@ @@ -305,7 +306,7 @@
<button
type="button"
class="inline-block p-4 rounded-t-lg {activeTab === 'write' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}"
on:click={() => activeTab = 'write'}
onclick={() => activeTab = 'write'}
role="tab"
>
Write
@ -315,7 +316,7 @@ @@ -315,7 +316,7 @@
<button
type="button"
class="inline-block p-4 rounded-t-lg {activeTab === 'preview' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}"
on:click={() => activeTab = 'preview'}
onclick={() => activeTab = 'preview'}
role="tab"
>
Preview
@ -416,7 +417,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -416,7 +417,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
<!-- Close button -->
<button
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-100"
on:click={closeSuccessMessage}
onclick={closeSuccessMessage}
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">

79
src/routes/events/+page.svelte

@ -0,0 +1,79 @@ @@ -0,0 +1,79 @@
<script lang="ts">
import { Heading, P } from "flowbite-svelte";
import { onMount } from "svelte";
import { page } from "$app/stores";
import type { NDKEvent } from '$lib/utils/nostrUtils';
import EventSearch from '$lib/components/EventSearch.svelte';
import EventDetails from '$lib/components/EventDetails.svelte';
import RelayActions from '$lib/components/RelayActions.svelte';
import CommentBox from '$lib/components/CommentBox.svelte';
let loading = $state(false);
let error = $state<string | null>(null);
let searchValue = $state<string | null>(null);
let event = $state<NDKEvent | null>(null);
let profile = $state<{
name?: string;
display_name?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
lud16?: string;
nip05?: string;
} | null>(null);
let userPubkey = $state<string | null>(null);
let userRelayPreference = $state(false);
function handleEventFound(newEvent: NDKEvent) {
event = newEvent;
if (newEvent.kind === 0) {
try {
profile = JSON.parse(newEvent.content);
} catch {
profile = null;
}
} else {
profile = null;
}
}
onMount(async () => {
const id = $page.url.searchParams.get('id');
if (id) {
searchValue = id;
}
// Get user's pubkey and relay preference from localStorage
userPubkey = localStorage.getItem('userPubkey');
userRelayPreference = localStorage.getItem('useUserRelays') === 'true';
});
</script>
<div class="w-full flex justify-center">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4">
<div class="flex justify-between items-center">
<Heading tag="h1" class="h-leather mb-2">Events</Heading>
</div>
<P class="mb-3">
Use this page to view any event (npub, nprofile, nevent, naddr, note, pubkey, or eventID).
</P>
<EventSearch {loading} {error} {searchValue} {event} onEventFound={handleEventFound} />
{#if event}
<EventDetails {event} {profile} {searchValue} />
<RelayActions {event} />
{#if userPubkey}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">Add Comment</Heading>
<CommentBox event={event} userPubkey={userPubkey} userRelayPreference={userRelayPreference} />
</div>
{:else}
<div class="mt-8 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<P>Please sign in to add comments.</P>
</div>
{/if}
{/if}
</main>
</div>

3
src/routes/new/compose/+page.svelte

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
let treeNeedsUpdate: boolean = false;
let treeUpdateCount: number = 0;
let someIndexValue = 0;
$: {
if (treeNeedsUpdate) {
@ -17,7 +18,7 @@ @@ -17,7 +18,7 @@
<main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'>
<Heading tag='h1' class='h-leather mb-2'>Compose</Heading>
{#key treeUpdateCount}
<Preview rootId={$pharosInstance.getRootIndexId()} allowEditing={true} bind:needsUpdate={treeNeedsUpdate} />
<Preview rootId={$pharosInstance.getRootIndexId()} allowEditing={true} bind:needsUpdate={treeNeedsUpdate} index={someIndexValue} />
{/key}
</main>
</div>

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

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import Pharos, { pharosInstance } from "$lib/parser";
import { ndkInstance } from "$lib/ndk";
import { goto } from "$app/navigation";
let someIndexValue = 0;
// TODO: Prompt user to sign in before editing.
@ -80,7 +81,7 @@ @@ -80,7 +81,7 @@
</ToolbarButton>
</Toolbar>
{#if rootIndexId}
<Preview sectionClass='m-2' rootId={rootIndexId} />
<Preview sectionClass='m-2' rootId={rootIndexId} index={someIndexValue} />
{/if}
</form>
{/if}

63
src/routes/publication/+page.svelte

@ -5,22 +5,33 @@ @@ -5,22 +5,33 @@
import { onDestroy, setContext } from "svelte";
import { PublicationTree } from "$lib/data_structures/publication_tree";
import Processor from "asciidoctor";
import ArticleNav from "$components/util/ArticleNav.svelte";
let { data }: PageProps = $props();
const publicationTree = new PublicationTree(data.indexEvent, data.ndk);
setContext('publicationTree', publicationTree);
setContext('asciidoctor', Processor());
setContext("publicationTree", publicationTree);
setContext("asciidoctor", Processor());
// Get publication metadata for OpenGraph tags
let title = $derived(data.indexEvent?.getMatchingTags('title')[0]?.[1] || data.parser?.getIndexTitle(data.parser?.getRootIndexId()) || 'Alexandria Publication');
let currentUrl = data.url?.href ?? '';
let title = $derived(
data.indexEvent?.getMatchingTags("title")[0]?.[1] ||
data.parser?.getIndexTitle(data.parser?.getRootIndexId()) ||
"Alexandria Publication",
);
let currentUrl = data.url?.href ?? "";
// Get image and summary from the event tags if available
// If image unavailable, use the Alexandria default pic.
let image = $derived(data.indexEvent?.getMatchingTags('image')[0]?.[1] || '/screenshots/old_books.jpg');
let summary = $derived(data.indexEvent?.getMatchingTags('summary')[0]?.[1] || 'Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.');
let image = $derived(
data.indexEvent?.getMatchingTags("image")[0]?.[1] ||
"/screenshots/old_books.jpg",
);
let summary = $derived(
data.indexEvent?.getMatchingTags("summary")[0]?.[1] ||
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.",
);
onDestroy(() => data.parser.reset());
</script>
@ -28,30 +39,38 @@ @@ -28,30 +39,38 @@
<svelte:head>
<!-- Basic meta tags -->
<title>{title}</title>
<meta name="description" content="{summary}" />
<meta name="description" content={summary} />
<!-- OpenGraph meta tags -->
<meta property="og:title" content="{title}" />
<meta property="og:description" content="{summary}" />
<meta property="og:url" content="{currentUrl}" />
<meta property="og:title" content={title} />
<meta property="og:description" content={summary} />
<meta property="og:url" content={currentUrl} />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Alexandria" />
<meta property="og:image" content="{image}" />
<meta property="og:image" content={image} />
<!-- Twitter Card meta tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{title}" />
<meta name="twitter:description" content="{summary}" />
<meta name="twitter:image" content="{image}" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={summary} />
<meta name="twitter:image" content={image} />
</svelte:head>
<main>
{#key data}
<ArticleNav
publicationType={data.publicationType}
rootId={data.parser.getRootIndexId()}
indexEvent={data.indexEvent}
/>
{/key}
<main class="publication {data.publicationType}">
{#await data.waitable}
<TextPlaceholder divClass='skeleton-leather w-full' size="xxl" />
<TextPlaceholder divClass="skeleton-leather w-full" size="xxl" />
{:then}
<Publication
rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType}
<Publication
rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType}
indexEvent={data.indexEvent}
/>
{/await}

3
src/routes/publication/+page.ts

@ -3,6 +3,7 @@ import type { Load } from '@sveltejs/kit'; @@ -3,6 +3,7 @@ import type { Load } from '@sveltejs/kit';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { getActiveRelays } from '$lib/ndk';
import { getMatchingTags } from '$lib/utils/nostrUtils';
/**
* Decodes an naddr identifier and returns a filter object
@ -96,7 +97,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom @@ -96,7 +97,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom
? await fetchEventById(ndk, id)
: await fetchEventByDTag(ndk, dTag!);
const publicationType = indexEvent?.getMatchingTags('type')[0]?.[1];
const publicationType = getMatchingTags(indexEvent, 'type')[0]?.[1];
const fetchPromise = parser.fetch(indexEvent);
return {

177
src/routes/start/+page.svelte

@ -0,0 +1,177 @@ @@ -0,0 +1,177 @@
<script lang="ts">
import { Heading, Img, P, A } from "flowbite-svelte";
// Get the git tag version from environment variables
const appVersion = import.meta.env.APP_VERSION || "development";
const isVersionKnown = appVersion !== "development";
</script>
<div class="w-full flex justify-center">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4">
<Heading tag="h1" class="h-leather mb-2"
>Getting Started with Alexandria</Heading
>
<Heading tag="h2" class="h-leather mt-4 mb-2">Overview</Heading>
<P class="mb-4">
Alexandria opens up to the <A href="./">landing page</A>, where the user
can: login (top-right), select whether to only view the publications
hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank"
>thecitadel document relay</A
> or add in their own relays, and scroll/search the publications.
</P>
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/LandingPage.png"
alt="Landing page"
class="image-border rounded-lg"
width="400"
/>
<Img
src="/screenshots/YourRelays.png"
alt="Relay selection"
class="image-border rounded-lg"
width="400"
/>
</div>
<P class="mb-3">
There is also the ability to view the publications as a diagram, if you
click on "Visualize", and to publish an e-book or other document (coming
soon).
</P>
<P class="mb-3">
If you click on a card, which represents a 30040 index event, the
associated reading view opens to the publication. The app then pulls all
of the content events (30041s and 30818s for wiki pages), in the order in
which they are indexed, and displays them as a single document.
</P>
<P class="mb-3">
Each content section (30041 or 30818) is also a level in the table of
contents, which can be accessed from the floating icon top-left in the
reading view. This allows for navigation within the publication.
Publications of type "blog" have a ToC which emphasizes that each entry
is a blog post.
(This functionality has been temporarily disabled, but the TOC is visible.)
</P>
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/ToC_normal.png"
alt="ToC basic"
class="image-border rounded-lg"
width="400"
/>
<Img
src="/screenshots/ToC_blog.png"
alt="ToC blog"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h2" class="h-leather mt-4 mb-2">Typical use cases</Heading>
<Heading tag="h3" class="h-leather mb-3">For e-books</Heading>
<P class="mb-3">
The most common use for Alexandria is for e-books: both those the users
have written themselves and those uploaded to Nostr from other sources.
The first minor version of the app, Gutenberg, is focused on displaying
and producing these publications.
</P>
<P class="mb-3">
An example of a book is <A
href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition"
>Jane Eyre</A
>
</P>
<div class="flex justify-center my-4">
<Img
src="/screenshots/JaneEyre.png"
alt="Jane Eyre, by Charlotte Brontë"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h3" class="h-leather mb-3">For scientific papers</Heading>
<P class="mb-3">
Alexandria will also display research papers with Asciimath and LaTeX
embedding, and the normal advanced formatting options available for
Asciidoc. In addition, we will be implementing special citation events,
which will serve as an alternative or addition to the normal footnotes.
</P>
<P class="mb-3">
Correctly displaying such papers, integrating citations, and allowing them
to be reviewed (with kind 1111 comments), and annotated (with highlights)
by users, is the focus of the second minor version, Euler.
</P>
<P class="mb-3">
Euler will also pioneer the HTTP-based (rather than websocket-based)
e-paper compatible version of the web app.
</P>
<P class="mb-3">
An example of a research paper is <A
href="/publication?d=less-partnering-less-children-or-both-by-julia-hellstrand-v-1"
>Less Partnering, Less Children, or Both?</A
>
</P>
<div class="flex justify-center my-4">
<Img
src="/screenshots/ResearchPaper.png"
alt="Research paper"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h3" class="h-leather mb-3">For documentation</Heading>
<P class="mb-3">
Our own team uses Alexandria to document the app, to display our <A
href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A
>, as well as to store copies of our most interesting <A
href="/publication?d=gitcitadel-project-documentation-by-stella-v-1"
>technical specifications</A
>.
</P>
<div class="flex justify-center my-4">
<Img
src="/screenshots/Documentation.png"
alt="Documentation"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h3" class="h-leather mb-3">For wiki pages</Heading>
<P class="mb-3">
Alexandria now supports wiki pages (kind 30818), allowing for
collaborative knowledge bases and documentation. Wiki pages, such as this
one about the <A href="/publication?d=sybil">Sybil utility</A> use the same
Asciidoc format as other publications but are specifically designed for interconnected,
evolving content.
</P>
<P class="mb-3">
Wiki pages can be linked to from other publications and can contain links
to other wiki pages, creating a web of knowledge that can be navigated and
explored.
</P>
</main>
</div>

3
src/routes/visualize/+page.svelte

@ -11,9 +11,6 @@ @@ -11,9 +11,6 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { filterValidIndexEvents } from "$lib/utils";
import { networkFetchLimit } from "$lib/state";
import { CogSolid } from "flowbite-svelte-icons";
import { Button } from "flowbite-svelte";
import Settings from "$lib/navigator/EventNetwork/Settings.svelte";
// Configuration
const DEBUG = false; // Set to true to enable debug logging

8
src/styles/base.css

@ -1,3 +1,9 @@ @@ -1,3 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind utilities;
@layer components {
body {
@apply bg-primary-0 dark:bg-primary-1000;
}
}

5
src/styles/events.css

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
@layer components {
canvas.qr-code {
@apply block mx-auto my-4;
}
}

60
src/styles/publications.css

@ -1,55 +1,55 @@ @@ -1,55 +1,55 @@
@layer components {
/* AsciiDoc content */
.note-leather p a {
.publication-leather p a {
@apply underline hover:text-primary-500 dark:hover:text-primary-400;
}
.note-leather section p {
.publication-leather section p {
@apply w-full;
}
.note-leather section p table {
.publication-leather section p table {
@apply w-full table-fixed space-x-2 space-y-2;
}
.note-leather section p table td {
.publication-leather section p table td {
@apply p-2;
}
.note-leather section p table td .content:has(> .imageblock) {
.publication-leather section p table td .content:has(> .imageblock) {
@apply flex flex-col items-center;
}
.note-leather .imageblock {
.publication-leather .imageblock {
@apply flex flex-col space-y-2;
}
.note-leather .imageblock .content {
.publication-leather .imageblock .content {
@apply flex justify-center;
}
.note-leather .imageblock .title {
.publication-leather .imageblock .title {
@apply text-center;
}
.note-leather .imageblock.left .content {
.publication-leather .imageblock.left .content {
@apply justify-start;
}
.note-leather .imageblock.left .title {
.publication-leather .imageblock.left .title {
@apply text-left;
}
.note-leather .imageblock.right .content {
.publication-leather .imageblock.right .content {
@apply justify-end;
}
.note-leather .imageblock.right .title {
.publication-leather .imageblock.right .title {
@apply text-right;
}
.note-leather section p table td .literalblock {
.publication-leather section p table td .literalblock {
@apply my-2 p-2 border rounded border-gray-400 dark:border-gray-600;
}
.note-leather .literalblock pre {
.publication-leather .literalblock pre {
@apply p-3 text-wrap break-words;
}
@ -58,7 +58,7 @@ @@ -58,7 +58,7 @@
}
/* lists */
.note-leather .ulist ul {
.publication-leather .ulist ul {
@apply space-y-1 list-disc list-inside;
}
@ -104,7 +104,7 @@ @@ -104,7 +104,7 @@
}
.publication-leather .verseblock pre.content {
@apply text-base font-sans;
@apply text-base font-sans overflow-x-scroll py-1;
}
.publication-leather .attribution {
@ -234,6 +234,34 @@ @@ -234,6 +234,34 @@
@apply w-full;
}
.coverImage {
@apply max-h-[230px] overflow-hidden;
}
.coverImage.depth-0 {
@apply max-h-[460px] overflow-hidden;
}
.coverImage img {
@apply object-contain w-full;
}
.coverImage.depth-0 img {
@apply m-auto w-auto;
}
/** blog */
@screen lg {
@media (hover: hover) {
.blog .discreet .card-leather:not(:hover) {
@apply bg-primary-50 dark:bg-primary-1000 opacity-75 transition duration-500 ease-in-out ;
}
.blog .discreet .group {
@apply bg-transparent;
}
}
}
/* Discrete headers */
h3.discrete,
h4.discrete,

20
src/styles/scrollbar.css

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
@layer components {
/* Global scrollbar styles */
* {
scrollbar-color: rgba(87, 66, 41, 0.8) transparent; /* Transparent track, default scrollbar thumb */
}
/* Webkit Browsers (Chrome, Safari, Edge) */
*::-webkit-scrollbar {
width: 12px; /* Thin scrollbar */
}
*::-webkit-scrollbar-track {
background: transparent; /* Fully transparent track */
}
*::-webkit-scrollbar-thumb {
@apply bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-800;;
border-radius: 6px; /* Rounded scrollbar */
}
}

BIN
static/screenshots/ToC_blog.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

BIN
static/screenshots/ToC_normal.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

18
tailwind.config.cjs

@ -22,11 +22,11 @@ const config = { @@ -22,11 +22,11 @@ const config = {
400: '#ad8351',
500: '#c6a885',
600: '#795c39',
700: '#574229',
800: '#342718',
900: '#231a10',
950: '#17110A',
1000: '#110d08',
700: '#564a3e',
800: '#3c352c',
900: '#2a241c',
950: '#1d1812',
1000: '#15110d',
},
success: {
50: '#e3f2e7',
@ -80,6 +80,14 @@ const config = { @@ -80,6 +80,14 @@ const config = {
listStyleType: {
'upper-alpha': 'upper-alpha', // Uppercase letters
'lower-alpha': 'lower-alpha', // Lowercase letters
},
flexGrow: {
'1': '1',
'2': '2',
'3': '3',
},
hueRotate: {
20: '20deg',
}
},
},

2
test_data/AsciidocFiles/21lessons.adoc

@ -1372,7 +1372,7 @@ Thanks to the countless authors and content producers who influenced my thinking @@ -1372,7 +1372,7 @@ Thanks to the countless authors and content producers who influenced my thinking
Last but not least, thanks to all the bitcoin maximalists, shitcoin minimalists, shills, bots, and shitposters which reside in the beautiful garden that is Bitcoin twitter. And finally, thank _you_ for reading this. I hope you enjoyed it as much as I did enjoy writing it.
Feel free to reach out to me https://twitter.com/dergigi[on X] or https://njump.me/npub1dergggklka99wwrs92yz8wdjs952h2ux2ha2ed598ngwu9w7a6fsh9xzpc[on Nostr] nostr:npub1dergggklka99wwrs92yz8wdjs952h2ux2ha2ed598ngwu9w7a6fsh9xzpc. My DMs are open.
Feel free to reach out to me https://twitter.com/dergigi[on X] or https://next-alexandria.gitcitadel.eu/events?id=npub1dergggklka99wwrs92yz8wdjs952h2ux2ha2ed598ngwu9w7a6fsh9xzpc[on Nostr] nostr:npub1dergggklka99wwrs92yz8wdjs952h2ux2ha2ed598ngwu9w7a6fsh9xzpc. My DMs are open.
== Thank You

2
test_data/AsciidocFiles/Rauhnaechte.adoc

@ -157,7 +157,7 @@ Namesake: *Silvester* @@ -157,7 +157,7 @@ Namesake: *Silvester*
The seventh Rauhnacht, associated with the month of July and the namesake _Silvester_, carries the theme of _Preparation for What’s to Come._
It represents the gateway — the transition from a past phase into a new one. Since the introduction of the Gregorian calendar, this transition has been celebrated by many on December 31st, a day dedicated to the Roman bishop Silvester. His death commemorates the end of Christian persecution and the establishment of Christianity as a state religion. (For spiritual insights on Christ consciousness, I recommend https://njump.me/naddr1qvzqqqr4gupzp35mw8w9vn7ux59vmhle98e96usz4s28pjr53psgh4ke3epxhfmrqyvhwumn8ghj7un9d3shjtnndehhyapwwdhkx6tpdshszythwden5te0dehhxarj9emkjmn99uqp2d3ctaynzefcvex5vamxvf04sunzfu6nqdgtaz38t[this article I recently shared]).
It represents the gateway — the transition from a past phase into a new one. Since the introduction of the Gregorian calendar, this transition has been celebrated by many on December 31st, a day dedicated to the Roman bishop Silvester. His death commemorates the end of Christian persecution and the establishment of Christianity as a state religion. (For spiritual insights on Christ consciousness, I recommend https://next-alexandria.gitcitadel.eu/events?id=naddr1qvzqqqr4gupzp35mw8w9vn7ux59vmhle98e96usz4s28pjr53psgh4ke3epxhfmrqyvhwumn8ghj7un9d3shjtnndehhyapwwdhkx6tpdshszythwden5te0dehhxarj9emkjmn99uqp2d3ctaynzefcvex5vamxvf04sunzfu6nqdgtaz38t[this article I recently shared]).
Every transition holds the opportunity to change, reshape, and perceive life with fresh eyes. Use this day to prepare for the new year:

8
tests/integration/markupIntegration.test.ts

@ -33,8 +33,8 @@ describe('Markup Integration Test', () => { @@ -33,8 +33,8 @@ describe('Markup Integration Test', () => {
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/);
// Hashtags
expect(output).toContain('text-primary-600');
// Nostr identifiers (should be njump.me links)
expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z');
// Nostr identifiers (should be Alexandria links)
expect(output).toContain('./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z');
// Wikilinks
expect(output).toContain('wikilink');
// YouTube iframe
@ -76,8 +76,8 @@ describe('Markup Integration Test', () => { @@ -76,8 +76,8 @@ describe('Markup Integration Test', () => {
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/);
// Hashtags
expect(output).toContain('text-primary-600');
// Nostr identifiers (should be njump.me links)
expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z');
// Nostr identifiers (should be Alexandria links)
expect(output).toContain('./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z');
// Wikilinks
expect(output).toContain('wikilink');
// YouTube iframe

8
tests/integration/markupTestfile.md

@ -111,16 +111,16 @@ https://upload.wikimedia.org/wikipedia/commons/f/f1/Heart_coraz%C3%B3n.svg @@ -111,16 +111,16 @@ https://upload.wikimedia.org/wikipedia/commons/f/f1/Heart_coraz%C3%B3n.svg
This is an implementation of [Nostr-flavored markup](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes.
You can even turn Alexandria URLs into embedded events, if they have hexids or bech32 addresses:
http://localhost:4173/publication?id=nevent1qqstjcyerjx4laxlxc70cwzuxf3u9kkzuhdhgtu8pwrzvh7k5d5zdngpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3qm3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srq0ylvuw
https://next-alexandria.gitcitadel.eu/events?id=nevent1qqstjcyerjx4laxlxc70cwzuxf3u9kkzuhdhgtu8pwrzvh7k5d5zdngpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3qm3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srq0ylvuw
But not if they have d-tags:
http://next-alexandria.gitcitadel.eu/publication?d=relay-test-thecitadel-by-unknown-v-1
https://next-alexandria.gitcitadel.eu/publication?d=relay-test-thecitadel-by-unknown-v-1
And within a markup tag: [markup link title](http://alexandria.gitcitadel.com/publication?id=84ad65f7a321404f55d97c2208dd3686c41724e6c347d3ee53cfe16f67cdfb7c).
And within a markup tag: [markup link title](https://next-alexandria.gitcitadel.com/publication?id=84ad65f7a321404f55d97c2208dd3686c41724e6c347d3ee53cfe16f67cdfb7c).
And to localhost: http://localhost:4173/publication?id=c36b54991e459221f444612d88ea94ef5bb4a1b93863ef89b1328996746f6d25
http://localhost:4173/profile?id=nprofile1qqs99d9qw67th0wr5xh05de4s9k0wjvnkxudkgptq8yg83vtulad30gxyk5sf
https://next-alexandria.gitcitadel.eu/events?id=nprofile1qqs99d9qw67th0wr5xh05de4s9k0wjvnkxudkgptq8yg83vtulad30gxyk5sf
You can even include code inline, like `<div class="leather min-h-full w-full flex flex-col items-center">` or

2
tests/unit/advancedMarkupParser.test.ts

@ -69,7 +69,7 @@ describe('Advanced Markup Parser', () => { @@ -69,7 +69,7 @@ describe('Advanced Markup Parser', () => {
it('parses nostr identifiers', async () => {
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq';
const output = await parseAdvancedmarkup(input);
expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq');
expect(output).toContain('./events?id=npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq');
});
it('parses emoji shortcodes', async () => {

2
tests/unit/basicMarkupParser.test.ts

@ -70,7 +70,7 @@ describe('Basic Markup Parser', () => { @@ -70,7 +70,7 @@ describe('Basic Markup Parser', () => {
it('parses nostr identifiers', async () => {
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq';
const output = await parseBasicmarkup(input);
expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq');
expect(output).toContain('./events?id=npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq');
});
it('parses emoji shortcodes', async () => {

5
vite.config.ts

@ -26,6 +26,11 @@ export default defineConfig({ @@ -26,6 +26,11 @@ export default defineConfig({
$components: './src/components'
}
},
build: {
rollupOptions: {
external: ['bech32']
}
},
test: {
include: ['./tests/unit/**/*.test.ts', './tests/integration/**/*.test.ts']
},

Loading…
Cancel
Save