Browse Source

close issue#166 update master to newest version

master
Silberengel 1 year ago
parent
commit
5c9bd4bc72
  1. BIN
      images/article_page.png
  2. BIN
      images/homepage.png
  3. 489
      package-lock.json
  4. 2
      package.json
  5. 98
      src/app.css
  6. 1
      src/app.d.ts
  7. 0
      src/lib/Card.svelte
  8. 118
      src/lib/cards/Article.svelte
  9. 130
      src/lib/cards/Editor.svelte
  10. 113
      src/lib/cards/Search.svelte
  11. 149
      src/lib/cards/Settings.svelte
  12. 88
      src/lib/cards/UserArticles.svelte
  13. 85
      src/lib/cards/Welcome.svelte
  14. 20
      src/lib/components/Article.svelte
  15. 87
      src/lib/components/ArticleHeader.svelte
  16. 52
      src/lib/components/EventLimitControl.svelte
  17. 588
      src/lib/components/EventNetwork.svelte
  18. 52
      src/lib/components/EventRenderLevelLimit.svelte
  19. 114
      src/lib/components/Login.svelte
  20. 9
      src/lib/components/Navigation.svelte
  21. 70
      src/lib/components/Note.svelte
  22. 202
      src/lib/components/Preview.svelte
  23. 115
      src/lib/components/PublicationFeed.svelte
  24. 38
      src/lib/components/Searchbar.svelte
  25. 10
      src/lib/consts.ts
  26. 111
      src/lib/defaultShareButton.svelte
  27. 57
      src/lib/navigator/EventNetwork/Legend.svelte
  28. 44
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  29. 335
      src/lib/navigator/EventNetwork/index.svelte
  30. 35
      src/lib/navigator/EventNetwork/types.ts
  31. 136
      src/lib/navigator/EventNetwork/utils/forceSimulation.ts
  32. 195
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  33. 252
      src/lib/ndk.ts
  34. 3
      src/lib/parser.ts
  35. 14
      src/lib/state.ts
  36. 2
      src/lib/stores.ts
  37. 5
      src/lib/utils.ts
  38. 36
      src/routes/+layout.ts
  39. 169
      src/routes/+page.svelte
  40. 6
      src/routes/about/+page.svelte
  41. 6
      src/routes/new/edit/+page.svelte
  42. 4
      src/routes/publication/+page.svelte
  43. 26
      src/routes/publication/+page.ts
  44. 74
      src/routes/visualize/+page.svelte

BIN
images/article_page.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

BIN
images/homepage.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

489
package-lock.json generated

@ -8,12 +8,13 @@ @@ -8,12 +8,13 @@
"name": "alexandria",
"version": "0.0.6",
"dependencies": {
"@nostr-dev-kit/ndk": "2.10.x",
"@nostr-dev-kit/ndk": "2.11.x",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.x",
"@popperjs/core": "2.11.x",
"@tailwindcss/forms": "0.5.x",
"@tailwindcss/typography": "0.5.x",
"asciidoctor": "3.0.x",
"d3": "^7.9.0",
"he": "1.2.x",
"nostr-tools": "2.10.x"
},
@ -1022,9 +1023,9 @@ @@ -1022,9 +1023,9 @@
}
},
"node_modules/@nostr-dev-kit/ndk": {
"version": "2.10.7",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.10.7.tgz",
"integrity": "sha512-cylva8jsaAGMijxAI32CnJWlzvwD4sWyl86/+RMS6xpZn4MIgeVUfBFc/pYkcfZzDP3v1Z9mIPsuiICRyvu9yQ==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.11.0.tgz",
"integrity": "sha512-FKIMtcVsVcquzrC+yir9lOXHCIHmQ3IKEVCMohqEB7N96HjP2qrI9s5utbjI3lkavFNF5tXg1Gp9ODEo7XCfLA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^1.6.0",
@ -1056,6 +1057,28 @@ @@ -1056,6 +1057,28 @@
"typescript-lru-cache": "^2.0.0"
}
},
"node_modules/@nostr-dev-kit/ndk-cache-dexie/node_modules/@nostr-dev-kit/ndk": {
"version": "2.10.7",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.10.7.tgz",
"integrity": "sha512-cylva8jsaAGMijxAI32CnJWlzvwD4sWyl86/+RMS6xpZn4MIgeVUfBFc/pYkcfZzDP3v1Z9mIPsuiICRyvu9yQ==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0",
"@noble/secp256k1": "^2.1.0",
"@scure/base": "^1.1.9",
"debug": "^4.3.6",
"light-bolt11-decoder": "^3.2.0",
"nostr-tools": "^2.7.1",
"tseep": "^1.2.2",
"typescript-lru-cache": "^2.0.0",
"utf8-buffer": "^1.0.0",
"websocket-polyfill": "^0.0.3"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -2009,6 +2032,416 @@ @@ -2009,6 +2032,416 @@
"node": ">=0.12"
}
},
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@ -2044,6 +2477,15 @@ @@ -2044,6 +2477,15 @@
"node": ">=0.10.0"
}
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/devalue": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz",
@ -3094,6 +3536,18 @@ @@ -3094,6 +3536,18 @@
"he": "bin/he"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -3162,6 +3616,15 @@ @@ -3162,6 +3616,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -4507,6 +4970,12 @@ @@ -4507,6 +4970,12 @@
"node": ">=0.10.0"
}
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.1.tgz",
@ -4569,6 +5038,12 @@ @@ -4569,6 +5038,12 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@ -4582,6 +5057,12 @@ @@ -4582,6 +5057,12 @@
"node": ">=6"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",

2
package.json

@ -13,7 +13,7 @@ @@ -13,7 +13,7 @@
"format": "prettier --plugin-search-dir . --write ."
},
"dependencies": {
"@nostr-dev-kit/ndk": "2.10.x",
"@nostr-dev-kit/ndk": "2.11.x",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.x",
"@popperjs/core": "2.11.x",
"@tailwindcss/forms": "0.5.x",

98
src/app.css

@ -24,6 +24,10 @@ @@ -24,6 +24,10 @@
@apply w-4 h-4;
}
div[role='tooltip'] button.btn-leather {
@apply hover:text-primary-400 dark:hover:text-primary-500 hover:border-primary-400 dark:hover:border-primary-500 hover:bg-gray-200 dark:hover:bg-gray-700;
}
/* Card */
div.card-leather {
@apply bg-primary-0 dark:bg-primary-1000 border-gray-200 has-[:hover]:border-primary-800 dark:border-gray-800 dark:has-[:hover]:border-primary-500;
@ -56,15 +60,98 @@ @@ -56,15 +60,98 @@
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
}
/* AsciiDoc content */
.note-leather p a {
@apply underline hover:text-primary-500 dark:hover:text-primary-400;
}
.note-leather section p {
@apply w-full;
}
.note-leather section p table {
@apply w-full table-fixed space-x-2 space-y-2;
}
.note-leather section p table td {
@apply p-2;
}
.note-leather section p table td .content:has(> .imageblock) {
@apply flex flex-col items-center;
}
.note-leather .imageblock {
@apply flex flex-col space-y-2;
}
.note-leather .imageblock .content {
@apply flex justify-center;
}
.note-leather .imageblock .title {
@apply text-center;
}
.note-leather .imageblock.left .content {
@apply justify-start;
}
.note-leather .imageblock.left .title {
@apply text-left;
}
.note-leather .imageblock.right .content {
@apply justify-end;
}
.note-leather .imageblock.right .title {
@apply text-right;
}
.note-leather section p table td .literalblock {
@apply my-2 p-2 border rounded border-gray-400 dark:border-gray-600;
}
.note-leather .literalblock pre {
@apply text-wrap break-words;
}
.note-leather .listingblock pre {
@apply overflow-x-auto;
}
/* Heading */
h1.h-leather,
h2.h-leather,
h3.h-leather,
h4.h-leather,
h5.h-leather {
h5.h-leather,
h6.h-leather {
@apply text-gray-800 dark:text-gray-300;
}
h1.h-leather {
@apply text-4xl font-bold;
}
h2.h-leather {
@apply text-3xl font-bold;
}
h3.h-leather {
@apply text-2xl font-bold;
}
h4.h-leather {
@apply text-xl font-bold;
}
h5.h-leather {
@apply text-lg font-semibold;
}
h6.h-leather {
@apply text-base font-semibold;
}
/* Modal */
div.modal-leather > div {
@apply bg-primary-0 dark:bg-primary-1000 border-b-[1px] border-gray-800 dark:border-gray-500;
@ -110,6 +197,11 @@ @@ -110,6 +197,11 @@
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
}
/* Skeleton */
div.skeleton-leather div {
@apply bg-gray-400 dark:bg-gray-600;
}
/* Textarea */
div.textarea-leather {
@apply bg-gray-200 dark:bg-gray-800 border-gray-400 dark:border-gray-600;
@ -134,6 +226,10 @@ @@ -134,6 +226,10 @@
@apply text-gray-800 dark:text-gray-300;
}
div[role='tooltip'] button.btn-leather .tooltip-leather {
@apply bg-gray-200 dark:bg-gray-700;
}
/* Unordered list */
.ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;

1
src/app.d.ts vendored

@ -8,6 +8,7 @@ declare global { @@ -8,6 +8,7 @@ declare global {
ndk?: NDK;
parser?: Pharos;
waitable?: Promise<any>;
publicationType?: string;
}
// interface Platform {}
}

0
src/lib/Card.svelte

118
src/lib/cards/Article.svelte

@ -1,118 +0,0 @@ @@ -1,118 +0,0 @@
<script lang="ts">
import { ndk } from '$lib/ndk';
import { afterUpdate, onMount } from 'svelte';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { formatDate, next } from '$lib/utils';
import { parse } from '$lib/articleParser.js';
import type { Tab } from '$lib/types';
import { page } from '$app/stores';
import { tabBehaviour, userPublickey } from '$lib/state';
export let eventid: string;
export let createChild: (tab: Tab) => void;
export let replaceSelf: (tab: Tab) => void;
let event: NDKEvent | null = null;
let copied = false;
function addClickListenerToWikilinks() {
const elements = document.querySelectorAll('[id^="wikilink-v0-"]');
elements.forEach((element) => {
element.addEventListener('click', () => {
let a = element.id.slice(12);
if ($tabBehaviour == 'replace') {
replaceSelf({ id: next(), type: 'find', data: a });
} else {
createChild({ id: next(), type: 'find', data: a });
}
});
});
}
function shareCopy() {
navigator.clipboard.writeText(`https://${$page.url.hostname}/article/${eventid}`);
copied = true;
setTimeout(() => {
copied = false;
}, 2500);
}
onMount(async () => {
event = await $ndk.fetchEvent(eventid);
});
afterUpdate(() => {
addClickListenerToWikilinks();
});
</script>
<div>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-missing-attribute -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<article class="prose font-sans mx-auto p-2 lg:max-w-4xl">
{#if event !== null}
<h1 class="mb-0">
{#if event?.tags.find((e) => e[0] == 'title')?.[0] && event?.tags.find((e) => e[0] == 'title')?.[1]}
{event.tags.find((e) => e[0] == 'title')?.[1]}
{:else}
{event.tags.find((e) => e[0] == 'd')?.[1]}
{/if}
</h1>
<span>
{#await event.author?.fetchProfile()}
by <a
class="cursor-pointer"
on:click={() => {
$tabBehaviour == 'replace'
? replaceSelf({ type: 'user', id: next(), data: event?.author.hexpubkey() })
: createChild({ type: 'user', id: next(), data: event?.author.hexpubkey() });
}}>...</a
>,
{:then profile}
by <a
class="cursor-pointer"
on:click={() => {
$tabBehaviour == 'replace'
? replaceSelf({ type: 'user', id: next(), data: event?.author.hexpubkey() })
: createChild({ type: 'user', id: next(), data: event?.author.hexpubkey() });
}}>{profile !== null && JSON.parse(Array.from(profile)[0]?.content)?.name}</a
>,
{/await}
{#if event.created_at}
updated on {formatDate(event.created_at)}
{/if}
&nbsp;&nbsp;<a
class="cursor-pointer"
on:click={() => {
$tabBehaviour == 'child'
? createChild({ id: next(), type: 'editor', data: { forkId: event?.id } })
: replaceSelf({ id: next(), type: 'editor', data: { forkId: event?.id } });
}}
>{#if $userPublickey == event.author.hexpubkey()}Edit{:else}Fork{/if}</a
>
&nbsp;&nbsp;<a class="cursor-pointer" on:click={shareCopy}
>{#if copied}Copied!{:else}Share{/if}</a
>&nbsp;&nbsp;&nbsp;<a
class="cursor-pointer"
on:click={() => {
$tabBehaviour == 'child'
? createChild({
id: next(),
type: 'find',
data: event?.tags.find((e) => e[0] == 'd')?.[1]
})
: replaceSelf({
id: next(),
type: 'find',
data: event?.tags.find((e) => e[0] == 'd')?.[1]
});
}}>Versions</a
>
</span>
<!-- Content -->
{@html parse(event?.content)}
{/if}
</article>
</div>

130
src/lib/cards/Editor.svelte

@ -1,130 +0,0 @@ @@ -1,130 +0,0 @@
<script lang="ts">
import { ndk } from '$lib/ndk';
import { wikiKind } from '$lib/consts';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { onMount } from 'svelte';
import type { Tab } from '$lib/types';
import { userPublickey } from '$lib/state';
export let replaceSelf: (tab: Tab) => void;
export let data: any;
if (!data.title) data.title = '';
if (!data.summary) data.summary = '';
if (!data.content) data.content = '';
let forkev: NDKEvent | null;
let success = 0;
let error: string = '';
onMount(async () => {
if (data.forkId) {
forkev = await $ndk.fetchEvent(data.forkId);
data.title =
forkev?.tags.find((e) => e[0] == 'title')?.[0] &&
forkev?.tags.find((e) => e[0] == 'title')?.[1]
? forkev.tags.find((e) => e[0] == 'title')?.[1]
: forkev?.tags.find((e) => e[0] == 'd')?.[1];
data.summary =
forkev?.tags.find((e) => e[0] == 'summary')?.[0] &&
forkev?.tags.find((e) => e[0] == 'summary')?.[1]
? forkev?.tags.find((e) => e[0] == 'summary')?.[1]
: undefined;
data.content = forkev?.content;
}
});
async function publish() {
if (data.title && data.content) {
try {
let event = new NDKEvent($ndk);
event.kind = wikiKind;
event.content = data.content;
event.tags.push(['d', data.title.toLowerCase().replaceAll(' ', '-')]);
event.tags.push(['title', data.title]);
if (data.summary) {
event.tags.push(['summary', data.summary]);
}
let relays = await event.publish();
relays.forEach((relay) => {
relay.once('published', () => {
console.debug('published to', relay);
});
relay.once('publish:failed', (relay, err) => {
console.debug('publish failed to', relay, err);
});
});
success = 1;
} catch (err) {
console.debug('failed to publish event', err);
error = String(err);
success = -1;
}
}
}
</script>
<div class="prose font-sans mx-auto p-2 lg:max-w-4xl">
<div class="prose">
<h1>
{#if data.forkId && $userPublickey == forkev?.author?.hexpubkey()}Editing{:else if data.forkId}Forking{:else}Creating{/if}
an article
</h1>
</div>
<div class="mt-2">
<label class="flex items-center"
>Title
<input
placeholder="example: Greek alphabet"
bind:value={data.title}
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md ml-2"
/></label
>
</div>
<div class="mt-2">
<label
>Article
<textarea
placeholder="The **Greek alphabet** has been used to write the [[Greek language]] sincie the late 9th or early 8th century BC. The Greek alphabet is the ancestor of the [[Latin]] and [[Cyrillic]] scripts."
bind:value={data.content}
rows="9"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
/></label
>
</div>
<div class="mt-2">
<details>
<summary> Add an explicit summary? </summary>
<label
>Summary
<textarea
bind:value={data.summary}
rows="3"
placeholder="The Greek alphabet is the earliest known alphabetic script to have distict letters for vowels. The Greek alphabet existed in many local variants."
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
/></label
>
</details>
</div>
<!-- Submit -->
{#if success !== 1}
<div class="mt-2">
<button
on:click={publish}
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>Submit</button
>
</div>
{/if}
<div>
{#if success == -1}
<p>Something went wrong :( note that only NIP07 is supported for signing</p>
<p>
Error Message: {error}
</p>
{:else if success == 1}
<p>Success!</p>
{/if}
</div>
</div>

113
src/lib/cards/Search.svelte

@ -1,113 +0,0 @@ @@ -1,113 +0,0 @@
<script lang="ts">
import { ndk } from '$lib/ndk';
import { wikiKind } from '$lib/consts';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { onMount } from 'svelte';
import type { Tab } from '$lib/types';
import { tabBehaviour } from '$lib/state';
import { parsePlainText } from '$lib/articleParser';
import { next } from '$lib/utils';
export let query: string;
export let replaceSelf: (tab: Tab) => void;
export let createChild: (tab: Tab) => void;
let results: NDKEvent[] = [];
let tried = 0;
async function search(query: string) {
results = [];
const filter = { kinds: [wikiKind], '#d': [query] };
const events = await $ndk.fetchEvents(filter);
if (!events) {
tried = 1;
results = [];
return;
}
tried = 1;
results = Array.from(events);
}
onMount(async () => {
await search(query);
});
</script>
<article class="font-sans mx-auto p-2 lg:max-w-4xl">
<div class="prose">
<h1 class="mb-0">{query}</h1>
<p class="mt-0 mb-0">
There are {#if tried == 1}{results.length}{:else}...{/if} articles with the name "{query}"
</p>
</div>
{#each results as result}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div
on:click={() => {
$tabBehaviour == 'child'
? createChild({ id: next(), type: 'article', data: result.id })
: replaceSelf({ id: next(), type: 'article', data: result.id });
}}
class="cursor-pointer px-4 py-5 bg-white border border-gray-300 hover:bg-slate-50 rounded-lg mt-2 min-h-[48px]"
>
<h1>
{result.tags.find((e) => e[0] == 'title')?.[0] &&
result.tags.find((e) => e[0] == 'title')?.[1]
? result.tags.find((e) => e[0] == 'title')?.[1]
: result.tags.find((e) => e[0] == 'd')?.[1]}
</h1>
<p class="text-xs">
<!-- implement published at? -->
<!-- {#if result.tags.find((e) => e[0] == "published_at")}
on {formatDate(result.tags.find((e) => e[0] == "published_at")[1])}
{/if} -->
{#await result.author?.fetchProfile()}
by <span class="text-gray-600 font-[600]">...</span>
{:then result}
by {result !== null && JSON.parse(Array.from(result)[0]?.content)?.name}
{/await}
</p>
<p class="text-xs">
{#if result.tags.find((e) => e[0] == 'summary')?.[0] && result.tags.find((e) => e[0] == 'summary')?.[1]}
{result.tags
.find((e) => e[0] == 'summary')?.[1]
.slice(
0,
192
)}{#if String(result.tags.find((e) => e[0] == 'summary')?.[1])?.length > 192}...{/if}
{:else}
{result.content.length <= 192
? parsePlainText(result.content.slice(0, 189))
: parsePlainText(result.content.slice(0, 189)) + '...'}
{/if}
</p>
</div>
{/each}
{#if tried == 1}
<div class="px-4 py-5 bg-white border border-gray-300 rounded-lg mt-2 min-h-[48px]">
<p class="mb-2">
{results.length < 1 ? "Can't find this article" : "Didn't find what you are looking for?"}
</p>
<button
on:click={() => {
$tabBehaviour == 'child'
? createChild({ id: next(), type: 'editor', data: { title: query } })
: replaceSelf({ id: next(), type: 'editor', data: { title: query } });
}}
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Create this article!
</button>
<button
on:click={() =>
$tabBehaviour == 'replace'
? replaceSelf({ id: next(), type: 'settings' })
: createChild({ id: next(), type: 'settings' })}
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Add more relays
</button>
</div>
{:else}
<div class="px-4 py-5 rounded-lg mt-2 min-h-[48px]">Loading...</div>
{/if}
</article>

149
src/lib/cards/Settings.svelte

@ -1,149 +0,0 @@ @@ -1,149 +0,0 @@
<script lang="ts">
import { browser } from '$app/environment';
import { standardRelays } from '$lib/consts';
import { ndk } from '$lib/ndk';
import { tabBehaviour, userPublickey } from '$lib/state';
import { NDKNip07Signer } from '@nostr-dev-kit/ndk';
import { onMount } from 'svelte';
let username = '...';
let relays: string[] = [];
let newTabBehaviour = $tabBehaviour;
let newRelay = '';
function removeRelay(index: number) {
relays.splice(index, 1);
relays = [...relays];
}
async function login() {
if (browser) {
if (!$ndk.signer) {
const signer = new NDKNip07Signer();
$ndk.signer = signer;
ndk.set($ndk);
}
if ($ndk.signer && $userPublickey == '') {
const newUserPublicKey = (await $ndk.signer.user()).hexpubkey();
localStorage.setItem('wikinostr_loggedInPublicKey', newUserPublicKey);
$userPublickey = newUserPublicKey;
userPublickey.set($userPublickey);
}
}
}
function logout() {
localStorage.removeItem('wikinostr_loggedInPublicKey');
userPublickey.set('');
}
function addRelay() {
if (newRelay) {
relays.push(newRelay);
newRelay = '';
relays = [...relays];
}
}
function saveData() {
addRelay();
localStorage.setItem('wikinostr_tabBehaviour', newTabBehaviour);
localStorage.setItem('wikinostr_relays', JSON.stringify(relays));
setTimeout(() => {
window.location.href = '';
}, 1);
}
if (browser) {
relays = JSON.parse(localStorage.getItem('wikinostr_relays') || JSON.stringify(standardRelays));
}
onMount(async () => {
// get user
const user = await $ndk.getUser({ hexpubkey: $userPublickey });
const profile = await user.fetchProfile();
if (profile) {
username = JSON.parse(Array.from(profile)[0].content).name;
}
});
</script>
<article class="font-sans mx-auto p-2 lg:max-w-4xl">
<div class="prose">
<h1 class="mt-0">Settings</h1>
</div>
<!-- Login Options -->
<div class="my-6">
<p class="text-sm">Account</p>
{#if $userPublickey == ''}
<p>You are not logged in!</p>
<button
on:click={login}
type="button"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>Login with NIP07
</button>
{:else}
<p>You are logged in as <a href={`nostr://${$userPublickey}`}>{username}</a></p>
<button
on:click={logout}
type="button"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>Logout
</button>
{/if}
</div>
<!-- Relay Selection -->
<div class="mb-6">
<p class="text-sm">Relays</p>
{#each relays as relay, index}
<div class="border rounded-full pl-2 my-1">
<button
class="text-red-500 py-0.5 px-1.5 rounded-full text-xl font-bold"
on:click={() => removeRelay(index)}
>
-
</button>
{relay}
</div>
{/each}
<div class="flex">
<input
bind:value={newRelay}
type="text"
class="inline mr-0 rounded-md rounded-r-none shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300"
placeholder="wss://relay.example.com"
/>
<button
on:click={addRelay}
type="button"
class="inline-flex ml-0 rounded-md rounded-l-none items-center px-2.5 py-1.5 border border-transparent text-sm font-medium shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>Add</button
>
</div>
</div>
<!-- More options -->
<div class="mb-6">
<p class="text-sm">Tab Behaviour</p>
<select
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
bind:value={newTabBehaviour}
>
<option value="replace">Replace Self Everywhere</option>
<option value="normal">Normal</option>
<option value="child">Create Child Everywhere</option>
</select>
</div>
<!-- Save button -->
<button
on:click={saveData}
type="button"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Save & Reload
</button>
</article>

88
src/lib/cards/UserArticles.svelte

@ -1,88 +0,0 @@ @@ -1,88 +0,0 @@
<script lang="ts">
import { parsePlainText } from '$lib/articleParser';
import { wikiKind } from '$lib/consts';
import { ndk } from '$lib/ndk';
import { tabBehaviour } from '$lib/state';
import type { Tab } from '$lib/types';
import { next } from '$lib/utils';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { onMount } from 'svelte';
let results: NDKEvent[] = [];
let username = '...';
export let createChild: (tab: Tab) => void;
export let replaceSelf: (tab: Tab) => void;
export let data: string;
async function search() {
results = [];
const filter = { kinds: [wikiKind], limit: 1024, authors: [data] };
const events = await $ndk.fetchEvents(filter);
if (!events) {
results = [];
return;
}
results = Array.from(events);
}
onMount(async () => {
// get user
const user = await $ndk.getUser({ hexpubkey: data });
const profile = await user.fetchProfile();
if (profile) {
username = JSON.parse(Array.from(profile)[0].content).name;
}
await search();
});
</script>
<article class="font-sans mx-auto p-2 lg:max-w-4xl">
<div>
<div class="prose">
<h1><a href={`nostr://${data}`}>{username}</a>'s articles</h1>
</div>
{#each results as result}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div
on:click={() =>
$tabBehaviour == 'replace'
? replaceSelf({ id: next(), type: 'article', data: result.id })
: createChild({ id: next(), type: 'article', data: result.id })}
class="cursor-pointer px-4 py-5 bg-white border border-gray-300 hover:bg-slate-50 rounded-lg mt-2 min-h-[48px]"
>
<h1>
{result.tags.find((e) => e[0] == 'title')?.[0] &&
result.tags.find((e) => e[0] == 'title')?.[1]
? result.tags.find((e) => e[0] == 'title')?.[1]
: result.tags.find((e) => e[0] == 'd')?.[1]}
</h1>
<p class="text-xs">
<!-- implement published at? -->
<!-- {#if result.tags.find((e) => e[0] == "published_at")}
on {formatDate(result.tags.find((e) => e[0] == "published_at")[1])}
{/if} -->
{#await result.author?.fetchProfile()}
by <span class="text-gray-600 font-[600]">...</span>
{:then result}
by {result !== null && JSON.parse(Array.from(result)[0]?.content)?.name}
{/await}
</p>
<p class="text-xs">
{#if result.tags.find((e) => e[0] == 'summary')?.[0] && result.tags.find((e) => e[0] == 'summary')?.[1]}
{result.tags
.find((e) => e[0] == 'summary')?.[1]
.slice(
0,
192
)}{#if String(result.tags.find((e) => e[0] == 'summary')?.[1])?.length > 192}...{/if}
{:else}
{result.content.length <= 192
? parsePlainText(result.content.slice(0, 189))
: parsePlainText(result.content.slice(0, 189)) + '...'}
{/if}
</p>
</div>
{/each}
</div>
</article>

85
src/lib/cards/Welcome.svelte

@ -1,85 +0,0 @@ @@ -1,85 +0,0 @@
<script lang="ts">
import { parsePlainText } from '$lib/articleParser';
import { wikiKind } from '$lib/consts';
import { ndk } from '$lib/ndk';
import { tabBehaviour } from '$lib/state';
import type { Tab } from '$lib/types';
import { next } from '$lib/utils';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { onMount } from 'svelte';
let results: NDKEvent[] = [];
export let createChild: (tab: Tab) => void;
export let replaceSelf: (tab: Tab) => void;
async function search() {
results = [];
const filter = { kinds: [wikiKind], limit: 48 };
const events = await $ndk.fetchEvents(filter);
if (!events) {
results = [];
return;
}
results = Array.from(events);
}
onMount(async () => {
await search();
});
</script>
<article class="font-sans mx-auto p-2 lg:max-w-4xl">
<div>
<div class="prose">
<h1>Welcome</h1>
</div>
</div>
<div>
<div class="prose">
<h2>Recent Articles</h2>
</div>
{#each results as result}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div
on:click={() =>
$tabBehaviour == 'replace'
? replaceSelf({ id: next(), type: 'article', data: result.id })
: createChild({ id: next(), type: 'article', data: result.id })}
class="cursor-pointer px-4 py-5 bg-white border border-gray-300 hover:bg-slate-50 rounded-lg mt-2 min-h-[48px]"
>
<h1>
{result.tags.find((e) => e[0] == 'title')?.[0] &&
result.tags.find((e) => e[0] == 'title')?.[1]
? result.tags.find((e) => e[0] == 'title')?.[1]
: result.tags.find((e) => e[0] == 'd')?.[1]}
</h1>
<p class="text-xs">
<!-- implement published at? -->
<!-- {#if result.tags.find((e) => e[0] == "published_at")}
on {formatDate(result.tags.find((e) => e[0] == "published_at")[1])}
{/if} -->
{#await result.author?.fetchProfile()}
by <span class="text-gray-600 font-[600]">...</span>
{:then result}
by {result !== null && JSON.parse(Array.from(result)[0]?.content)?.name}
{/await}
</p>
<p class="text-xs">
{#if result.tags.find((e) => e[0] == 'summary')?.[0] && result.tags.find((e) => e[0] == 'summary')?.[1]}
{result.tags
.find((e) => e[0] == 'summary')?.[1]
.slice(
0,
192
)}{#if String(result.tags.find((e) => e[0] == 'summary')?.[1])?.length > 192}...{/if}
{:else}
{result.content.length <= 192
? parsePlainText(result.content.slice(0, 189))
: parsePlainText(result.content.slice(0, 189)) + '...'}
{/if}
</p>
</div>
{/each}
</div>
</article>

20
src/lib/components/Article.svelte

@ -15,13 +15,17 @@ @@ -15,13 +15,17 @@
import { pharosInstance } from "$lib/parser";
import { page } from "$app/state";
let { rootId }: { rootId: string } = $props();
let { rootId, publicationType } = $props<{ rootId: string, publicationType: string }>();
if (rootId !== $pharosInstance.getRootIndexId()) {
console.error("Root ID does not match parser root index ID");
}
const tocBreakpoint = 1140;
let activeHash = $state(page.url.hash);
let showToc: boolean = $state(true);
let showTocButton: boolean = $state(false);
function normalizeHashPath(str: string): string {
return str
@ -47,23 +51,19 @@ @@ -47,23 +51,19 @@
}
}
let showToc: boolean = true;
let showTocButton: boolean = false;
const tocBreakpoint = 1140;
/**
* Hides the table of contents sidebar when the window shrinks below a certain size. This
* prevents the sidebar from occluding the article content.
*/
const setTocVisibilityOnResize = () => {
function setTocVisibilityOnResize() {
showToc = window.innerWidth >= tocBreakpoint;
showTocButton = window.innerWidth < tocBreakpoint;
};
}
/**
* Hides the table of contents sidebar when the user clicks outside of it.
*/
const hideTocOnClick = (ev: MouseEvent) => {
function hideTocOnClick(ev: MouseEvent) {
const target = ev.target as HTMLElement;
if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) {
@ -73,7 +73,7 @@ @@ -73,7 +73,7 @@
if (showToc) {
showToc = false;
}
};
}
onMount(() => {
// Always check whether the TOC sidebar should be visible.
@ -124,7 +124,7 @@ @@ -124,7 +124,7 @@
</Sidebar>
{/if} -->
<div class="flex flex-col space-y-4 max-w-2xl">
<Preview {rootId} />
<Preview {rootId} {publicationType} />
</div>
<style>

87
src/lib/components/ArticleHeader.svelte

@ -1,62 +1,72 @@ @@ -1,62 +1,72 @@
<script lang="ts">
import { page } from "$app/stores";
import { neventEncode } from "$lib/utils";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { standardRelays } from "../consts";
import { Card, Button, Modal, Tooltip } from "flowbite-svelte";
import { ClipboardCheckOutline, ClipboardCleanOutline, CodeOutline, ShareNodesOutline } from "flowbite-svelte-icons";
import { ndk } from "../ndk";
import { ndkInstance } from '$lib/ndk';
import { neventEncode } from '$lib/utils';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { naddrEncode, type AddressPointer } from 'nostr-tools/nip19';
import { standardRelays } from '../consts';
import { Card, Button, Modal, Tooltip } from 'flowbite-svelte';
import { ClipboardCheckOutline, ClipboardCleanOutline, CodeOutline, ShareNodesOutline } from 'flowbite-svelte-icons';
export let event: NDKEvent;
const { event } = $props<{ event: NDKEvent }>();
let title: string;
let author: string;
let href: string;
const relays = $derived.by(() => {
return $ndkInstance.activeUser?.relayUrls ?? standardRelays;
});
$: try {
const relays = $ndk.activeUser?.relayUrls ?? standardRelays;
title = event.getMatchingTags('title')[0][1];
author = event.getMatchingTags('author')[0][1];
const d = event.getMatchingTags('d')[0][1];
const href = $derived.by(() => {
const d = event.getMatchingTags('d')[0]?.[1];
if (d != null) {
href = `publication?d=${d}`;
return `publication?d=${d}`;
} else {
href = `publication?id=${neventEncode(event, relays)}`;
return `publication?id=${neventEncode(event, relays)}`;
}
} catch (e) {
console.warn(e);
}
});
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 eventIdCopied: boolean = $state(false);
let jsonModalOpen: boolean = $state(false);
let shareLinkCopied: boolean = $state(false);
let eventIdCopied: boolean = false;
function copyEventId() {
console.debug("copyEventID");
const relays: string[] = standardRelays;
const naddr = neventEncode(event, relays);
const nevent = neventEncode(event, relays);
navigator.clipboard.writeText(naddr);
navigator.clipboard.writeText(nevent);
eventIdCopied = true;
}
let jsonModalOpen: boolean = false;
function viewJson() {
console.debug("viewJSON");
const relays: string[] = standardRelays;
const naddr = neventEncode(event, relays);
jsonModalOpen = true;
}
let shareLinkCopied: boolean = false;
function shareNjump() {
const relays: string[] = standardRelays;
const naddr = neventEncode(event, relays);
const dTag : string | undefined = event.dTag;
console.debug(naddr);
navigator.clipboard.writeText(`njump.me/${naddr}`);
shareLinkCopied = true;
if (typeof dTag === 'string') {
const opts: AddressPointer = {
identifier: dTag,
pubkey: event.pubkey,
kind: 30040,
relays
};
const naddr = naddrEncode(opts);
console.debug(naddr);
navigator.clipboard.writeText(`https://njump.me/${naddr}`);
shareLinkCopied = true;
}
else {
console.log('dTag is undefined');
}
}
</script>
{#if title != null && href != null}
@ -65,9 +75,12 @@ @@ -65,9 +75,12 @@
<a href="/{href}" class='flex flex-col space-y-2'>
<h2 class='text-lg font-bold'>{title}</h2>
<h3 class='text-base font-normal'>by {author}</h3>
{#if version != '1'}
<h3 class='text-base font-normal'>version: {version}</h3>
{/if}
</a>
<div class='w-full flex space-x-2 justify-end'>
<Button class='btn-leather' size='xs' on:click={shareNjump}><ShareNodesOutline /></Button>
<Button class='btn-leather' size='xs' onclick={shareNjump}><ShareNodesOutline /></Button>
<Tooltip class='tooltip-leather' type='auto' placement='top' on:show={() => shareLinkCopied = false}>
{#if shareLinkCopied}
<ClipboardCheckOutline />
@ -75,7 +88,7 @@ @@ -75,7 +88,7 @@
Share via NJump
{/if}
</Tooltip>
<Button class='btn-leather' size='xs' outline on:click={copyEventId}><ClipboardCleanOutline /></Button>
<Button class='btn-leather' size='xs' outline onclick={copyEventId}><ClipboardCleanOutline /></Button>
<Tooltip class='tooltip-leather' type='auto' placement='top' on:show={() => eventIdCopied = false}>
{#if eventIdCopied}
<ClipboardCheckOutline />
@ -83,7 +96,7 @@ @@ -83,7 +96,7 @@
Copy event ID
{/if}
</Tooltip>
<Button class='btn-leather' size='xs' outline on:click={viewJson}><CodeOutline /></Button>
<Button class='btn-leather' size='xs' outline onclick={viewJson}><CodeOutline /></Button>
<Tooltip class='tooltip-leather' type='auto' placement='top'>View JSON</Tooltip>
</div>
</div>

52
src/lib/components/EventLimitControl.svelte

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
<script lang="ts">
import { networkFetchLimit } from "$lib/state";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher<{
update: { limit: number };
}>();
let inputValue = $networkFetchLimit;
function handleInput(event: Event) {
const input = event.target as HTMLInputElement;
const value = parseInt(input.value);
// Ensure value is between 1 and 50
if (value >= 1 && value <= 50) {
inputValue = value;
}
}
function handleUpdate() {
$networkFetchLimit = inputValue;
dispatch("update", { limit: inputValue });
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter") {
handleUpdate();
}
}
</script>
<div class="flex items-center gap-2 mb-4">
<label for="event-limit" class="text-sm font-medium"
>Number of root events:
</label>
<input
type="number"
id="event-limit"
min="1"
max="50"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1"
bind:value={inputValue}
on:input={handleInput}
on:keydown={handleKeyDown}
/>
<button
on:click={handleUpdate}
class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
Update
</button>
</div>

588
src/lib/components/EventNetwork.svelte

@ -1,588 +0,0 @@ @@ -1,588 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import * as d3 from "d3";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
export let events: NDKEvent[] = [];
let svg: SVGSVGElement;
let isDarkMode = false;
const nodeRadius = 20;
const linkDistance = 10;
const arrowDistance = 10;
const warmupClickEnergy = 0.9; // Energy to restart simulation on drag
let container: HTMLDivElement;
let width: number = 1000;
let height: number = 600;
let windowHeight: number;
$: graphHeight = windowHeight ? Math.max(windowHeight * 0.2, 400) : 400;
$: if (container) {
width = container.clientWidth || width;
height = container.clientHeight || height;
}
interface NetworkNode extends d3.SimulationNodeDatum {
id: string;
event?: NDKEvent;
index?: number;
isContainer: boolean;
title: string;
content: string;
author: string;
type: "Index" | "Content";
x?: number;
y?: number;
fx?: number | null;
fy?: number | null;
vx?: number;
vy?: number;
}
interface NetworkLink extends d3.SimulationLinkDatum<NetworkNode> {
source: NetworkNode;
target: NetworkNode;
isSequential: boolean;
}
function createEventMap(events: NDKEvent[]): Map<string, NDKEvent> {
return new Map(events.map((event) => [event.id, event]));
}
function updateNodeVelocity(
node: NetworkNode,
deltaVx: number,
deltaVy: number,
) {
if (typeof node.vx === "number" && typeof node.vy === "number") {
node.vx = node.vx - deltaVx;
node.vy = node.vy - deltaVy;
}
}
function applyGlobalLogGravity(
node: NetworkNode,
centerX: number,
centerY: number,
alpha: number,
) {
const dx = (node.x ?? 0) - centerX;
const dy = (node.y ?? 0) - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
const force = Math.log(distance + 1) * 0.05 * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
}
function applyConnectedGravity(
node: NetworkNode,
links: NetworkLink[],
alpha: number,
) {
const connectedNodes = links
.filter(
(link) => link.source.id === node.id || link.target.id === node.id,
)
.map((link) => (link.source.id === node.id ? link.target : link.source));
if (connectedNodes.length === 0) return;
const cogX = d3.mean(connectedNodes, (n) => n.x);
const cogY = d3.mean(connectedNodes, (n) => n.y);
if (cogX === undefined || cogY === undefined) return;
const dx = (node.x ?? 0) - cogX;
const dy = (node.y ?? 0) - cogY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
const force = distance * 0.3 * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
}
function getNode(
id: string,
nodeMap: Map<string, NetworkNode>,
event?: NDKEvent,
index?: number,
): NetworkNode | null {
if (!id) return null;
if (!nodeMap.has(id)) {
const node: NetworkNode = {
id,
event,
index,
isContainer: event?.kind === 30040,
title: event?.getMatchingTags("title")?.[0]?.[1] || "Untitled",
content: event?.content || "",
author: event?.pubkey || "",
type: event?.kind === 30040 ? "Index" : "Content",
x: width / 2 + (Math.random() - 0.5) * 100,
y: height / 2 + (Math.random() - 0.5) * 100,
};
nodeMap.set(id, node);
}
return nodeMap.get(id) || null;
}
function getEventColor(eventId: string): string {
const num = parseInt(eventId.slice(0, 4), 16);
const hue = num % 360;
const saturation = 70;
const lightness = 75;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
function generateGraph(events: NDKEvent[]): {
nodes: NetworkNode[];
links: NetworkLink[];
} {
const nodes: NetworkNode[] = [];
const links: NetworkLink[] = [];
const nodeMap = new Map<string, NetworkNode>();
// Create event lookup map - O(n) operation done once
const eventMap = createEventMap(events);
const indexEvents = events.filter((e) => e.kind === 30040);
indexEvents.forEach((index) => {
if (!index.id) return;
const contentRefs = index.getMatchingTags("e");
const sourceNode = getNode(index.id, nodeMap, index);
if (!sourceNode) return;
nodes.push(sourceNode);
contentRefs.forEach((tag, idx) => {
if (!tag[1]) return;
// O(1) lookup instead of O(n) search
const targetEvent = eventMap.get(tag[1]);
if (!targetEvent) return;
const targetNode = getNode(tag[1], nodeMap, targetEvent, idx);
if (!targetNode) return;
nodes.push(targetNode);
const prevNodeId =
idx === 0 ? sourceNode.id : contentRefs[idx - 1]?.[1];
const prevNode = nodeMap.get(prevNodeId);
if (prevNode && targetNode) {
links.push({
source: prevNode,
target: targetNode,
isSequential: true,
});
}
});
});
return { nodes, links };
}
function setupDragHandlers(
simulation: d3.Simulation<NetworkNode, NetworkLink>,
) {
// Create drag behavior with proper typing
const dragBehavior = d3
.drag<SVGGElement, NetworkNode>()
.on(
"start",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Warm up simulation when drag starts
if (!event.active)
simulation.alphaTarget(warmupClickEnergy).restart();
// Fix node position during drag
d.fx = d.x;
d.fy = d.y;
},
)
.on(
"drag",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Update fixed position to drag position
d.fx = event.x;
d.fy = event.y;
},
)
.on(
"end",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Cool down simulation when drag ends
if (!event.active) simulation.alphaTarget(0);
// Release fixed position, allowing forces to take over
d.fx = null;
d.fy = null;
},
);
return dragBehavior;
}
function drawNetwork() {
if (!svg || !events?.length) return;
const { nodes, links } = generateGraph(events);
if (!nodes.length) return;
const svgElement = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`);
// Set up zoom behavior
let g = svgElement.append("g");
const zoom = d3
.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 9])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svgElement.call(zoom);
if (g.empty()) {
g = svgElement.append("g");
// Define arrow marker with black fill
}
svgElement.select("defs").remove();
const defs = svgElement.append("defs");
defs
.append("marker")
.attr("id", "arrowhead")
.attr("markerUnits", "strokeWidth") // Added this
.attr("viewBox", "-10 -5 10 10")
.attr("refX", 0)
.attr("refY", 0)
.attr("markerWidth", 5)
.attr("markerHeight", 5)
.attr("orient", "auto")
.append("path")
.attr("d", "M -10 -5 L 0 0 L -10 5 z")
.attr("class", "network-link-leather")
.attr("fill", "none")
.attr("stroke-width", 1); // Added stroke
// Force simulation setup
const simulation = d3
.forceSimulation<NetworkNode>(nodes)
.force(
"link",
d3
.forceLink<NetworkNode, NetworkLink>(links)
.id((d) => d.id)
.distance(linkDistance * 0.1),
)
.force("collide", d3.forceCollide<NetworkNode>().radius(nodeRadius * 4));
simulation.on("end", () => {
// Get the bounds of the graph
const bounds = g.node()?.getBBox();
if (bounds) {
const dx = bounds.width;
const dy = bounds.height;
const x = bounds.x;
const y = bounds.y;
// Calculate scale to fit
const scale = 1.25 / Math.max(dx / width, dy / height);
const translate = [
width / 2 - scale * (x + dx / 2),
height / 2 - scale * (y + dy / 2),
];
// Apply the initial transform
svgElement
.transition()
.duration(750)
.call(
zoom.transform,
d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale),
);
}
});
const dragHandler = setupDragHandlers(simulation);
// Create links
// First, make sure we're selecting and creating links correctly
const link = g
.selectAll("path") // Changed from "path.link" to just "path"
.data(links)
.join(
(enter) =>
enter
.append("path")
.attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)") // This should now be applied
.attr("class", "network-link-leather"), // Add class if needed
(update) => update,
(exit) => exit.remove(),
);
// Create nodes
const node = g
.selectAll<SVGGElement, NetworkNode>("g.node")
.data(nodes, (d: NetworkNode) => d.id)
.join(
(enter) => {
const nodeEnter = enter
.append("g")
.attr("class", "node network-node-leather")
.call(dragHandler);
// add drag circle
nodeEnter
.append("circle")
.attr("r", nodeRadius * 2.5)
.attr("fill", "transparent")
.attr("stroke", "transparent")
.style("cursor", "move");
// add visual circle, stroke based on current theme
nodeEnter
.append("circle")
.attr("r", nodeRadius)
.attr("class", (d: NetworkNode) =>
!d.isContainer
? "network-node-leather network-node-content"
: "network-node-leather",
)
.attr("stroke-width", 2);
// add text labels
nodeEnter
.append("text")
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("fill", "black")
.attr("font-size", "12px");
// .attr("font-weight", "bold");
return nodeEnter;
},
(update) => update,
(exit) => exit.remove(),
);
// Add text labels
node
.select("circle:nth-child(2)")
.attr("fill", (d: NetworkNode) =>
!d.isContainer
? isDarkMode
? "#FFFFFF"
: "network-link-leather"
: getEventColor(d.id),
);
node.select("text").text((d: NetworkNode) => (d.isContainer ? "I" : "C"));
// Add tooltips
const tooltip = d3
.select("body")
.append("div")
.attr(
"class",
"tooltip-leather fixed hidden p-4 rounded shadow-lg " +
"bg-primary-0 dark:bg-primary-800 " +
"border border-gray-200 dark:border-gray-800 " +
"p-4 rounded shadow-lg border border-gray-200 dark:border-gray-800 " +
"transition-colors duration-200",
)
.style("z-index", 1000);
node
.on("mouseover", function (event, d) {
tooltip
.style("display", "block")
.html(
`
<div class="space-y-2">
<div class="font-bold text-base">${d.title}</div>
<div class="text-gray-600 dark:text-gray-400 text-sm">
${d.type} (${d.isContainer ? "30040" : "30041"})
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm overflow-hidden text-ellipsis">
ID: ${d.id}
</div>
${
d.content
? `
<div class="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-40">
${d.content}
</div>
`
: ""
}
</div>
`,
)
.style("left", event.pageX - 10 + "px")
.style("top", event.pageY + 10 + "px");
})
.on("mousemove", function (event) {
tooltip
.style("left", event.pageX + 10 + "px")
.style("top", event.pageY - 10 + "px");
})
.on("mouseout", () => {
tooltip.style("display", "none");
});
// Handle simulation ticks
simulation.on("tick", () => {
nodes.forEach((node) => {
applyGlobalLogGravity(node, width / 2, height / 2, simulation.alpha());
applyConnectedGravity(node, links, simulation.alpha());
});
link.attr("d", (d) => {
const dx = d.target.x! - d.source.x!;
const dy = d.target.y! - d.source.y!;
const angle = Math.atan2(dy, dx);
const sourceGap = nodeRadius;
const targetGap = nodeRadius + arrowDistance; // Increased gap for arrowhead
const startX = d.source.x! + sourceGap * Math.cos(angle);
const startY = d.source.y! + sourceGap * Math.sin(angle);
const endX = d.target.x! - targetGap * Math.cos(angle);
const endY = d.target.y! - targetGap * Math.sin(angle);
return `M${startX},${startY}L${endX},${endY}`;
});
node.attr("transform", (d) => `translate(${d.x},${d.y})`);
});
}
onMount(() => {
isDarkMode = document.body.classList.contains("dark");
// Add window resize listener
const handleResize = () => {
windowHeight = window.innerHeight;
};
// Initial resize
windowHeight = window.innerHeight;
window.addEventListener("resize", handleResize);
// Watch for theme changes
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === "class") {
const newIsDarkMode = document.body.classList.contains("dark");
if (newIsDarkMode !== isDarkMode) {
isDarkMode = newIsDarkMode;
// drawNetwork();
}
}
});
});
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
width = entry.contentRect.width;
height = graphHeight;
}
// first remove all nodes and links
d3.select(svg).selectAll("*").remove();
drawNetwork();
});
// Start observers
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
resizeObserver.observe(container);
// Clean up
return () => {
themeObserver.disconnect();
resizeObserver.disconnect();
};
});
// Reactive redaw
$: {
if (svg && events?.length) {
drawNetwork();
}
}
</script>
<div
class="flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4"
>
<div class="h-[calc(100%-130px)] min-h-[300px]" bind:this={container}>
<svg
bind:this={svg}
class="w-full h-full border border-gray-300 dark:border-gray-700 rounded"
/>
</div>
<!-- Legend -->
<div class="leather-legend">
<h3 class="text-lg font-bold mb-2 h-leather">Legend</h3>
<ul class="legend-list">
<li class="legend-item">
<div class="legend-icon">
<span
class="legend-circle"
style="background-color: hsl(200, 70%, 75%)"
>
</span>
<span class="legend-letter">I</span>
</div>
<span>Index events (kind 30040) - Each with a unique pastel color</span>
</li>
<li class="legend-item">
<div class="legend-icon">
<span class="legend-circle content"></span>
<span class="legend-letter">C</span>
</div>
<span>Content events (kind 30041) - Publication sections</span>
</li>
<li class="legend-item">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path d="M4 12h16M16 6l6 6-6 6" class="network-link-leather" />
</svg>
<span>Arrows indicate reading/sequence order</span>
</li>
</ul>
</div>
</div>
<style>
.legend-list {
@apply list-disc pl-5 space-y-2 text-gray-800 dark:text-gray-300;
}
.legend-item {
@apply flex items-center;
}
.legend-icon {
@apply relative w-6 h-6 mr-2;
}
.legend-circle {
@apply absolute inset-0 rounded-full border-2 border-black;
}
.legend-circle.content {
@apply bg-gray-700 dark:bg-gray-300;
background-color: #d6c1a8;
}
.legend-letter {
@apply absolute inset-0 flex items-center justify-center text-black text-xs;
}
</style>

52
src/lib/components/EventRenderLevelLimit.svelte

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
<script lang="ts">
import { levelsToRender } from "$lib/state";
import { createEventDispatcher } from "svelte";
let inputValue = $levelsToRender;
const dispatch = createEventDispatcher<{
update: { limit: number };
}>();
function handleInput(event: Event) {
const input = event.target as HTMLInputElement;
const value = parseInt(input.value);
// Ensure value is between 1 and 50
if (value >= 1 && value <= 50) {
inputValue = value;
}
}
function handleUpdate() {
$levelsToRender = inputValue;
dispatch("update", { limit: inputValue });
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter") {
handleUpdate();
}
}
</script>
<div class="flex items-center gap-2 mb-4">
<label for="levels-to-render" class="text-sm font-medium"
>Levels to render:
</label>
<label for="event-limit" class="text-sm font-medium">Limit: </label>
<input
type="number"
id="levels-to-render"
min="1"
max="50"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1"
bind:value={inputValue}
oninput={handleInput}
onkeydown={handleKeyDown}
/>
<button
onclick={handleUpdate}
class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
Update
</button>
</div>

114
src/lib/components/Login.svelte

@ -1,55 +1,89 @@ @@ -1,55 +1,89 @@
<script lang='ts'>
import { Avatar, Button, Popover } from 'flowbite-svelte';
import { NDKNip07Signer, type NDKUserProfile } from '@nostr-dev-kit/ndk';
import { signedIn, ndk } from '$lib/ndk';
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 { ArrowRightToBracketOutline } from 'flowbite-svelte-icons';
let profile: NDKUserProfile | null = null;
let pfp: string | undefined = undefined;
let username: string | undefined = undefined;
let tag: string | undefined = undefined;
let profile = $state<NDKUserProfile | null>(null);
let pfp = $derived(profile?.image);
let username = $derived(profile?.name);
let tag = $derived(profile?.name);
$: {
pfp = profile?.image;
username = profile?.name;
tag = profile?.name;
}
const signInWithExtension = async () => {
const signer = new NDKNip07Signer();
const user = await signer.user();
user.ndk = $ndk;
$ndk.signer = signer;
$ndk.activeUser = user;
let signInFailed = $state<boolean>(false);
await $ndk.connect();
profile = await $ndk.activeUser?.fetchProfile();
$effect(() => {
if ($ndkSignedIn) {
$ndkInstance
.getUser({ pubkey: $activePubkey ?? undefined })
?.fetchProfile()
.then(userProfile => {
profile = userProfile;
});
}
});
console.debug('NDK signed in with extension and reconnected.');
async function handleSignInClick() {
try {
const user = await loginWithExtension();
if (!user) {
throw new Error('The NIP-07 extension did not return a user.');
}
$signedIn = true;
};
profile = await user.fetchProfile();
persistLogin(user);
} catch (e) {
console.error(e);
signInFailed = true;
// TODO: Show an error message to the user.
}
}
const signInWithBunker = () => {
console.warn('Bunker sign-in not yet implemented.');
};
async function handleSignOutClick() {
logout($ndkInstance.activeUser!);
profile = null;
}
</script>
{#if $signedIn}
{#if $ndkSignedIn}
<Avatar
rounded
class='h-6 w-6 m-4 cursor-pointer'
src={pfp}
alt={username}
/>
<Popover
class='popover-leather w-fit'
placement='bottom'
target='avatar'
>
<h3 class='text-lg font-bold'>{username}</h3>
<h4 class='text-base'>@{tag}</h4>
</Popover>
{#key username || tag}
<Popover
class='popover-leather w-fit'
placement='bottom'
target='avatar'
>
<div class='flex flex-row justify-between space-x-4'>
<div class='flex flex-col'>
<h3 class='text-lg font-bold'>{username}</h3>
<h4 class='text-base'>@{tag}</h4>
</div>
<div class='flex flex-col justify-center'>
<Button
id='sign-out-button'
class='btn-leather !p-2 hover:text-primary-400 dark:hover:text-primary-500 hover:border-primary-400 dark:hover:border-primary-500'
pill
outline
color='alternative'
onclick={handleSignOutClick}
>
<ArrowRightToBracketOutline class='w-4 h-4 !fill-none dark:!fill-none' />
<Tooltip
class='tooltip-leather w-fit whitespace-nowrap'
triggeredBy='#sign-out-button'
placement='bottom'
>
Sign out
</Tooltip>
</Button>
</div>
</div>
</Popover>
{/key}
{:else}
<Avatar rounded class='h-6 w-6 m-4 cursor-pointer' id='avatar' />
<Popover
@ -59,16 +93,16 @@ @@ -59,16 +93,16 @@
>
<div class='w-full flex space-x-2'>
<Button
on:click={signInWithExtension}
onclick={handleSignInClick}
>
Extension Sign-In
</Button>
<Button
<!-- <Button
color='alternative'
on:click={signInWithBunker}
>
Bunker Sign-In
</Button>
</Button> -->
</div>
</Popover>
{/if}

9
src/lib/components/Navigation.svelte

@ -1,17 +1,16 @@ @@ -1,17 +1,16 @@
<script lang="ts">
import { DarkMode, Navbar, NavLi, NavUl, NavHamburger, NavBrand } from 'flowbite-svelte';
import Login from './Login.svelte';
import Login from './Login.svelte';
let className: string;
export { className as class };
let { class: className = '' } = $props();
let leftMenuOpen: boolean = false;
let leftMenuOpen = $state(false);
</script>
<Navbar class={`Navbar navbar-leather ${className}`}>
<div class='flex flex-grow justify-between'>
<NavBrand href='/'>
<h1 class='font-serif'>Alexandria</h1>
<h1>Alexandria</h1>
</NavBrand>
</div>
<div class='flex md:order-2'>

70
src/lib/components/Note.svelte

@ -1,70 +0,0 @@ @@ -1,70 +0,0 @@
<script lang="ts">
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import {Converter} from 'showdown';
const converter = new Converter();
export let notes: NDKEvent[] = [];
notes.forEach((note) => {
note.votes = 0;
});
import {nip19} from 'nostr-tools';
$: notes.forEach((note) => {
note.voteUp = () => {
note.votes++;
note.update();
};
note.voteDown = () => {
note.votes--;
note.update();
};
note.getVotes = () => {
return note.votes;
};
});
</script>
<div class="notes">
{#each notes as note}
<div class="title" id={nip19.noteEncode(note.id)}>
<h4>{note.getMatchingTags('title')[0][1]}</h4>
</div>
<div class="vote">
<button on:click={note.voteUp}>&#x25B2;</button>
<p>{note.getVotes()}</p>
<button on:click={note.voteDown}>&#x25BC;</button>
</div>
<div class="content">
{@html converter.makeHtml(note.content)}
</div>
{/each}
</div>
<style>
.notes {
display: grid;
border: 1px solid white;
}
.title {
display: grid;
grid-column: 1/2;
margin: auto;
float: right;
border: 1px solid white;
text-align: center;
}
.content {
display: grid;
grid-column: 1/2;
width: 100%;
padding: 10px;
border: 1px solid white;
}
.vote {
display: grid;
grid-template-rows: 1fr 1fr 1fr;
grid-column: 3/3;
width: 5%;
margin: 1%;
}
</style>

202
src/lib/components/Preview.svelte

@ -1,40 +1,53 @@ @@ -1,40 +1,53 @@
<script lang="ts">
import { page } from "$app/state";
import { pharosInstance, SiblingSearchDirection } from "$lib/parser";
import { Button, ButtonGroup, CloseButton, Heading, Input, P, Textarea, Tooltip } from "flowbite-svelte";
import { CaretDownSolid, CaretUpSolid, EditOutline } from "flowbite-svelte-icons";
import { createEventDispatcher } from "svelte";
<script lang='ts'>
import { pharosInstance, SiblingSearchDirection } from '$lib/parser';
import { Button, ButtonGroup, CloseButton, Input, P, Textarea, Tooltip } from 'flowbite-svelte';
import { CaretDownSolid, CaretUpSolid, EditOutline } from 'flowbite-svelte-icons';
import Self from './Preview.svelte';
// TODO: Fix move between parents.
export let sectionClass: string = '';
export let isSectionStart: boolean = false;
export let rootId: string;
export let parentId: string | null | undefined = null;
export let depth: number = 0;
export let allowEditing: boolean = false;
export let needsUpdate: boolean = false;
let {
allowEditing,
depth = 0,
isSectionStart,
needsUpdate = $bindable<boolean>(),
oncursorcapture,
oncursorrelease,
parentId,
rootId,
sectionClass,
publicationType,
} = $props<{
allowEditing?: boolean;
depth?: number;
isSectionStart?: boolean;
needsUpdate?: boolean;
oncursorcapture?: (e: MouseEvent) => void;
oncursorrelease?: (e: MouseEvent) => void;
parentId?: string | null | undefined;
rootId: string;
sectionClass?: string;
publicationType?: string;
}>();
const dispatch = createEventDispatcher();
let currentContent: string = $state($pharosInstance.getContent(rootId));
let title: string | undefined = $state($pharosInstance.getIndexTitle(rootId));
let orderedChildren: string[] = $state($pharosInstance.getOrderedChildIds(rootId));
let currentContent: string = $pharosInstance.getContent(rootId);
let title: string | undefined = $pharosInstance.getIndexTitle(rootId);
let orderedChildren: string[] = $pharosInstance.getOrderedChildIds(rootId);
let isEditing: boolean = $state(false);
let hasCursor: boolean = $state(false);
let childHasCursor: boolean = $state(false);
let isEditing: boolean = false;
let hasCursor: boolean = false;
let childHasCursor: boolean;
let hasPreviousSibling: boolean = $state(false);
let hasNextSibling: boolean = $state(false);
let hasPreviousSibling: boolean = false;
let hasNextSibling: boolean = false;
let subtreeNeedsUpdate: boolean = $state(false);
let updateCount: number = $state(0);
let subtreeUpdateCount: number = $state(0);
let subtreeNeedsUpdate: boolean = false;
let updateCount: number = 0;
let subtreeUpdateCount: number = 0;
let buttonsVisible: boolean = $derived(hasCursor && !childHasCursor);
$: buttonsVisible = hasCursor && !childHasCursor;
$: {
$effect(() => {
if (needsUpdate) {
updateCount++;
needsUpdate = false;
@ -58,9 +71,9 @@ @@ -58,9 +71,9 @@
needsUpdate = true;
}
}
}
});
$: {
$effect(() => {
if (parentId && allowEditing) {
// Check for previous/next siblings on load
const previousSibling = $pharosInstance.getNearestSibling(rootId, depth - 1, SiblingSearchDirection.Previous);
@ -70,43 +83,34 @@ @@ -70,43 +83,34 @@
hasPreviousSibling = !!previousSibling[0];
hasNextSibling = !!nextSibling[0];
}
}
});
const getHeadingTag = (depth: number) => {
switch (depth) {
case 0:
return "h2";
case 1:
return "h3";
case 2:
return "h4";
case 3:
return "h5";
case 4:
return "h6";
}
};
const handleMouseEnter = (e: MouseEvent) => {
function handleMouseEnter(e: MouseEvent) {
hasCursor = true;
dispatch('cursorcapture', e);
};
if (oncursorcapture) {
oncursorcapture(e);
}
}
const handleMouseLeave = (e: MouseEvent) => {
function handleMouseLeave(e: MouseEvent) {
hasCursor = false;
dispatch('cursorrelease', e);
};
if (oncursorrelease) {
oncursorrelease(e);
}
}
const handleChildCursorCaptured = (e: MouseEvent) => {
function handleChildCursorCaptured(e: MouseEvent) {
childHasCursor = true;
dispatch('cursorrelase', e);
};
if (oncursorcapture) {
oncursorcapture(e);
}
}
const handleChildCursorReleased = (e: MouseEvent) => {
function handleChildCursorReleased(e: MouseEvent) {
childHasCursor = false;
}
const toggleEditing = (id: string, shouldSave: boolean = true) => {
function toggleEditing(id: string, shouldSave: boolean = true) {
const editing = isEditing;
if (editing && shouldSave) {
@ -118,9 +122,9 @@ @@ -118,9 +122,9 @@
}
isEditing = !editing;
};
}
const moveUp = (rootId: string, parentId: string) => {
function moveUp(rootId: string, parentId: string) {
// Get the previous sibling and its index
const [prevSiblingId, prevIndex] = $pharosInstance.getNearestSibling(rootId, depth - 1, SiblingSearchDirection.Previous);
if (!prevSiblingId || prevIndex == null) {
@ -132,7 +136,7 @@ @@ -132,7 +136,7 @@
needsUpdate = true;
};
const moveDown = (rootId: string, parentId: string) => {
function moveDown(rootId: string, parentId: string) {
// Get the next sibling and its index
const [nextSiblingId, nextIndex] = $pharosInstance.getNearestSibling(rootId, depth - 1, SiblingSearchDirection.Next);
if (!nextSiblingId || nextIndex == null) {
@ -142,16 +146,56 @@ @@ -142,16 +146,56 @@
// Move the current event after the next sibling
$pharosInstance.moveEvent(rootId, nextSiblingId, true);
needsUpdate = true;
};
}
</script>
{#snippet sectionHeading(title: string, depth: number)}
{#if depth === 0}
<h1 class='h-leather'>
{title}
</h1>
{:else if depth === 1}
<h2 class='h-leather'>
{title}
</h2>
{:else if depth === 2}
<h3 class='h-leather'>
{title}
</h3>
{:else if depth === 3}
<h4 class='h-leather'>
{title}
</h4>
{:else if depth === 4}
<h5 class='h-leather'>
{title}
</h5>
{:else}
<h6 class='h-leather'>
{title}
</h6>
{/if}
{/snippet}
{#snippet contentParagraph(content: string, publicationType: string)}
{#if publicationType === 'book'}
<P class='whitespace-normal' firstupper={isSectionStart}>
{@html content}
</P>
{:else}
<P class='whitespace-normal' firstupper={false}>
{@html content}
</P>
{/if}
{/snippet}
<!-- This component is recursively structured. The base case is single block of content. -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<section
id={rootId}
class={`note-leather flex space-x-2 justify-between text-wrap break-words ${sectionClass}`}
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
aria-label='Publication section'
>
<!-- Zettel base case -->
{#if orderedChildren.length === 0 || depth >= 4}
@ -165,7 +209,7 @@ @@ -165,7 +209,7 @@
class='btn-leather min-w-fit'
size='sm'
outline
on:click={() => toggleEditing(rootId, false)}
onclick={() => toggleEditing(rootId, false)}
>
Cancel
</Button>
@ -173,7 +217,7 @@ @@ -173,7 +217,7 @@
type='submit'
class='btn-leather min-w-fit'
size='sm'
on:click={() => toggleEditing(rootId, true)}
onclick={() => toggleEditing(rootId, true)}
>
Save
</Button>
@ -181,9 +225,7 @@ @@ -181,9 +225,7 @@
</Textarea>
</form>
{:else}
<P class='whitespace-normal' firstupper={isSectionStart}>
{@html currentContent}
</P>
{@render contentParagraph(currentContent, publicationType)}
{/if}
{/key}
{:else}
@ -191,29 +233,29 @@ @@ -191,29 +233,29 @@
{#if isEditing}
<ButtonGroup class='w-full'>
<Input type='text' class='input-leather' size='lg' bind:value={title}>
<CloseButton slot='right' on:click={() => toggleEditing(rootId, false)} />
<CloseButton slot='right' onclick={() => toggleEditing(rootId, false)} />
</Input>
<Button class='btn-leather' color='primary' size='lg' on:click={() => toggleEditing(rootId, true)}>
<Button class='btn-leather' color='primary' size='lg' onclick={() => toggleEditing(rootId, true)}>
Save
</Button>
</ButtonGroup>
{:else}
<Heading tag={getHeadingTag(depth)} class='h-leather'>
{title}
</Heading>
{@render sectionHeading(title!, depth)}
{/if}
<!-- Recurse on child indices and zettels -->
{#key subtreeUpdateCount}
{#each orderedChildren as id, index}
<svelte:self
<Self
rootId={id}
parentId={rootId}
publicationType={publicationType}
depth={depth + 1}
{allowEditing}
{sectionClass}
isSectionStart={index === 0}
bind:needsUpdate={subtreeNeedsUpdate}
on:cursorcapture={handleChildCursorCaptured}
on:cursorrelease={handleChildCursorReleased}
oncursorcapture={handleChildCursorCaptured}
oncursorrelease={handleChildCursorReleased}
/>
{/each}
{/key}
@ -222,16 +264,16 @@ @@ -222,16 +264,16 @@
{#if allowEditing && depth > 0}
<div class={`flex flex-col space-y-2 justify-start ${buttonsVisible ? 'visible' : 'invisible'}`}>
{#if hasPreviousSibling && parentId}
<Button class='btn-leather' size='sm' outline on:click={() => moveUp(rootId, parentId)}>
<Button class='btn-leather' size='sm' outline onclick={() => moveUp(rootId, parentId)}>
<CaretUpSolid />
</Button>
{/if}
{#if hasNextSibling && parentId}
<Button class='btn-leather' size='sm' outline on:click={() => moveDown(rootId, parentId)}>
<Button class='btn-leather' size='sm' outline onclick={() => moveDown(rootId, parentId)}>
<CaretDownSolid />
</Button>
{/if}
<Button class='btn-leather' size='sm' outline on:click={() => toggleEditing(rootId)}>
<Button class='btn-leather' size='sm' outline onclick={() => toggleEditing(rootId)}>
<EditOutline />
</Button>
<Tooltip class='tooltip-leather' type='auto' placement='top'>

115
src/lib/components/PublicationFeed.svelte

@ -0,0 +1,115 @@ @@ -0,0 +1,115 @@
<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 { Button, P, Skeleton, Spinner } from 'flowbite-svelte';
import ArticleHeader from './ArticleHeader.svelte';
import { onMount } from 'svelte';
let { relays } = $props<{ relays: string[] }>();
let eventsInView: NDKEvent[] = $state([]);
let loadingMore: boolean = $state(false);
let endOfFeed: boolean = $state(false);
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;
}
endOfFeed = eventArray?.at(eventArray.length - 1)?.id === eventsInView?.at(eventsInView.length - 1)?.id;
if (endOfFeed) {
return;
}
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[];
}
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}`);
}
return skeletonIds;
}
async function loadMorePublications() {
loadingMore = true;
await getEvents(cutoffTimestamp);
loadingMore = false;
}
onMount(async () => {
await getEvents();
});
</script>
<div class='leather flex flex-col flex-grow-0 space-y-4 overflow-y-auto w-max p-2'>
{#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 articles found.</p>
{/if}
{#if !loadingMore && !endOfFeed}
<div class='flex justify-center mt-4 mb-8'>
<Button outline class="w-full" onclick={async () => {
await loadMorePublications();
}}>
Show more publications
</Button>
</div>
{:else if loadingMore}
<div class='flex justify-center mt-4 mb-8'>
<Button outline disabled class="w-full">
<Spinner class='mr-3 text-gray-300' size='4' />
Loading...
</Button>
</div>
{:else}
<div class='flex justify-center mt-4 mb-8'>
<P class='text-sm text-gray-600'>You've reached the end of the feed.</P>
</div>
{/if}
</div>

38
src/lib/components/Searchbar.svelte

@ -1,38 +0,0 @@ @@ -1,38 +0,0 @@
<script lang="ts">
import { tabs } from '$lib/state';
import { next, scrollTabIntoView } from '$lib/utils';
import type { Tab } from '$lib/types';
let query = '';
function search() {
let a = query;
query = '';
if (a) {
let newTabs = $tabs;
const newTab: Tab = {
id: next(),
type: 'find',
data: a.toLowerCase().replaceAll(' ', '-')
};
newTabs.push(newTab);
tabs.set(newTabs);
scrollTabIntoView(String(newTab.id), true);
}
}
</script>
<form on:submit|preventDefault={search} class="mt- flex rounded-md shadow-sm">
<div class="relative flex items-stretch flex-grow focus-within:z-10">
<input
bind:value={query}
class="focus:ring-indigo-500 focus:border-indigo-500 block w-full rounded-none rounded-l-md sm:text-sm border-gray-300"
placeholder="article name"
/>
</div>
<button
type="submit"
class="-ml-px relative inline-flex items-center space-x-2 px-3 py-2 border border-gray-300 text-sm font-medium rounded-r-md text-gray-700 bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-white"
>Go</button
>
</form>

10
src/lib/consts.ts

@ -1,9 +1,13 @@ @@ -1,9 +1,13 @@
export const wikiKind = 30818;
export const indexKind = 30040;
export const zettelKinds = [ 30041 ];
export const standardRelays = [ "wss://thecitadel.nostr1.com", "wss://relay.noswhere.com" ];
export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://relay.noswhere.com' ];
export const bootstrapRelays = [ 'wss://purplepag.es', 'wss://relay.noswhere.com' ];
export enum FeedType {
Relays,
Follows,
StandardRelays = 'standard',
UserRelays = 'user',
}
export const loginStorageKey = 'alexandria/login/pubkey';
export const feedTypeStorageKey = 'alexandria/feed/type';

111
src/lib/defaultShareButton.svelte

@ -1,111 +0,0 @@ @@ -1,111 +0,0 @@
<script lang="ts">
import { ndk } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { neventEncode } from "$lib/utils.ts";
import { nip19 } from "nostr-tools";
import { standardRelays } from "./consts";
import Modal from "$lib/Modal.svelte";
export let event: NDKEvent;
// const eventString: string = JSON.stringify(event);
// event.toString();
let modal = false;
function copyEventID() {
console.debug("copyEventID");
const relays: string[] = standardRelays;
const naddr = neventEncode(event, relays);
navigator.clipboard.writeText(naddr);
}
function viewJSON() {
console.debug("viewJSON");
modal = !modal;
console.debug(modal);
}
function shareNjump() {
const relays: string[] = standardRelays;
const naddr = neventEncode(event, relays);
console.debug(naddr);
navigator.clipboard.writeText(`njump.me/${naddr}`);
}
</script>
<div class="dropdown">
<button class="dropbtn">
<div class="dot" />
<div class="dot" />
<div class="dot" />
</button>
<div class="dropdown-content">
<a on:click={copyEventID}>Copy Event ID</a>
<!-- <a on:click={viewJSON}>View JSON</a> -->
<a on:click={shareNjump}>Share (njump)</a>
</div>
</div>
<Modal showModal={modal} {event} />
<style>
.dropdown {
position: relative;
display: inline-block;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
border-color: green;
/* boarder-width: 100px; */
}
.dropbtn {
color: white;
grid-column: 2;
margin: 50px;
padding: 16px;
font-size: 16px;
border: none;
cursor: pointer;
border: 1px solid red;
}
.dot {
height: 9px;
width: 9px;
background-color: white;
border-radius: 50%;
display: inline-block;
margin: 0 5px;
}
/* The container <div> - needed to position the dropdown content */
/* Dropdown Content (Hidden by Default) */
.dropdown-content {
display: none;
position: absolute;
background-color: #f9f9f9;
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
z-index: 1;
}
/* Links inside the dropdown */
.dropdown-content a {
color: black;
padding: 12px 16px;
text-decoration: none;
display: block;
}
/* Change color of dropdown links on hover */
.dropdown-content a:hover {
background-color: #cacaca;
}
/* Show the dropdown menu on hover */
.dropdown:hover .dropdown-content {
display: block;
}
/* Change the background color of the dropdown button when the dropdown content is shown */
.dropdown:hover .dropbtn {
/* background-color: #3e8e41; */
}
</style>

57
src/lib/navigator/EventNetwork/Legend.svelte

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
<script lang="ts">
export let className: string = "";
</script>
<div class="leather-legend {className}">
<h3 class="text-lg font-bold mb-2 h-leather">Legend</h3>
<ul class="legend-list">
<li class="legend-item">
<div class="legend-icon">
<span class="legend-circle" style="background-color: hsl(200, 70%, 75%)">
</span>
<span class="legend-letter">I</span>
</div>
<span>Index events (kind 30040) - Each with a unique pastel color</span>
</li>
<li class="legend-item">
<div class="legend-icon">
<span class="legend-circle content"></span>
<span class="legend-letter">C</span>
</div>
<span>Content events (kind 30041) - Publication sections</span>
</li>
<li class="legend-item">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path d="M4 12h16M16 6l6 6-6 6" class="network-link-leather" />
</svg>
<span>Arrows indicate reading/sequence order</span>
</li>
</ul>
</div>
<style>
.legend-list {
@apply list-disc pl-5 space-y-2 text-gray-800 dark:text-gray-300;
}
.legend-item {
@apply flex items-center;
}
.legend-icon {
@apply relative w-6 h-6 mr-2;
}
.legend-circle {
@apply absolute inset-0 rounded-full border-2 border-black;
}
.legend-circle.content {
@apply bg-gray-700 dark:bg-gray-300;
background-color: #d6c1a8;
}
.legend-letter {
@apply absolute inset-0 flex items-center justify-center text-black text-xs;
}
</style>

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

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
<script lang="ts">
import type { NetworkNode } from "./types";
export let node: NetworkNode;
export let selected: boolean = false;
export let x: number;
export let y: number;
</script>
<div
class="tooltip-leather fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-800
border border-gray-200 dark:border-gray-800 transition-colors duration-200"
style="left: {x + 10}px; top: {y - 10}px; z-index: 1000;"
>
<div class="space-y-2">
<div class="font-bold text-base">{node.title}</div>
<div class="text-gray-600 dark:text-gray-400 text-sm">
{node.type} ({node.isContainer ? "30040" : "30041"})
</div>
<div
class="text-gray-600 dark:text-gray-400 text-sm overflow-hidden text-ellipsis"
>
ID: {node.id}
{#if node.naddr}
<div>{node.naddr}</div>
{/if}
{#if node.nevent}
<div>{node.nevent}</div>
{/if}
</div>
{#if node.content}
<div
class="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-40"
>
{node.content}
</div>
{/if}
{#if selected}
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Click node again to dismiss
</div>
{/if}
</div>
</div>

335
src/lib/navigator/EventNetwork/index.svelte

@ -0,0 +1,335 @@ @@ -0,0 +1,335 @@
<!-- EventNetwork.svelte -->
<script lang="ts">
import { onMount } from "svelte";
import * as d3 from "d3";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { levelsToRender } from "$lib/state";
import { generateGraph, getEventColor } from "./utils/networkBuilder";
import { createSimulation, setupDragHandlers, applyGlobalLogGravity, applyConnectedGravity } from "./utils/forceSimulation";
import Legend from "./Legend.svelte";
import NodeTooltip from "./NodeTooltip.svelte";
let { events = [] } = $props<{ events?: NDKEvent[] }>();
let svg: SVGSVGElement;
let isDarkMode = $state(false);
let container: HTMLDivElement;
// Use a string ID for comparisons instead of the node object
let selectedNodeId = $state<string | null>(null);
let tooltipVisible = $state(false);
let tooltipX = $state(0);
let tooltipY = $state(0);
let tooltipNode = $state<NetworkNode | null>(null);
const nodeRadius = 20;
const linkDistance = 10;
const arrowDistance = 10;
let width = $state(1000);
let height = $state(600);
let windowHeight = $state<number | undefined>(undefined);
let simulation: d3.Simulation<NetworkNode, NetworkLink> | null = null;
let svgGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
let graphHeight = $derived(windowHeight ? Math.max(windowHeight * 0.2, 400) : 400);
// Update dimensions when container changes
$effect(() => {
if (container) {
width = container.clientWidth || width;
height = container.clientHeight || height;
}
});
// Track levelsToRender changes
let currentLevels = $derived(levelsToRender);
function initializeGraph() {
if (!svg) return;
const svgElement = d3.select(svg)
.attr("viewBox", `0 0 ${width} ${height}`);
// Clear existing content
svgElement.selectAll("*").remove();
// Create main group for zoom
svgGroup = svgElement.append("g");
// Set up zoom behavior
const zoom = d3
.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 9])
.on("zoom", (event) => {
svgGroup.attr("transform", event.transform);
});
svgElement.call(zoom);
// Set up arrow marker
const defs = svgElement.append("defs");
defs
.append("marker")
.attr("id", "arrowhead")
.attr("markerUnits", "strokeWidth")
.attr("viewBox", "-10 -5 10 10")
.attr("refX", 0)
.attr("refY", 0)
.attr("markerWidth", 5)
.attr("markerHeight", 5)
.attr("orient", "auto")
.append("path")
.attr("d", "M -10 -5 L 0 0 L -10 5 z")
.attr("class", "network-link-leather")
.attr("fill", "none")
.attr("stroke-width", 1);
}
function updateGraph() {
if (!svg || !events?.length || !svgGroup) return;
const { nodes, links } = generateGraph(events, currentLevels);
if (!nodes.length) return;
// Stop any existing simulation
if (simulation) simulation.stop();
// Create new simulation
simulation = createSimulation(nodes, links, nodeRadius, linkDistance);
const dragHandler = setupDragHandlers(simulation);
// Update links
const link = svgGroup
.selectAll<SVGPathElement, NetworkLink>("path.link")
.data(links, d => `${d.source.id}-${d.target.id}`)
.join(
enter => enter
.append("path")
.attr("class", "link network-link-leather")
.attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)"),
update => update,
exit => exit.remove()
);
// Update nodes
const node = svgGroup
.selectAll<SVGGElement, NetworkNode>("g.node")
.data(nodes, d => d.id)
.join(
enter => {
const nodeEnter = enter
.append("g")
.attr("class", "node network-node-leather")
.call(dragHandler);
nodeEnter
.append("circle")
.attr("class", "drag-circle")
.attr("r", nodeRadius * 2.5)
.attr("fill", "transparent")
.attr("stroke", "transparent")
.style("cursor", "move");
nodeEnter
.append("circle")
.attr("class", "visual-circle")
.attr("r", nodeRadius)
.attr("stroke-width", 2);
nodeEnter
.append("text")
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("fill", "black")
.attr("font-size", "12px");
return nodeEnter;
},
update => update,
exit => exit.remove()
);
// Update node appearances
node.select("circle.visual-circle")
.attr("class", d => !d.isContainer
? "visual-circle network-node-leather network-node-content"
: "visual-circle network-node-leather"
)
.attr("fill", d => !d.isContainer
? isDarkMode ? "#FFFFFF" : "network-link-leather"
: getEventColor(d.id)
);
node.select("text")
.text(d => d.isContainer ? "I" : "C");
// Update node interactions
node
.on("mouseover", (event, d) => {
if (!selectedNodeId) {
tooltipVisible = true;
tooltipNode = d;
tooltipX = event.pageX;
tooltipY = event.pageY;
}
})
.on("mousemove", (event, d) => {
if (!selectedNodeId) {
tooltipX = event.pageX;
tooltipY = event.pageY;
}
})
.on("mouseout", () => {
if (!selectedNodeId) {
tooltipVisible = false;
tooltipNode = null;
}
})
.on("click", (event, d) => {
event.stopPropagation();
if (selectedNodeId === d.id) {
selectedNodeId = null;
tooltipVisible = false;
tooltipNode = d;
tooltipX = event.pageX;
tooltipY = event.pageY;
} else {
selectedNodeId = d.id;
tooltipVisible = true;
tooltipNode = d;
tooltipX = event.pageX;
tooltipY = event.pageY;
}
});
// Handle simulation ticks
simulation.on("tick", () => {
nodes.forEach(node => {
applyGlobalLogGravity(node, width / 2, height / 2, simulation!.alpha());
applyConnectedGravity(node, links, simulation!.alpha());
});
// Update positions
link.attr("d", d => {
const dx = d.target.x! - d.source.x!;
const dy = d.target.y! - d.source.y!;
const angle = Math.atan2(dy, dx);
const sourceGap = nodeRadius;
const targetGap = nodeRadius + arrowDistance;
const startX = d.source.x! + sourceGap * Math.cos(angle);
const startY = d.source.y! + sourceGap * Math.sin(angle);
const endX = d.target.x! - targetGap * Math.cos(angle);
const endY = d.target.y! - targetGap * Math.sin(angle);
return `M${startX},${startY}L${endX},${endY}`;
});
node.attr("transform", d => `translate(${d.x},${d.y})`);
});
}
onMount(() => {
isDarkMode = document.body.classList.contains("dark");
// Initialize the graph structure
initializeGraph();
// Handle window resizing
const handleResize = () => {
windowHeight = window.innerHeight;
};
windowHeight = window.innerHeight;
window.addEventListener("resize", handleResize);
// Watch for theme changes
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === "class") {
const newIsDarkMode = document.body.classList.contains("dark");
if (newIsDarkMode !== isDarkMode) {
isDarkMode = newIsDarkMode;
// Update node colors when theme changes
if (svgGroup) {
svgGroup.selectAll<SVGGElement, NetworkNode>("g.node")
.select("circle.visual-circle")
.attr("fill", d => !d.isContainer
? newIsDarkMode ? "#FFFFFF" : "network-link-leather"
: getEventColor(d.id)
);
}
}
}
});
});
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
width = entry.contentRect.width;
height = graphHeight;
}
if (svg) {
d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`);
// Trigger simulation to adjust to new dimensions
if (simulation) {
simulation.alpha(0.3).restart();
}
}
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
resizeObserver.observe(container);
return () => {
themeObserver.disconnect();
resizeObserver.disconnect();
window.removeEventListener("resize", handleResize);
if (simulation) simulation.stop();
};
});
// Watch for changes that should trigger a graph update
$effect(() => {
if (svg && events?.length) {
// Include currentLevels in the effect dependencies
const _ = currentLevels;
updateGraph();
}
});
</script>
<div
class="flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4"
>
<div class="h-[calc(100%-130px)] min-h-[300px]" bind:this={container}>
<svg
bind:this={svg}
class="w-full h-full border border-gray-300 dark:border-gray-700 rounded"
/>
</div>
{#if tooltipVisible && tooltipNode}
<NodeTooltip
node={tooltipNode}
selected={tooltipNode.id === selectedNodeId}
x={tooltipX}
y={tooltipY}
/>
{/if}
<Legend />
</div>
<style>
.tooltip {
max-width: 300px;
word-wrap: break-word;
}
</style>

35
src/lib/navigator/EventNetwork/types.ts

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
export interface NetworkNode extends d3.SimulationNodeDatum {
id: string;
event?: NDKEvent;
level: number;
kind: number;
title: string;
content: string;
author: string;
type: "Index" | "Content";
naddr?: string;
nevent?: string;
x?: number;
y?: number;
isContainer?: boolean;
}
export interface NetworkLink extends d3.SimulationLinkDatum<NetworkNode> {
source: NetworkNode;
target: NetworkNode;
isSequential: boolean;
}
export interface GraphData {
nodes: NetworkNode[];
links: NetworkLink[];
}
export interface GraphState {
nodeMap: Map<string, NetworkNode>;
links: NetworkLink[];
eventMap: Map<string, NDKEvent>;
referencedIds: Set<string>;
}

136
src/lib/navigator/EventNetwork/utils/forceSimulation.ts

@ -0,0 +1,136 @@ @@ -0,0 +1,136 @@
/**
* D3 force simulation utilities for the event network
*/
import type { NetworkNode, NetworkLink } from "../types";
import type { Simulation } from "d3";
import * as d3 from "d3";
/**
* Updates a node's velocity
*/
export function updateNodeVelocity(
node: NetworkNode,
deltaVx: number,
deltaVy: number
) {
if (typeof node.vx === "number" && typeof node.vy === "number") {
node.vx = node.vx - deltaVx;
node.vy = node.vy - deltaVy;
}
}
/**
* Applies a logarithmic gravity force to a node
*/
export function applyGlobalLogGravity(
node: NetworkNode,
centerX: number,
centerY: number,
alpha: number,
) {
const dx = (node.x ?? 0) - centerX;
const dy = (node.y ?? 0) - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
const force = Math.log(distance + 1) * 0.05 * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
}
/**
* Applies gravity between connected nodes
*/
export function applyConnectedGravity(
node: NetworkNode,
links: NetworkLink[],
alpha: number,
) {
const connectedNodes = links
.filter(
(link) => link.source.id === node.id || link.target.id === node.id,
)
.map((link) => (link.source.id === node.id ? link.target : link.source));
if (connectedNodes.length === 0) return;
const cogX = d3.mean(connectedNodes, (n) => n.x);
const cogY = d3.mean(connectedNodes, (n) => n.y);
if (cogX === undefined || cogY === undefined) return;
const dx = (node.x ?? 0) - cogX;
const dy = (node.y ?? 0) - cogY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
const force = distance * 0.3 * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
}
/**
* Sets up drag behavior for nodes
*/
export function setupDragHandlers(
simulation: Simulation<NetworkNode, NetworkLink>,
warmupClickEnergy: number = 0.9
) {
return d3
.drag<SVGGElement, NetworkNode>()
.on(
"start",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
if (!event.active)
simulation.alphaTarget(warmupClickEnergy).restart();
d.fx = d.x;
d.fy = d.y;
},
)
.on(
"drag",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
d.fx = event.x;
d.fy = event.y;
},
)
.on(
"end",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
},
);
}
/**
* Creates a D3 force simulation for the network
*/
export function createSimulation(
nodes: NetworkNode[],
links: NetworkLink[],
nodeRadius: number,
linkDistance: number
) {
return d3
.forceSimulation<NetworkNode>(nodes)
.force(
"link",
d3
.forceLink<NetworkNode, NetworkLink>(links)
.id((d) => d.id)
.distance(linkDistance * 0.1),
)
.force("collide", d3.forceCollide<NetworkNode>().radius(nodeRadius * 4));
}

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

@ -0,0 +1,195 @@ @@ -0,0 +1,195 @@
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";
/**
* Creates a NetworkNode from an NDKEvent
*/
export function createNetworkNode(
event: NDKEvent,
level: number = 0
): NetworkNode {
const isContainer = event.kind === 30040;
const node: NetworkNode = {
id: event.id,
event,
isContainer,
level,
title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled",
content: event.content || "",
author: event.pubkey || "",
kind: event.kind,
type: event?.kind === 30040 ? "Index" : "Content",
};
if (event.kind && event.pubkey) {
try {
const dTag = event.getMatchingTags("d")?.[0]?.[1] || "";
node.naddr = nip19.naddrEncode({
pubkey: event.pubkey,
identifier: dTag,
kind: event.kind,
relays: standardRelays,
});
node.nevent = nip19.neventEncode({
id: event.id,
relays: standardRelays,
kind: event.kind,
});
} catch (error) {
console.warn("Failed to generate identifiers for node:", error);
}
}
return node;
}
export function createEventMap(events: NDKEvent[]): Map<string, NDKEvent> {
const eventMap = new Map<string, NDKEvent>();
events.forEach((event) => {
if (event.id) {
eventMap.set(event.id, event);
}
});
return eventMap;
}
export function extractEventIdFromATag(tag: string[]): string | null {
return tag[3] || null;
}
/**
* Generates a color for an event based on its ID
*/
export function getEventColor(eventId: string): string {
const num = parseInt(eventId.slice(0, 4), 16);
const hue = num % 360;
const saturation = 70;
const lightness = 75;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
export function initializeGraphState(events: NDKEvent[]): GraphState {
const nodeMap = new Map<string, NetworkNode>();
const eventMap = createEventMap(events);
// Create initial nodes
events.forEach((event) => {
if (!event.id) return;
const node = createNetworkNode(event);
nodeMap.set(event.id, node);
});
// Build referenced IDs set
const referencedIds = new Set<string>();
events.forEach((event) => {
event.getMatchingTags("a").forEach((tag) => {
const id = extractEventIdFromATag(tag);
if (id) referencedIds.add(id);
});
});
return {
nodeMap,
links: [],
eventMap,
referencedIds,
};
}
export function processSequence(
sequence: NetworkNode[],
indexEvent: NDKEvent,
level: number,
state: GraphState,
maxLevel: number,
): void {
if (level >= maxLevel || sequence.length === 0) return;
// Set levels for sequence nodes
sequence.forEach((node) => {
node.level = level + 1;
});
// Create initial link from index to first content
const indexNode = state.nodeMap.get(indexEvent.id);
if (indexNode && sequence[0]) {
state.links.push({
source: indexNode,
target: sequence[0],
isSequential: true,
});
}
// Create sequential links
for (let i = 0; i < sequence.length - 1; i++) {
const currentNode = sequence[i];
const nextNode = sequence[i + 1];
state.links.push({
source: currentNode,
target: nextNode,
isSequential: true,
});
processNestedIndex(currentNode, level + 1, state, maxLevel);
}
// Process final node if it's an index
const lastNode = sequence[sequence.length - 1];
if (lastNode?.isContainer) {
processNestedIndex(lastNode, level + 1, state, maxLevel);
}
}
export function processNestedIndex(
node: NetworkNode,
level: number,
state: GraphState,
maxLevel: number,
): void {
if (!node.isContainer || level >= maxLevel) return;
const nestedEvent = state.eventMap.get(node.id);
if (nestedEvent) {
processIndexEvent(nestedEvent, level, state, maxLevel);
}
}
export function processIndexEvent(
indexEvent: NDKEvent,
level: number,
state: GraphState,
maxLevel: number,
): void {
if (level >= maxLevel) return;
const sequence = indexEvent
.getMatchingTags("a")
.map((tag) => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null)
.map((id) => state.nodeMap.get(id))
.filter((node): node is NetworkNode => node !== undefined);
processSequence(sequence, indexEvent, level, state, maxLevel);
}
export function generateGraph(
events: NDKEvent[],
maxLevel: number
): GraphData {
const state = initializeGraphState(events);
// Process root indices
events
.filter((e) => e.kind === 30040 && e.id && !state.referencedIds.has(e.id))
.forEach((rootIndex) => processIndexEvent(rootIndex, 0, state, maxLevel));
return {
nodes: Array.from(state.nodeMap.values()),
links: state.links,
};
}

252
src/lib/ndk.ts

@ -1,35 +1,245 @@ @@ -1,35 +1,245 @@
import { browser } from '$app/environment';
import NDK from '@nostr-dev-kit/ndk';
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
import { writable, type Writable } from 'svelte/store';
import { standardRelays } from './consts';
export function getStoredNdkConfig() {
const relays = JSON.parse(
(browser && localStorage.getItem('alexandria_relays')) || JSON.stringify(standardRelays)
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 { feedType } from './stores';
export const ndkInstance: Writable<NDK> = writable();
export const ndkSignedIn: Writable<boolean> = writable(false);
export const activePubkey: Writable<string | null> = writable(null);
export const inboxRelays: Writable<string[]> = writable([]);
export const outboxRelays: Writable<string[]> = writable([]);
/**
* Gets the user's pubkey from local storage, if it exists.
* @returns The user's pubkey, or null if there is no logged-in user.
* @remarks Local storage is used in place of cookies to persist the user's login across browser
* sessions.
*/
export function getPersistedLogin(): string | null {
const pubkey = localStorage.getItem(loginStorageKey);
return pubkey;
}
/**
* Writes the user's pubkey to local storage.
* @param user The user to persist.
* @remarks Use this function when the user logs in. Currently, only one pubkey is stored at a
* time.
*/
export function persistLogin(user: NDKUser): void {
localStorage.setItem(loginStorageKey, user.pubkey);
}
/**
* Clears the user's pubkey from local storage.
* @remarks Use this function when the user logs out.
*/
export function clearLogin(): void {
localStorage.removeItem(loginStorageKey);
}
/**
* Constructs a key use to designate a user's relay lists in local storage.
* @param user The user for whom to construct the key.
* @param type The type of relay list to designate.
* @returns The constructed key.
*/
function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string {
return `${loginStorageKey}/${user.pubkey}/${type}`;
}
/**
* Stores the user's relay lists in local storage.
* @param user The user for whom to store the relay lists.
* @param inboxes The user's inbox relays.
* @param outboxes The user's outbox relays.
*/
function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKRelay>): void {
localStorage.setItem(
getRelayStorageKey(user, 'inbox'),
JSON.stringify(Array.from(inboxes).map(relay => relay.url))
);
localStorage.setItem(
getRelayStorageKey(user, 'outbox'),
JSON.stringify(Array.from(outboxes).map(relay => relay.url))
);
}
/**
* Retrieves the user's relay lists from local storage.
* @param user The user for whom to retrieve the relay lists.
* @returns A tuple of relay sets of the form `[inboxRelays, outboxRelays]`. Either set may be
* empty if no relay lists were stored for the user.
*/
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
const inboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'inbox')) ?? '[]')
);
const outboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'outbox')) ?? '[]')
);
// const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'alexandria-ndk-cache-db' });
return {
relays,
// dexieAdapter,
};
return [inboxes, outboxes];
}
export function getNdkInstance() {
const { relays } = getStoredNdkConfig();
export function clearPersistedRelays(user: NDKUser): void {
localStorage.removeItem(getRelayStorageKey(user, 'inbox'));
localStorage.removeItem(getRelayStorageKey(user, 'outbox'));
}
export function getActiveRelays(ndk: NDK): NDKRelaySet {
return get(feedType) === FeedType.UserRelays
? new NDKRelaySet(
new Set(get(inboxRelays).map(relay => new NDKRelay(
relay,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
))),
ndk
)
: new NDKRelaySet(
new Set(standardRelays.map(relay => new NDKRelay(
relay,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
))),
ndk
);
}
/**
* Initializes an instance of NDK, and connects it to the logged-in user's preferred relay set
* (if available), or to Alexandria's standard relay set.
* @returns The initialized NDK instance.
*/
export function initNdk(): NDK {
const startingPubkey = getPersistedLogin();
const [startingInboxes, _] = startingPubkey != null
? getPersistedRelays(new NDKUser({ pubkey: startingPubkey }))
: [null, null];
const ndk = new NDK({
autoConnectUserRelays: true,
enableOutboxModel: true,
explicitRelayUrls: relays,
explicitRelayUrls: startingInboxes != null
? Array.from(startingInboxes.values())
: standardRelays,
});
ndk.connect().then(() => console.debug('ndk connected'));
// TODO: Should we prompt the user to confirm authentication?
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk });
ndk.connect().then(() => console.debug("ndk connected"));
return ndk;
}
export const ndk: Writable<NDK> = writable(getNdkInstance());
/**
* Signs in with a NIP-07 browser extension, and determines the user's preferred inbox and outbox
* relays.
* @returns The user's profile, if it is available.
* @throws If sign-in fails. This may because there is no accessible NIP-07 extension, or because
* NDK is unable to fetch the user's profile or relay lists.
*/
export async function loginWithExtension(pubkey?: string): Promise<NDKUser | null> {
try {
const ndk = get(ndkInstance);
const signer = new NDKNip07Signer();
const signerUser = await signer.user();
// TODO: Handle changing pubkeys.
if (pubkey && signerUser.pubkey !== pubkey) {
console.debug('Switching pubkeys from last login.');
}
activePubkey.set(signerUser.pubkey);
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(signerUser);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
const user = ndk.getUser({ pubkey: signerUser.pubkey });
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
inboxRelays.set(Array.from(inboxes ?? persistedInboxes).map(relay => relay.url));
outboxRelays.set(Array.from(outboxes ?? persistedOutboxes).map(relay => relay.url));
persistRelays(signerUser, inboxes, outboxes);
ndk.signer = signer;
ndk.activeUser = user;
ndkInstance.set(ndk);
ndkSignedIn.set(true);
export const signedIn: Writable<boolean> = writable(false);
return user;
} catch (e) {
throw new Error(`Failed to sign in with NIP-07 extension: ${e}`);
}
}
/**
* Handles logging out a user.
* @param user The user to log out.
*/
export function logout(user: NDKUser): void {
clearLogin();
clearPersistedRelays(user);
activePubkey.set(null);
ndkSignedIn.set(false);
}
/**
* Fetches the user's NIP-65 relay list, if one can be found, and returns the inbox and outbox
* relay sets.
* @returns A tuple of relay sets of the form `[inboxRelays, outboxRelays]`.
*/
async function getUserPreferredRelays(
ndk: NDK,
user: NDKUser,
bootstraps: readonly string[] = bootstrapRelays
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent(
{
kinds: [10002],
authors: [user.pubkey],
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
NDKRelaySet.fromRelayUrls(bootstraps, ndk),
);
const inboxRelays = new Set<NDKRelay>();
const outboxRelays = new Set<NDKRelay>();
if (relayList == null) {
const relayMap = await window.nostr?.getRelays?.();
Object.entries(relayMap ?? {}).forEach(([url, relayType]) => {
const relay = new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk);
if (relayType.read) inboxRelays.add(relay);
if (relayType.write) outboxRelays.add(relay);
});
} else {
relayList.tags.forEach(tag => {
switch (tag[0]) {
case 'r':
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
break;
case 'w':
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
break;
default:
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
break;
}
});
}
return [inboxRelays, outboxRelays];
}

3
src/lib/parser.ts

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
import NDK, { NDKEvent } from '@nostr-dev-kit/ndk';
import { getNdkInstance } from './ndk';
import asciidoctor from 'asciidoctor';
import type {
AbstractBlock,
@ -13,7 +12,7 @@ import type { @@ -13,7 +12,7 @@ import type {
} from 'asciidoctor';
import he from 'he';
import { writable, type Writable } from 'svelte/store';
import { indexKind, zettelKinds } from './consts';
import { zettelKinds } from './consts';
interface IndexMetadata {
authors?: string[];

14
src/lib/state.ts

@ -1,13 +1,15 @@ @@ -1,13 +1,15 @@
import { browser } from '$app/environment';
import { writable, type Writable } from 'svelte/store';
import type { Tab } from './types';
import { browser } from "$app/environment";
import { writable, type Writable } from "svelte/store";
import type { Tab } from "./types";
export const pathLoaded: Writable<boolean> = writable(false);
export const tabs: Writable<Tab[]> = writable([{ id: 0, type: 'welcome' }]);
export const tabs: Writable<Tab[]> = writable([{ id: 0, type: "welcome" }]);
export const tabBehaviour: Writable<string> = writable(
(browser && localStorage.getItem('wikinostr_tabBehaviour')) || 'normal'
(browser && localStorage.getItem("wikinostr_tabBehaviour")) || "normal",
);
export const userPublickey: Writable<string> = writable(
(browser && localStorage.getItem('wikinostr_loggedInPublicKey')) || ''
(browser && localStorage.getItem("wikinostr_loggedInPublicKey")) || "",
);
export const networkFetchLimit: Writable<number> = writable(5);
export const levelsToRender: Writable<number> = writable(3);

2
src/lib/stores.ts

@ -5,4 +5,4 @@ export let idList = writable<string[]>([]); @@ -5,4 +5,4 @@ export let idList = writable<string[]>([]);
export let alexandriaKinds = readable<number[]>([30040, 30041]);
export let feedType = writable<FeedType>(FeedType.Relays);
export let feedType = writable<FeedType>(FeedType.StandardRelays);

5
src/lib/utils.ts

@ -97,7 +97,10 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> { @@ -97,7 +97,10 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
(event.content != null && event.content.length > 0)
|| event.getMatchingTags('title').length === 0
|| event.getMatchingTags('d').length === 0
|| event.getMatchingTags('e').length === 0
|| (
event.getMatchingTags('a').length === 0
&& event.getMatchingTags('e').length === 0
)
) {
events.delete(event);
}

36
src/routes/+layout.ts

@ -1,17 +1,33 @@ @@ -1,17 +1,33 @@
import NDK from "@nostr-dev-kit/ndk";
import type { LayoutLoad } from "./$types";
import { standardRelays } from "$lib/consts";
import Pharos, { pharosInstance } from "$lib/parser";
import { feedTypeStorageKey } from '$lib/consts';
import { FeedType } from '$lib/consts';
import { getPersistedLogin, initNdk, loginWithExtension, ndkInstance } from '$lib/ndk';
import Pharos, { pharosInstance } from '$lib/parser';
import { feedType } from '$lib/stores';
import type { LayoutLoad } from './$types';
export const ssr = false;
export const load: LayoutLoad = () => {
const ndk = new NDK({
autoConnectUserRelays: true,
enableOutboxModel: true,
explicitRelayUrls: standardRelays,
});
ndk.connect().then(() => console.debug("ndk connected"));
const initialFeedType = localStorage.getItem(feedTypeStorageKey) as FeedType
?? FeedType.StandardRelays;
feedType.set(initialFeedType);
const ndk = initNdk();
ndkInstance.set(ndk);
try {
// Michael J - 18 Jan 2025 - This will not work server-side, since the NIP-07 extension is only
// available in the browser, and the flags for persistent login are saved in the browser's
// local storage. If SSR is ever enabled, move this code block to run client-side.
const pubkey = getPersistedLogin();
if (pubkey) {
// Michael J - 27 Jan 2025 - We don't await this call; it will run in the background and
// update Svelte stores to propagate data.
loginWithExtension(pubkey);
}
} catch (e) {
console.warn(`Failed to login with extension: ${e}\n\nContinuing with anonymous session.`);
}
const parser = new Pharos(ndk);
pharosInstance.set(parser);

169
src/routes/+page.svelte

@ -1,155 +1,48 @@ @@ -1,155 +1,48 @@
<script lang='ts'>
import ArticleHeader from '$lib/components/ArticleHeader.svelte';
import { FeedType, indexKind, standardRelays } from '$lib/consts';
import { filterValidIndexEvents } from '$lib/utils';
import NDK, { NDKEvent, NDKRelaySet, type NDKUser } from '@nostr-dev-kit/ndk';
import { Button, Dropdown, Radio, Skeleton } from 'flowbite-svelte';
import { FeedType, feedTypeStorageKey, standardRelays } from '$lib/consts';
import { Button, Dropdown, Radio } from 'flowbite-svelte';
import { ChevronDownOutline } from 'flowbite-svelte-icons';
import type { PageData } from './$types';
import { setContext } from 'svelte';
let { data }: { data: PageData } = $props();
let ndk: NDK = data.ndk;
let user: NDKUser | null | undefined = $state(ndk.activeUser);
let readRelays: string[] | null | undefined = $state(user?.relayUrls);
let userFollows: Set<NDKUser> | null | undefined = $state(null);
let feedType: FeedType = $state(FeedType.Relays);
import { inboxRelays, ndkSignedIn } from '$lib/ndk';
import PublicationFeed from '$lib/components/PublicationFeed.svelte';
import { feedType } from '$lib/stores';
$effect(() => {
if (user) {
user.follows().then(follows => userFollows = follows);
}
localStorage.setItem(feedTypeStorageKey, $feedType);
});
const getEvents = (): Promise<Set<NDKEvent>> =>
// @ts-ignore
ndk.fetchEvents(
{ kinds: [indexKind] },
{
groupable: true,
skipVerification: false,
skipValidation: false
},
NDKRelaySet.fromRelayUrls(standardRelays, ndk)
).then(filterValidIndexEvents);
const getEventsFromUserRelays = (userRelays: string[]): Promise<Set<NDKEvent>> => {
return ndk
.fetchEvents(
// @ts-ignore
{ kinds: [indexKind] },
{
closeOnEose: true,
groupable: true,
skipVerification: false,
skipValidation: false,
},
)
.then(filterValidIndexEvents);
}
const getEventsFromUserFollows = (follows: Set<NDKUser>, userRelays?: string[]): Promise<Set<NDKEvent>> => {
return ndk
.fetchEvents(
{
authors: Array.from(follows ?? []).map(user => user.pubkey),
// @ts-ignore
kinds: [indexKind]
},
{
groupable: true,
skipVerification: false,
skipValidation: false
},
)
.then(filterValidIndexEvents);
}
const getFeedTypeFriendlyName = (feedType: FeedType): string => {
switch (feedType) {
case FeedType.Relays:
return 'Relays';
case FeedType.Follows:
return 'Follows';
case FeedType.StandardRelays:
return `Alexandria's Relays`;
case FeedType.UserRelays:
return `Your Relays`;
default:
return '';
}
};
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}`);
}
return skeletonIds;
}
</script>
<div class='leather flex flex-col flex-grow-0 space-y-4 overflow-y-auto w-max p-2'>
{#key user}
{#if user == null || readRelays == null}
{#await getEvents()}
{#each getSkeletonIds() as id}
<Skeleton size='lg' />
{/each}
{:then events}
{#if events.size > 0}
{#each Array.from(events) as event}
<ArticleHeader {event} />
{/each}
{:else}
<p class='text-center'>No articles found.</p>
{/if}
{/await}
{:else}
<div class='leather w-full flex justify-end'>
<Button>
{`Showing articles from: ${getFeedTypeFriendlyName(feedType)}`}<ChevronDownOutline class='w-6 h-6' />
</Button>
<Dropdown class='w-fit p-2 space-y-2 text-sm'>
<li>
<Radio name='relays' bind:group={feedType} value={FeedType.Relays}>Relays</Radio>
</li>
<li>
<Radio name='follows' bind:group={feedType} value={FeedType.Follows}>Follows</Radio>
</li>
</Dropdown>
</div>
{#if feedType === FeedType.Relays && readRelays != null}
{#await getEventsFromUserRelays(readRelays)}
{#each getSkeletonIds() as id}
<Skeleton size='lg' />
{/each}
{:then events}
{#if events.size > 0}
{#each Array.from(events) as event}
<ArticleHeader {event} />
{/each}
{:else}
<p class='text-center'>No articles found.</p>
{/if}
{/await}
{:else if feedType === FeedType.Follows && userFollows != null}
{#await getEventsFromUserFollows(userFollows, readRelays)}
{#each getSkeletonIds() as id}
<Skeleton size='lg' />
{/each}
{:then events}
{#if events.size > 0}
{#each Array.from(events) as event}
<ArticleHeader {event} />
{/each}
{:else}
<p class='text-center'>No articles found.</p>
{/if}
{/await}
{/if}
{#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} />
{:else}
<div class='leather w-full flex justify-end'>
<Button>
{`Showing articles from: ${getFeedTypeFriendlyName($feedType)}`}<ChevronDownOutline class='w-6 h-6' />
</Button>
<Dropdown class='w-fit p-2 space-y-2 text-sm'>
<li>
<Radio name='relays' bind:group={$feedType} value={FeedType.StandardRelays}>Alexandria's Relays</Radio>
</li>
<li>
<Radio name='follows' bind:group={$feedType} value={FeedType.UserRelays}>Your Relays</Radio>
</li>
</Dropdown>
</div>
{#if $feedType === FeedType.StandardRelays}
<PublicationFeed relays={standardRelays} />
{:else if $feedType === FeedType.UserRelays}
<PublicationFeed relays={$inboxRelays} />
{/if}
{/key}
{/if}
</div>

6
src/routes/about/+page.svelte

@ -7,9 +7,9 @@ @@ -7,9 +7,9 @@
<div class='w-full flex justify-center'>
<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'>About</Heading>
<p>Alexandria is an editor and generator for <a href="https://github.com/nostr-protocol/nips/pull/1600" class="text-indigo-600 underline">curated publications</a> and will soon also be a reader for long-form articles and wiki pages.
It is produced by the <a href="https://wikistr.com/gitcitadel-project" class="text-indigo-600 underline">GitCitadel project team</a>.</p>
<p>Alexandria is an editor and generator for <a href="https://github.com/nostr-protocol/nips/pull/1600" class='note-leather'>curated publications</a> and will soon also be a reader for long-form articles and wiki pages.
It is produced by the <a href="https://wikistr.com/gitcitadel-project" class='note-leather'>GitCitadel project team</a>.</p>
<p>Please submit support issues on the <a href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5emfw33kjarpv3jkcs83wav" class="text-indigo-600 underline">project repo page</a> and follow us on <a href="https://github.com/ShadowySupercode/gitcitadel" class="text-indigo-600 underline">GitHub</a> and <a href="https://geyser.fund/project/gitcitadel" class="text-indigo-600 underline">Geyserfund</a>.</p>
<p>Please submit support issues on the <a href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5emfw33kjarpv3jkcs83wav" class='note-leather'>project repo page</a> and follow us on <a href="https://github.com/ShadowySupercode/gitcitadel" class='note-leather'>GitHub</a> and <a href="https://geyser.fund/project/gitcitadel" class='note-leather'>Geyserfund</a>.</p>
</main>
</div>

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

@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
import { CodeOutline, EyeSolid, PaperPlaneOutline } from "flowbite-svelte-icons";
import Preview from "$lib/components/Preview.svelte";
import Pharos, { pharosInstance } from "$lib/parser";
import { ndk } from "$lib/ndk";
import { ndkInstance } from "$lib/ndk";
import { goto } from "$app/navigation";
// TODO: Prompt user to sign in before editing.
@ -15,7 +15,7 @@ @@ -15,7 +15,7 @@
const showPreview = () => {
try {
$pharosInstance ??= new Pharos($ndk);
$pharosInstance ??= new Pharos($ndkInstance);
$pharosInstance.reset();
$pharosInstance.parse(editorText);
} catch (e) {
@ -42,7 +42,7 @@ @@ -42,7 +42,7 @@
return;
}
$pharosInstance.generate($ndk.activeUser?.pubkey!);
$pharosInstance.generate($ndkInstance.activeUser?.pubkey!);
goto('/new/compose');
}
</script>

4
src/routes/publication/+page.svelte

@ -11,8 +11,8 @@ @@ -11,8 +11,8 @@
<main>
{#await data.waitable}
<TextPlaceholder size="xxl" />
<TextPlaceholder divClass='skeleton-leather w-full' size="xxl" />
{:then}
<Article rootId={data.parser.getRootIndexId()} />
<Article rootId={data.parser.getRootIndexId()} publicationType={data.publicationType} />
{/await}
</main>

26
src/routes/publication/+page.ts

@ -1,7 +1,9 @@ @@ -1,7 +1,9 @@
import { error } from '@sveltejs/kit';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { NDKRelay, NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk';
import type { PageLoad } from './$types';
import { pharosInstance } from '$lib/parser';
import { get } from 'svelte/store';
import { getActiveRelays, inboxRelays, ndkInstance } from '$lib/ndk';
import { standardRelays } from '$lib/consts';
export const load: PageLoad = async ({ url, parent }) => {
const id = url.searchParams.get('id');
@ -10,7 +12,6 @@ export const load: PageLoad = async ({ url, parent }) => { @@ -10,7 +12,6 @@ export const load: PageLoad = async ({ url, parent }) => {
const { ndk, parser } = await parent();
let eventPromise: Promise<NDKEvent | null>;
let indexEvent: NDKEvent | null;
if (id) {
@ -22,21 +23,26 @@ export const load: PageLoad = async ({ url, parent }) => { @@ -22,21 +23,26 @@ export const load: PageLoad = async ({ url, parent }) => {
error(404, `Failed to fetch publication root event for ID: ${id}\n${err}`);
});
} else if (dTag) {
eventPromise = ndk.fetchEvent({ '#d': [dTag] })
.then((ev: NDKEvent | null) => {
return ev;
})
.catch((err: any) => {
error(404, `Failed to fetch publication root event for d tag: ${dTag}\n${err}`);
});
eventPromise = new Promise<NDKEvent | null>(resolve => {
ndk
.fetchEvent({ '#d': [dTag] }, { closeOnEose: false }, getActiveRelays(ndk))
.then((event: NDKEvent | null) => {
resolve(event);
})
.catch((err: any) => {
error(404, `Failed to fetch publication root event for d tag: ${dTag}\n${err}`);
});
});
} else {
error(400, 'No publication root event ID or d tag provided.');
}
indexEvent = await eventPromise as NDKEvent;
const publicationType = indexEvent?.getMatchingTags('type')[0]?.[1];
const fetchPromise = parser.fetch(indexEvent);
return {
waitable: fetchPromise,
publicationType,
};
};

74
src/routes/visualize/+page.svelte

@ -1,13 +1,23 @@ @@ -1,13 +1,23 @@
<script lang="ts">
import { onMount } from "svelte";
import EventNetwork from "$lib/components/EventNetwork.svelte";
import { ndk } from "$lib/ndk";
import EventNetwork from "$lib/navigator/EventNetwork/index.svelte";
import { ndkInstance } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { filterValidIndexEvents } from "$lib/utils";
import EventLimitControl from "$lib/components/EventLimitControl.svelte";
import EventRenderLevelLimit from "$lib/components/EventRenderLevelLimit.svelte";
import { networkFetchLimit } from "$lib/state";
import { fly } from "svelte/transition";
import { quintOut } from "svelte/easing";
import { CogSolid } from "flowbite-svelte-icons";
import { Button, Tooltip } from "flowbite-svelte";
let events: NDKEvent[] = [];
let loading = true;
let error: string | null = null;
// panel visibility
let showSettings = false;
async function fetchEvents() {
try {
@ -15,8 +25,8 @@ @@ -15,8 +25,8 @@
error = null;
// Fetch both index and content events
const indexEvents = await $ndk.fetchEvents(
{ kinds: [30040] },
const indexEvents = await $ndkInstance.fetchEvents(
{ kinds: [30040], limit: $networkFetchLimit },
{
groupable: true,
skipVerification: false,
@ -30,13 +40,14 @@ @@ -30,13 +40,14 @@
// Get all the content event IDs referenced by the index events
const contentEventIds = new Set<string>();
validIndexEvents.forEach((event) => {
event.getMatchingTags("e").forEach((tag) => {
contentEventIds.add(tag[1]);
event.getMatchingTags("a").forEach((tag) => {
let eventId = tag[3];
contentEventIds.add(eventId);
});
});
// Fetch the referenced content events
const contentEvents = await $ndk.fetchEvents(
const contentEvents = await $ndkInstance.fetchEvents(
{
kinds: [30041],
ids: Array.from(contentEventIds),
@ -52,20 +63,60 @@ @@ -52,20 +63,60 @@
events = [...Array.from(validIndexEvents), ...Array.from(contentEvents)];
} catch (e) {
console.error("Error fetching events:", e);
error = e.message;
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
function handleLimitUpdate() {
fetchEvents();
}
onMount(() => {
fetchEvents();
});
</script>
<div class="leather w-full p-4">
<div class="leather w-full p-4 relative">
<h1 class="h-leather text-2xl font-bold mb-4">Publication Network</h1>
<!-- Settings Toggle Button -->
<!-- Settings Button - Using Flowbite Components -->
{#if !loading && !error}
<Button
class="btn-leather fixed right-4 top-24 z-40 rounded-lg min-w-[150px]"
size="sm"
on:click={() => (showSettings = !showSettings)}
>
<CogSolid class="mr-2 h-5 w-5" />
Settings
</Button>
<!-- Settings Panel -->
{#if showSettings}
<div
class="fixed right-0 top-[140px] h-auto w-80 bg-white dark:bg-gray-800 p-4 shadow-lg z-30
overflow-y-auto max-h-[calc(100vh-96px)] rounded-l-lg border-l border-t border-b
border-gray-200 dark:border-gray-700"
transition:fly={{ duration: 300, x: 320, opacity: 1, easing: quintOut }}
>
<div class="card space-y-4">
<h2 class="text-xl font-bold mb-4 h-leather">
Visualization Settings
</h2>
<div class="space-y-4">
<span class="text-sm text-gray-600 dark:text-gray-400">
Showing {events.length} events from {$networkFetchLimit} headers
</span>
<EventLimitControl on:update={handleLimitUpdate} />
<EventRenderLevelLimit on:update={handleLimitUpdate} />
</div>
</div>
</div>
{/if}
{/if}
{#if loading}
<div class="flex justify-center items-center h-64">
<div role="status">
@ -104,7 +155,6 @@ @@ -104,7 +155,6 @@
</div>
{:else}
<EventNetwork {events} />
<div class="mt-8 prose dark:prose-invert max-w-none"></div>
<div class="mt-8 prose dark:prose-invert max-w-none" />
{/if}
</div>
</div>
Loading…
Cancel
Save