Browse Source

Merge master into feature/text-entry

Merges 38 commits from master including:
- New Alexandria component library (src/lib/a/)
- UI updates and styling improvements
- Route reorganization (my-notes → profile/my-notes)
- Tailwind 4 migration
- New nostr utilities and stores

Conflict Resolutions:
- package.json: Merged dependencies (kept CodeMirror + added Lucide/CVA)
- package-lock.json: Regenerated after dependency merge
- asciidoc_metadata.ts: Kept feature branch's deeper header processing logic
- ZettelEditor.svelte: Kept feature branch's CodeMirror implementation
- compose/+page.svelte: Kept feature branch (removed orphaned publish button)
- my-notes/+page.svelte: Accepted master's location and .ts extensions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
master
limina1 5 months ago
parent
commit
7afbffb9b5
  1. 1450
      deno.lock
  2. 4
      import_map.json
  3. 3082
      package-lock.json
  4. 18
      package.json
  5. 7
      postcss.config.js
  6. 537
      src/app.css
  7. 24
      src/app.html
  8. 1026
      src/lib/a/AGENTS.md
  9. 156
      src/lib/a/README.md
  10. 290
      src/lib/a/cards/AEventPreview.svelte
  11. 449
      src/lib/a/cards/AProfilePreview.svelte
  12. 140
      src/lib/a/forms/ACommentForm.svelte
  13. 170
      src/lib/a/forms/AMarkupForm.svelte
  14. 137
      src/lib/a/forms/ASearchForm.svelte
  15. 288
      src/lib/a/forms/ATextareaWithPreview.svelte
  16. 29
      src/lib/a/index.ts
  17. 64
      src/lib/a/nav/AFooter.svelte
  18. 148
      src/lib/a/nav/ANavbar.svelte
  19. 370
      src/lib/a/parse-components.js
  20. 70
      src/lib/a/primitives/AAlert.svelte
  21. 103
      src/lib/a/primitives/ADetails.svelte
  22. 58
      src/lib/a/primitives/AInput.svelte
  23. 79
      src/lib/a/primitives/ANostrBadge.svelte
  24. 66
      src/lib/a/primitives/ANostrBadgeRow.svelte
  25. 218
      src/lib/a/primitives/ANostrUser.svelte
  26. 131
      src/lib/a/primitives/APagination.svelte
  27. 77
      src/lib/a/primitives/AThemeToggleMini.svelte
  28. 64
      src/lib/a/reader/ATechBlock.svelte
  29. 36
      src/lib/a/reader/ATechToggle.svelte
  30. 229
      src/lib/components/CommentBox.svelte
  31. 2
      src/lib/components/CommentViewer.svelte
  32. 361
      src/lib/components/EventDetails.svelte
  33. 4
      src/lib/components/EventRenderLevelLimit.svelte
  34. 15
      src/lib/components/EventSearch.svelte
  35. 246
      src/lib/components/Notifications.svelte
  36. 33
      src/lib/components/RelayStatus.svelte
  37. 40
      src/lib/components/cards/ProfileHeader.svelte
  38. 107
      src/lib/components/event_input/eventServices.ts
  39. 2
      src/lib/components/event_input/types.ts
  40. 37
      src/lib/components/event_input/validation.ts
  41. 329
      src/lib/components/publications/Publication.svelte
  42. 2
      src/lib/components/publications/PublicationFeed.svelte
  43. 66
      src/lib/components/publications/PublicationHeader.svelte
  44. 9
      src/lib/components/publications/TableOfContents.svelte
  45. 12
      src/lib/components/util/ArticleNav.svelte
  46. 4
      src/lib/components/util/CardActions.svelte
  47. 10
      src/lib/components/util/CopyToClipboard.svelte
  48. 279
      src/lib/components/util/Profile.svelte
  49. 80
      src/lib/data_structures/publication_tree.ts
  50. 2
      src/lib/navigator/EventNetwork/Legend.svelte
  51. 9
      src/lib/nostr/event.ts
  52. 22
      src/lib/nostr/format.ts
  53. 20
      src/lib/nostr/nip05.ts
  54. 147
      src/lib/nostr/nip58.ts
  55. 9
      src/lib/nostr/types.ts
  56. 10
      src/lib/snippets/UserSnippets.svelte
  57. 34
      src/lib/stores/techStore.ts
  58. 18
      src/lib/stores/themeStore.ts
  59. 7
      src/lib/stores/userStore.ts
  60. 3
      src/lib/styles/cva.ts
  61. 48
      src/lib/utils/cache_manager.ts
  62. 53
      src/lib/utils/event_input_utils.ts
  63. 5
      src/lib/utils/event_search.ts
  64. 2
      src/lib/utils/image_utils.ts
  65. 48
      src/lib/utils/markup/advancedMarkupParser.ts
  66. 14
      src/lib/utils/markup/basicMarkupParser.ts
  67. 5
      src/lib/utils/markup/embeddedMarkupParser.ts
  68. 75
      src/lib/utils/markup/markupUtils.ts
  69. 22
      src/lib/utils/nostrUtils.ts
  70. 99
      src/lib/utils/npubCache.ts
  71. 53
      src/lib/utils/profile_search.ts
  72. 366
      src/lib/utils/subscription_search.ts
  73. 113
      src/lib/utils/user_lists.ts
  74. 26
      src/routes/+layout.svelte
  75. 4
      src/routes/+layout.ts
  76. 10
      src/routes/+page.svelte
  77. 4
      src/routes/[...catchall]/+page.svelte
  78. 15
      src/routes/about/+page.svelte
  79. 7
      src/routes/about/relay-stats/+page.svelte
  80. 405
      src/routes/contact/+page.svelte
  81. 92
      src/routes/events/+page.svelte
  82. 67
      src/routes/events/compose/+page.svelte
  83. 21
      src/routes/new/compose/+page.svelte
  84. 113
      src/routes/profile/+page.svelte
  85. 39
      src/routes/profile/my-notes/+page.svelte
  86. 13
      src/routes/profile/notifications/+page.svelte
  87. 1
      src/routes/publication/[type]/[identifier]/+layout.server.ts
  88. 11
      src/routes/publication/[type]/[identifier]/+page.svelte
  89. 3
      src/routes/publication/[type]/[identifier]/+page.ts
  90. 10
      src/routes/start/+page.svelte
  91. 252
      src/styles/a/cards.css
  92. 5
      src/styles/a/forms.css
  93. 5
      src/styles/a/primitives.css
  94. 2
      src/styles/base.css
  95. 10
      src/styles/publications.css
  96. 64
      src/theme-tokens.css
  97. 123
      tailwind.config.cjs
  98. 111
      tests/unit/mathProcessing.test.ts
  99. 8
      tests/unit/tagExpansion.test.ts
  100. 2
      vite.config.ts

1450
deno.lock

File diff suppressed because it is too large Load Diff

4
import_map.json

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
"@nostr-dev-kit/ndk-cache-dexie": "npm:@nostr-dev-kit/ndk-cache-dexie@2.6.x",
"@popperjs/core": "npm:@popperjs/core@2.11.x",
"@tailwindcss/forms": "npm:@tailwindcss/forms@0.5.x",
"@tailwindcss/postcss": "npm:@tailwindcss/postcss@^4.1.11",
"@tailwindcss/typography": "npm:@tailwindcss/typography@0.5.x",
"asciidoctor": "npm:asciidoctor@3.0.x",
"d3": "npm:d3@^7.9.0",
@ -25,6 +26,7 @@ @@ -25,6 +26,7 @@
"plantuml-encoder": "npm:plantuml-encoder@^1.4.0",
"qrcode": "npm:qrcode@^1.5.4",
"child_process": "node:child_process",
"process": "node:process"
"process": "node:process",
"tailwindcss": "npm:tailwindcss@^4.1.11"
}
}

3082
package-lock.json generated

File diff suppressed because it is too large Load Diff

18
package.json

@ -13,7 +13,8 @@ @@ -13,7 +13,8 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"test": "vitest"
"test": "vitest",
"tokens": "node src/lib/theme/build-tokens.mjs"
},
"dependencies": {
"@codemirror/basic-setup": "^0.20.0",
@ -21,15 +22,18 @@ @@ -21,15 +22,18 @@
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.1",
"@lucide/svelte": "^0.539.0",
"@noble/curves": "^1.9.4",
"@noble/hashes": "^1.8.0",
"@nostr-dev-kit/ndk": "^2.14.32",
"@nostr-dev-kit/ndk-cache-dexie": "2.6.x",
"@popperjs/core": "2.11.x",
"@tailwindcss/forms": "0.5.x",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "0.5.x",
"asciidoctor": "3.0.x",
"bech32": "^2.0.0",
"class-variance-authority": "^0.7.1",
"codemirror": "^6.0.2",
"d3": "^7.9.0",
"he": "1.2.x",
@ -37,7 +41,8 @@ @@ -37,7 +41,8 @@
"node-emoji": "^2.2.0",
"nostr-tools": "2.15.x",
"plantuml-encoder": "^1.4.0",
"qrcode": "^1.5.4"
"qrcode": "^1.5.4",
"tailwindcss": "^4.1.11"
},
"devDependencies": {
"@playwright/test": "^1.54.1",
@ -53,9 +58,10 @@ @@ -53,9 +58,10 @@
"@types/qrcode": "^1.5.5",
"autoprefixer": "^10.4.21",
"eslint-plugin-svelte": "^3.11.0",
"flowbite": "2.x",
"flowbite-svelte": "0.48.x",
"flowbite": "~2.5.2",
"flowbite-svelte": "1.11.x",
"flowbite-svelte-icons": "2.1.x",
"flowbite-typography": "^1.0.5",
"playwright": "^1.50.1",
"postcss": "^8.5.6",
"postcss-load-config": "6.x",
@ -64,10 +70,10 @@ @@ -64,10 +70,10 @@
"svelte": "^5.36.8",
"svelte-check": "4.x",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^3.4.17",
"tslib": "2.8.x",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.1.3"
"vitest": "^3.1.3",
"yaml": "^2.5.0"
}
}

7
postcss.config.js

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
import tailwindcss from "tailwindcss";
import autoprefixer from "autoprefixer";
export default {
plugins: [tailwindcss(), autoprefixer()],
plugins: {
"@tailwindcss/postcss": {},
},
};

537
src/app.css

@ -1,13 +1,228 @@ @@ -1,13 +1,228 @@
@import "tailwindcss";
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css" layer(utilities);
/* then your own imports and layers */
@import "./styles/base.css";
@import "./styles/scrollbar.css";
@import "./styles/publications.css";
@import "./styles/visualize.css";
@import "./styles/asciidoc.css";
@import "theme-tokens.css";
@import "./styles/a/cards.css";
@import "./styles/a/forms.css";
@import "./styles/a/primitives.css";
@layer theme, base, components, utilities;
@plugin "flowbite/plugin";
@plugin "flowbite-typography";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* single color */
--color-highlight: #f9f6f1;
--color-border: var(--color-highlight);
--color-text-muted: var(--color-text-muted);
/* success */
--color-success-50: #e3f2e7;
--color-success-100: #c7e6cf;
--color-success-200: #a2d4ae;
--color-success-300: #7dbf8e;
--color-success-400: #5ea571;
--color-success-500: #4e8e5f;
--color-success-600: #3e744c;
--color-success-700: #305b3b;
--color-success-800: #22412a;
--color-success-900: #15281b;
/* info */
--color-info-50: #e7eff6;
--color-info-100: #c5d9ea;
--color-info-200: #9fbfdb;
--color-info-300: #7aa5cc;
--color-info-400: #5e90be;
--color-info-500: #4779a5;
--color-info-600: #365d80;
--color-info-700: #27445d;
--color-info-800: #192b3a;
--color-info-900: #0d161f;
/* warning */
--color-warning-50: #fef4e6;
--color-warning-100: #fde4bf;
--color-warning-200: #fcd18e;
--color-warning-300: #fbbc5c;
--color-warning-400: #f9aa33;
--color-warning-500: #f7971b;
--color-warning-600: #c97a14;
--color-warning-700: #9a5c0e;
--color-warning-800: #6c3e08;
--color-warning-900: #3e2404;
/* danger */
--color-danger-50: #fbeaea;
--color-danger-100: #f5cccc;
--color-danger-200: #eba5a5;
--color-danger-300: #e17e7e;
--color-danger-400: #d96060;
--color-danger-500: #c94848;
--color-danger-600: #a53939;
--color-danger-700: #7c2b2b;
--color-danger-800: #521c1c;
--color-danger-900: #2b0e0e;
}
/* Map Tailwind utilities → theme tokens (PRIMARY ONLY) */
@theme inline {
--color-primary-0: var(--brand-primary-0);
--color-primary-50: var(--brand-primary-50);
--color-primary-100: var(--brand-primary-100);
--color-primary-200: var(--brand-primary-200);
--color-primary-300: var(--brand-primary-300);
--color-primary-400: var(--brand-primary-400);
--color-primary-500: var(--brand-primary-500);
--color-primary-600: var(--brand-primary-600);
--color-primary-700: var(--brand-primary-700);
--color-primary-800: var(--brand-primary-800);
--color-primary-900: var(--brand-primary-900);
--color-primary-950: var(--brand-primary-950);
--color-primary-1000: var(--brand-primary-1000);
}
@source "../node_modules/flowbite-svelte/dist";
@source "../node_modules/flowbite-svelte-icons/dist";
/* @utility and @layer rules… */
/* .content-visibility-auto */
@utility content-visibility-auto {
content-visibility: auto;
}
/* .contain-size */
@utility contain-size {
contain: size;
}
/* numbers -> px (e.g. contain-intrinsic-w-[320] => width: 320px) */
@utility contain-intrinsic-w-* {
--tw-ciw: --value(number);
width: calc(var(--tw-ciw) * 1px);
}
@utility contain-intrinsic-h-* {
--tw-cih: --value(number);
height: calc(var(--tw-cih) * 1px);
}
/* percentages (e.g. contain-intrinsic-wp-[65%] => width: 65%) */
@utility contain-intrinsic-wp-* {
width: --value(percentage);
}
@utility contain-intrinsic-hp-* {
height: --value(percentage);
}
/* list-upper-alpha, list-lower-alpha (keep your old class names)
Note: in v4 you can also write list-[upper-alpha] / list-[lower-alpha] inline. */
@utility list-upper-alpha {
list-style-type: upper-alpha;
}
@utility list-lower-alpha {
list-style-type: lower-alpha;
}
/* flexGrow 1/2/3 — unlock grow-2, grow-3 (and any number via brackets) */
@utility grow-* {
flex-grow: --value(integer);
}
/* Hue rotate: use arbitrary values directly, e.g. hue-rotate-[20deg] (no config needed). */
/* --- Let Tailwind scan Flowbite Svelte (node_modules are ignored by default) --- */
@source "../node_modules/flowbite-svelte/dist";
@source "../node_modules/flowbite-svelte-icons/dist";
/* Custom styles */
@layer base {
.leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100;
/* disable chrome cancel button */
input[type="search"]::-webkit-search-cancel-button {
display: none;
}
/* === HEADINGS === */
/* Base heading styles - gray-900 (light) / gray-100 (dark) for high contrast */
h1,
h2,
h3,
h4,
h5,
h6,
h1.h-leather,
h2.h-leather,
h3.h-leather,
h4.h-leather,
h5.h-leather,
h6.h-leather {
@apply text-gray-900 dark:text-gray-100;
}
/* Heading sizes and weights */
h1,
h1.h-leather {
@apply text-4xl font-bold;
}
h2,
h2.h-leather {
@apply text-3xl font-bold;
}
h3,
h3.h-leather {
@apply text-2xl font-bold;
}
h4,
h4.h-leather {
@apply text-xl font-bold;
}
h5,
h5.h-leather {
@apply text-lg font-semibold;
}
h6,
h6.h-leather {
@apply text-base font-semibold;
}
/* Heading links - primary-600 (light) / primary-400 (dark) for hover */
h1 a,
h2 a,
h3 a,
h4 a,
h5 a,
h6 a,
h1.h-leather a,
h2.h-leather a,
h3.h-leather a,
h4.h-leather a,
h5.h-leather a,
h6.h-leather a {
@apply text-gray-900 dark:text-gray-100 hover:text-primary-600
dark:hover:text-primary-400;
}
/* === LEATHER COMPONENTS === */
.leather,
.publication-leather {
@apply text-gray-900 dark:text-gray-100;
}
.btn-leather.text-xs {
@ -36,28 +251,6 @@ @@ -36,28 +251,6 @@
@apply border border-primary-700;
}
div.card-leather {
@apply shadow-none text-primary-1000 border-s-4 bg-highlight
border-primary-200 has-[:hover]:border-primary-700;
@apply dark:bg-primary-1000 dark:border-primary-800
dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
}
div.card-leather h1,
div.card-leather h2,
div.card-leather h3,
div.card-leather h4,
div.card-leather h5,
div.card-leather h6 {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100
dark:hover:text-primary-400;
}
div.card-leather .font-thin {
@apply text-gray-900 hover:text-primary-700 dark:text-gray-100
dark:hover:text-primary-300;
}
main {
@apply max-w-full flex;
}
@ -73,14 +266,13 @@ @@ -73,14 +266,13 @@
main.main-leather,
article.article-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100;
@apply text-gray-900 dark:text-gray-100;
}
div.note-leather,
p.note-leather,
section.note-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100
p-2 rounded;
@apply text-gray-900 dark:text-gray-100 p-2 rounded;
}
.edit div.note-leather:hover:not(:has(.note-leather:hover)),
@ -89,50 +281,8 @@ @@ -89,50 +281,8 @@
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
}
h1.h-leather,
h2.h-leather,
h3.h-leather,
h4.h-leather,
h5.h-leather,
h6.h-leather {
@apply text-gray-900 dark:text-gray-100;
}
/* Responsive card styles */
.responsive-card {
@apply w-full min-w-0 overflow-hidden;
}
.responsive-card-content {
@apply break-words overflow-hidden;
}
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;
}
div.modal-leather > div {
@apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100
@apply bg-primary-50 dark:bg-primary-950 border-b-[1px] border-primary-100
dark:border-primary-600;
}
@ -142,14 +292,16 @@ @@ -142,14 +292,16 @@
div.modal-leather > div > h4,
div.modal-leather > div > h5,
div.modal-leather > div > h6 {
@apply text-gray-900 hover:text-gray-900 dark:text-gray-100
dark:hover:text-gray-100;
@apply text-gray-900 dark:text-gray-100;
}
div.modal-leather button {
@apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950
dark:hover:bg-primary-950 text-gray-900 hover:text-primary-600
dark:text-gray-100 dark:hover:text-primary-400;
div.modal-leather > div > h1 a,
div.modal-leather > div > h2 a,
div.modal-leather > div > h3 a,
div.modal-leather > div > h4 a,
div.modal-leather > div > h5 a,
div.modal-leather > div > h6 a {
@apply hover:text-primary-600 dark:hover:text-primary-400;
}
/* Navbar */
@ -166,26 +318,33 @@ @@ -166,26 +318,33 @@
dark:hover:fill-primary-400;
}
/* NavBrand hover - all text highlights together */
#navi a:hover h1,
#navi a:hover p {
@apply !text-primary-600 dark:!text-primary-400;
transition: color 0.2s ease-in-out;
}
nav.navbar-leather h1,
nav.navbar-leather h2,
nav.navbar-leather h3,
nav.navbar-leather h4,
nav.navbar-leather h5,
nav.navbar-leather h6 {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100
dark:hover:text-primary-400;
}
div.skeleton-leather div {
@apply bg-primary-100 dark:bg-primary-800;
@apply text-gray-900 dark:text-gray-100;
}
div.skeleton-leather {
@apply h-48;
nav.navbar-leather h1 a,
nav.navbar-leather h2 a,
nav.navbar-leather h3 a,
nav.navbar-leather h4 a,
nav.navbar-leather h5 a,
nav.navbar-leather h6 a {
@apply hover:text-primary-600 dark:hover:text-primary-400;
}
div.textarea-leather {
@apply bg-primary-0 dark:bg-primary-1000;
@apply bg-primary-50 dark:bg-primary-1000;
}
div.textarea-leather > div:nth-child(1),
@ -194,7 +353,7 @@ @@ -194,7 +353,7 @@
}
div.textarea-leather > div:nth-child(2) {
@apply bg-primary-0 dark:bg-primary-1000;
@apply bg-primary-50 dark:bg-primary-1000;
}
div.textarea-leather,
@ -240,33 +399,7 @@ @@ -240,33 +399,7 @@
/* Utilities can be applied via the @apply directive. */
@layer utilities {
.h-leather {
@apply text-gray-900 dark:text-gray-100 pt-4;
}
.h1-leather {
@apply text-4xl font-bold;
}
.h2-leather {
@apply text-3xl font-bold;
}
.h3-leather {
@apply text-2xl font-bold;
}
.h4-leather {
@apply text-xl font-bold;
}
.h5-leather {
@apply text-lg font-semibold;
}
.h6-leather {
@apply text-base font-semibold;
}
/* Removed redundant .h-leather and .h1-leather through .h6-leather - use base layer definitions instead */
/* Line clamp utilities for text truncation */
.line-clamp-1 {
@ -290,33 +423,23 @@ @@ -290,33 +423,23 @@
-webkit-line-clamp: 3;
}
.decoration-none {
text-decoration: none !important;
}
/* Lists */
.ol-leather li a,
.ul-leather li a {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100
@apply text-gray-900 dark:text-gray-100 hover:text-primary-600
dark:hover:text-primary-400;
}
/* Links - consistent hover colors */
.link {
@apply underline cursor-pointer hover:text-primary-600
dark:hover:text-primary-400;
}
/* Card with transition */
.ArticleBox.grid .ArticleBoxImage {
@apply max-h-0;
transition: max-height 0.5s ease;
}
.ArticleBox.grid.active .ArticleBoxImage {
@apply max-h-40;
}
.tags span {
@apply bg-primary-50 text-primary-800 text-sm font-medium me-2 px-2.5 py-0.5
rounded-sm dark:bg-primary-900 dark:text-primary-200;
}
.npub-badge {
@apply inline-flex space-x-1 items-center text-primary-600
dark:text-primary-500 hover:underline me-2 px-2 py-0.5 rounded-sm border
@ -326,9 +449,17 @@ @@ -326,9 +449,17 @@
@apply fill-primary-600 dark:fill-primary-500;
}
}
[data-tech="off"] .tech-detail {
@apply !hidden;
}
}
@layer components {
nav a {
text-decoration-line: none !important;
}
canvas.qr-code {
@apply block mx-auto my-4;
}
@ -349,7 +480,7 @@ @@ -349,7 +480,7 @@
/* Tooltip */
.tooltip-leather {
@apply fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-1000
@apply fixed p-4 rounded shadow-lg bg-primary-50 dark:bg-primary-1000
text-gray-900 dark:text-gray-100 border border-gray-200
dark:border-gray-700 transition-colors duration-200;
max-width: 400px;
@ -360,112 +491,78 @@ @@ -360,112 +491,78 @@
@apply dark:text-white;
}
/* Rendered publication content */
.publication-leather {
@apply flex flex-col space-y-4;
scroll-margin-top: 150px;
scroll-behavior: smooth;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply h-leather;
}
h1 {
@apply h1-leather;
}
h2 {
@apply h2-leather;
}
h3 {
@apply h3-leather;
}
h4 {
@apply h4-leather;
}
h5 {
@apply h5-leather;
}
h6 {
@apply h6-leather;
}
div {
@apply flex flex-col space-y-4;
}
/* Publication headings inherit from base layer - removed duplicate definitions */
.olist {
@apply flex flex-col space-y-4;
.olist {
@apply flex flex-col space-y-4;
ol {
@apply ol-leather list-decimal px-6 flex flex-col space-y-2;
ol {
@apply list-decimal px-6 flex flex-col space-y-2;
li {
.paragraph {
@apply py-2;
}
li {
.paragraph {
@apply py-2;
}
}
}
}
.ulist {
@apply flex flex-col space-y-4;
.ulist {
@apply flex flex-col space-y-4;
ul {
@apply ul-leather list-disc px-6 flex flex-col space-y-2;
ul {
@apply list-disc px-6 flex flex-col space-y-2;
li {
.paragraph {
@apply py-2;
}
li {
.paragraph {
@apply py-2;
}
}
}
}
a {
@apply link;
}
/* All links - consistent hover behavior */
a {
@apply underline cursor-pointer hover:text-primary-600
dark:hover:text-primary-400;
}
.imageblock {
@apply flex flex-col items-center;
.imageblock {
@apply flex flex-col items-center;
.title {
@apply text-sm text-center;
}
.title {
@apply text-sm text-center;
}
}
.stemblock {
@apply bg-gray-200 dark:bg-gray-800 p-4 rounded-lg;
}
.stemblock {
@apply bg-gray-200 dark:bg-gray-800 p-4 rounded-lg;
}
.literalblock {
pre {
@apply text-wrap;
}
.literalblock {
pre {
@apply text-wrap;
}
}
table {
@apply w-full overflow-x-auto;
table {
@apply w-full overflow-x-auto;
caption {
@apply text-sm;
}
caption {
@apply text-sm;
}
thead,
tbody {
th,
td {
@apply border border-gray-200 dark:border-gray-700;
}
thead,
tbody {
th,
td {
@apply border border-gray-200 dark:border-gray-700;
}
}
}
@ -473,7 +570,7 @@ @@ -473,7 +570,7 @@
/* Footnotes */
.footnote-ref {
text-decoration: none;
color: var(--color-primary);
color: var(--color-primary-500);
}
.footnotes {
@ -495,6 +592,7 @@ @@ -495,6 +592,7 @@
overflow: hidden;
text-overflow: ellipsis;
}
.footnotes li {
margin-bottom: 0.5rem;
}
@ -502,12 +600,12 @@ @@ -502,12 +600,12 @@
.footnote-backref {
text-decoration: none;
margin-left: 0.5rem;
color: var(--color-primary);
color: var(--color-primary-500);
}
.note-leather .footnote-ref,
.note-leather .footnote-backref {
color: var(--color-leather-primary);
color: var(--color-primary-500);
}
/* Scrollable content */
@ -575,15 +673,15 @@ @@ -575,15 +673,15 @@
input[type="tel"],
input[type="url"],
textarea {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100
border-s-4 border-primary-200 rounded shadow-none px-4 py-2;
@apply bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100
border-s-4 border-primary-200 rounded shadow-none;
@apply focus:border-primary-600 dark:focus:border-primary-400;
}
/* Table of Contents highlighting */
.toc-highlight {
@apply bg-primary-200 dark:bg-primary-700 border-l-4 border-primary-600
dark:border-primary-400 font-medium;
@apply bg-primary-300 dark:bg-primary-700 border-s-4 border-primary-600
rounded dark:border-primary-400 font-medium;
transition: all 0.2s ease-in-out;
}
@ -618,3 +716,10 @@ @@ -618,3 +716,10 @@
text-indent: 0 !important;
}
}
.icon-wiki {
font-size: 20px;
line-height: 20px;
vertical-align: text-bottom;
font-weight: 500;
}

24
src/app.html

@ -1,10 +1,32 @@ @@ -1,10 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-tech="off">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png?v=2" />
<meta name="viewport" content="width=device-width" />
<!-- Apply saved theme ASAP to avoid flash -->
<script>
try {
const t = localStorage.getItem("theme");
if (t) document.documentElement.dataset.theme = t;
} catch (_) {
/* no-op */
}
</script>
<!-- Apply saved tech toggle ASAP; default is off -->
<script>
try {
const v = localStorage.getItem("alexandria/showTech");
document.documentElement.dataset.tech = v === "true"
? "on"
: "off";
} catch (_) {
/* no-op */
}
</script>
<!-- MathJax for math rendering -->
<script>
window.MathJax = {

1026
src/lib/a/AGENTS.md

File diff suppressed because it is too large Load Diff

156
src/lib/a/README.md

@ -0,0 +1,156 @@ @@ -0,0 +1,156 @@
# Alexandria Component Library
A comprehensive, project-scoped component library for the Alexandria nostr
application. All components are built on Flowbite Svelte and Tailwind CSS,
providing consistent theming and accessibility across the application.
> **For AI Agents & Detailed Guidelines:** See [AGENTS.md](./AGENTS.md) for
> complete workflow instructions, styling architecture, and component creation
> guidelines.
## Quick Start
```typescript
// Import components from the library
import { AAlert, AEventPreview, AMarkupForm } from '$lib/a';
// Use in your Svelte components
<AAlert color="success" dismissable={true}>
{#snippet children()}Your changes have been saved!{/snippet}
</AAlert>
```
## Component Categories
### 🧱 Primitives (8)
Basic building blocks: `AAlert`, `ADetails`, `AInput`, `ANostrBadge`,
`ANostrBadgeRow`, `ANostrUser`, `APagination`, `AThemeToggleMini`
### 🧭 Navigation (2)
App navigation: `ANavbar`, `AFooter`
### 📝 Forms (4)
Input interfaces: `ACommentForm`, `AMarkupForm`, `ASearchForm`,
`ATextareaWithPreview`
### 🃏 Cards (2)
Content display: `AEventPreview`, `AProfilePreview`
### 👁 Reader (2)
Technical content controls: `ATechBlock`, `ATechToggle`
## Component Reference
All components are documented in `alexandria-components.json`. This file
contains:
- Complete prop definitions with types and defaults
- Usage examples and patterns
- Features and accessibility information
**View component details:**
```bash
# Generate/update the component reference
cd src/lib/a
node parse-components.js
```
## Usage Examples
### Display a user profile
```svelte
<ANostrUser
{npub}
{profile}
size="lg"
showBadges={true}
href="/profile/{npub}"
/>
```
### Show an event card
```svelte
<AEventPreview
{event}
label="Article"
showContent={true}
actions={[{label: "View", onClick: handleView}]}
/>
```
### Rich text editor with preview
```svelte
<ATextareaWithPreview
bind:value={content}
parser={parseMarkup}
previewSnippet={markupRenderer}
placeholder="Write your content..."
/>
```
### Alert notification
```svelte
{#if saveSuccessful}
<AAlert color="success" dismissable={true}>
{#snippet children()}Your changes have been saved.{/snippet}
</AAlert>
{/if}
```
## Key Features
- ✅ **Consistent theming** - Automatic light/dark mode support
- ✅ **Accessibility first** - ARIA attributes, keyboard navigation, screen
reader friendly
- ✅ **TypeScript support** - Full type definitions for all props
- ✅ **TSDoc documented** - Machine-readable documentation for AI tools
- ✅ **Flexible APIs** - Sensible defaults with extensive customization options
## Documentation
All components follow TSDoc format with these tags:
- `@fileoverview` - Component description
- `@category` - Component category
- `@prop` - Property definitions with types
- `@example` - Usage examples
- `@features` - Key functionality
- `@accessibility` - Accessibility notes
The `parse-components.js` script extracts this documentation into
`alexandria-components.json` for automated tooling and AI agents.
## Contributing
When adding components:
1. Follow the `A[ComponentName]` naming convention
2. Add complete TSDoc documentation
3. Place in the appropriate category folder
4. Export from `index.ts`
5. Run `node parse-components.js` to update the JSON reference
**See [AGENTS.md](./AGENTS.md) for detailed guidelines on:**
- Component creation workflow
- Styling architecture (mandatory `/src/styles/a/` folder structure)
- TSDoc documentation standards
- Testing requirements
- Common patterns and best practices
## Resources
- [Flowbite Svelte](https://flowbite-svelte.com/) - Base component library
- [Tailwind CSS](https://tailwindcss.com/) - Styling framework
- [TSDoc](https://tsdoc.org/) - Documentation standard
- [Svelte 5](https://svelte.dev/docs) - Framework documentation

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

@ -0,0 +1,290 @@ @@ -0,0 +1,290 @@
<script lang="ts">
/**
* @fileoverview AEventPreview Component - Alexandria
*
* A card component for displaying nostr event previews with configurable display options.
* Shows event metadata, content, author information, and action buttons.
*
* @component
* @category Cards
*
* @prop {NDKEvent} event - The nostr event to display (required)
* @prop {string} [label=""] - Optional label/category for the event
* @prop {boolean} [community=false] - Whether this is a community event
* @prop {number} [truncateContentAt=200] - Character limit for content truncation
* @prop {boolean} [showKind=true] - Whether to show event kind
* @prop {boolean} [showSummary=true] - Whether to show event summary
* @prop {boolean} [showDeferralNaddr=true] - Whether to show deferral naddr
* @prop {boolean} [showPublicationLink=true] - Whether to show publication link
* @prop {boolean} [showContent=true] - Whether to show event content
* @prop {Array<{label: string, onClick: (ev: NDKEvent) => void, variant?: string}>} [actions] - Action buttons
* @prop {(ev: NDKEvent) => void} [onSelect] - Callback when event is selected
* @prop {(naddr: string, ev: NDKEvent) => void} [onDeferralClick] - Callback for deferral clicks
*
* @example
* ```svelte
* <AEventPreview
* {event}
* label="Article"
* showContent={true}
* actions={[{label: "View", onClick: handleView}]}
* />
* ```
*
* @example Basic event preview
* ```svelte
* <AEventPreview {event} />
* ```
*
* @example Community event with actions
* ```svelte
* <AEventPreview
* {event}
* community={true}
* actions={[
* {label: "Reply", onClick: handleReply},
* {label: "Share", onClick: handleShare, variant: "light"}
* ]}
* />
* ```
*
* @example Minimal preview without content
* ```svelte
* <AEventPreview
* {event}
* showContent={false}
* showSummary={false}
* truncateContentAt={100}
* />
* ```
*
* @features
* - Responsive card layout with author badges
* - Content truncation with "show more" functionality
* - Publication links and metadata display
* - Configurable action buttons
* - Community event highlighting
* - Event kind and summary display
*
* @accessibility
* - Semantic card structure
* - Keyboard accessible action buttons
* - Screen reader friendly metadata
* - Proper heading hierarchy
*/
import { Card, Button } from "flowbite-svelte";
import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { toNpub, getMatchingTags } from "$lib/utils/nostrUtils";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { preventDefault } from "svelte/legacy";
let {
event,
label = "",
community = false,
truncateContentAt = 200,
showKind = true,
showSummary = true,
showDeferralNaddr = true,
showPublicationLink = true,
showContent = true,
actions,
onSelect,
onDeferralClick,
}: {
event: NDKEvent;
label?: string;
community?: boolean;
truncateContentAt?: number;
showKind?: boolean;
showSummary?: boolean;
showDeferralNaddr?: boolean;
showPublicationLink?: boolean;
showContent?: boolean;
actions?: {
label: string;
onClick: (ev: NDKEvent) => void;
variant?: "primary" | "light" | "alternative";
}[];
onSelect?: (ev: NDKEvent) => void;
onDeferralClick?: (naddr: string, ev: NDKEvent) => void;
} = $props();
type ProfileData = {
name?: string;
display_name?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
lud16?: string;
nip05?: string;
};
function parseProfileContent(ev: NDKEvent): ProfileData | null {
if (ev.kind !== 0 || !ev.content) {
return null;
}
try {
return JSON.parse(ev.content) as ProfileData;
} catch {
return null;
}
}
function getSummary(ev: NDKEvent): string | undefined {
return getMatchingTags(ev, "summary")[0]?.[1];
}
function getDeferralNaddr(ev: NDKEvent): string | undefined {
return getMatchingTags(ev, "deferral")[0]?.[1];
}
const profileData = parseProfileContent(event);
const summary = showSummary ? getSummary(event) : undefined;
const deferralNaddr = showDeferralNaddr ? getDeferralNaddr(event) : undefined;
function clippedContent(content: string): string {
if (!showContent) {
return "";
}
if (!truncateContentAt || content.length <= truncateContentAt) {
return content;
}
return content.slice(0, truncateContentAt) + "...";
}
function handleSelect(): void {
onSelect?.(event);
}
function handleKeydown(e: KeyboardEvent): void {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect();
}
}
function handleDeferralClick(e: MouseEvent): void {
e.stopPropagation();
if (deferralNaddr) {
onDeferralClick?.(deferralNaddr, event);
}
}
const displayName: string | undefined =
profileData?.display_name || profileData?.name;
const avatarFallback: string = (displayName || event.pubkey || "?")
.slice(0, 1)
.toUpperCase();
const createdDate: string = event.created_at
? new Date(event.created_at * 1000).toLocaleDateString()
: "Unknown date";
const computedActions =
actions && actions.length > 0
? actions
: [
{
label: "Open",
onClick: (ev: NDKEvent) => onSelect?.(ev),
variant: "light" as const,
},
];
</script>
<Card
class="event-preview-card"
role="group"
tabindex="0"
aria-label="Event preview"
onclick={handleSelect}
onkeydown={handleKeydown}
size="xl"
>
<!-- Header -->
<div class="card-header">
<!-- Meta -->
<div class="flex flex-row w-full gap-3 items-center min-w-0">
{#if label}
<span class="event-label">
{label}
</span>
{/if}
{#if showKind}
<span class="event-kind-badge">
Kind {event.kind}
</span>
{/if}
{#if community}
<span
class="community-badge"
title="Has posted to the community"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
Community
</span>
{/if}
<span class="text-xs ml-auto mb-4">
{createdDate}
</span>
</div>
<div class="flex flex-row">
{@render userBadge(toNpub(event.pubkey) as string, displayName)}
</div>
</div>
<!-- Body -->
<div class="card-body">
{#if event.kind === 0 && profileData?.about}
<div class="card-about">
{clippedContent(profileData.about)}
</div>
{:else}
{#if summary}
<div class="card-summary">
{summary}
</div>
{/if}
{#if deferralNaddr}
<div class="text-xs text-primary-800 dark:text-primary-300">
Read
<span
class="deferral-link"
role="button"
tabindex="0"
onclick={handleDeferralClick}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleDeferralClick(e as unknown as MouseEvent);
}
}}
>
{deferralNaddr}
</span>
</div>
{/if}
{#if showContent && event.content}
<div class="card-content">
{clippedContent(event.content)}
</div>
{/if}
{/if}
</div>
<!-- Footer / Actions -->
{#if showPublicationLink && event.kind !== 0}
<div class="card-footer">
<ViewPublicationLink {event} />
</div>
{/if}
</Card>

449
src/lib/a/cards/AProfilePreview.svelte

@ -0,0 +1,449 @@ @@ -0,0 +1,449 @@
<script lang="ts">
/**
* @fileoverview AProfilePreview Component - Alexandria
*
* A comprehensive profile card component for displaying nostr user profiles.
* Shows avatar, banner, name, bio, NIP-05 verification, lightning address, and user status indicators.
*
* @component
* @category Cards
*
* @prop {NDKEvent} event - The nostr event (kind 0 profile) to display (required)
* @prop {UserLite} [user] - User object containing npub identifier
* @prop {Profile} profile - User profile metadata (required)
* @prop {boolean} [loading=false] - Whether the profile is currently loading
* @prop {string} [error=null] - Error message if profile loading failed
* @prop {boolean} [isOwn=false] - Whether this is the current user's own profile
* @prop {Record<string, boolean>} [communityStatusMap=false] - Map of pubkey to community membership status
*
* @example
* ```svelte
* <AProfilePreview
* {event}
* user={{npub}}
* {profile}
* />
* ```
*
* @example Own profile with actions
* ```svelte
* <AProfilePreview
* {event}
* {profile}
* isOwn={true}
* />
* ```
*
* @example Loading state
* ```svelte
* <AProfilePreview
* {event}
* {profile}
* loading={true}
* />
* ```
*
* @example With error handling
* ```svelte
* <AProfilePreview
* {event}
* {profile}
* error={errorMessage}
* />
* ```
*
* @features
* - Banner image with fallback color generation
* - Avatar display with proper sizing
* - NIP-05 verification badge display
* - Community membership indicator (star icon)
* - User list membership indicator (heart icon)
* - Lightning address (lud16) with QR code modal
* - Multiple identifier formats (npub, nprofile, nevent)
* - Copy to clipboard functionality for identifiers
* - Website link display
* - Bio/about text with markup rendering
* - Own profile actions (notifications, my notes)
* - Loading and error states
*
* @accessibility
* - Semantic profile structure with proper headings
* - Keyboard accessible action buttons and dropdowns
* - Screen reader friendly verification status badges
* - Proper modal focus management for QR code
* - Alt text for images
* - ARIA labels for status indicators
*/
import {
Card,
Heading,
P,
Button,
Modal,
Avatar,
Dropdown,
DropdownItem,
} from "flowbite-svelte";
import { ChevronDownOutline } from "flowbite-svelte-icons";
import AAlert from "$lib/a/primitives/AAlert.svelte";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { goto } from "$app/navigation";
import LazyImage from "$lib/components/util/LazyImage.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
import QrCode from "$lib/components/util/QrCode.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils";
import {
lnurlpWellKnownUrl,
checkCommunity,
} from "$lib/utils/search_utility";
import { bech32 } from "bech32";
import { getNdkContext, activeInboxRelays } from "$lib/ndk";
import { toNpub } from "$lib/utils/nostrUtils";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import {
isPubkeyInUserLists,
fetchCurrentUserLists,
} from "$lib/utils/user_lists";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
type UserLite = { npub?: string | null };
type Profile = {
name?: string;
display_name?: string;
displayName?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
lud16?: string;
nip05?: string;
// Optional flags that might come via cached profile data
isInUserLists?: boolean;
} | null;
const props = $props<{
user?: UserLite;
profile: Profile;
loading?: boolean;
error?: string | null;
isOwn?: boolean;
event: NDKEvent;
communityStatusMap?: Record<string, boolean>;
}>();
const ndk = getNdkContext();
let lnModalOpen = $state(false);
let lnurl = $state<string | null>(null);
let communityStatus = $state<boolean | null>(null);
let isInUserLists = $state<boolean | null>(null);
function displayName() {
const p = props.profile;
const u = props.user;
return (
p?.display_name ||
p?.displayName ||
p?.name ||
(u?.npub ? u.npub.slice(0, 10) + "…" : "")
);
}
function shortNpub() {
const npub = props.user?.npub;
if (!npub) return "";
return npub.slice(0, 12) + "…" + npub.slice(-8);
}
function hideOnError(e: Event) {
const img = e.currentTarget as HTMLImageElement | null;
if (img) {
img.style.display = "none";
const next = img.nextElementSibling as HTMLElement | null;
if (next) next.classList.remove("hidden");
}
}
function getIdentifiers(
event: NDKEvent,
profile: any,
): { label: string; value: string; link?: string }[] {
const ids: { label: string; value: string; link?: string }[] = [];
if (event.kind === 0) {
const npub = toNpub(event.pubkey);
if (npub)
ids.push({ label: "npub", value: npub, link: `/events?id=${npub}` });
ids.push({
label: "nprofile",
value: nprofileEncode(event.pubkey, $activeInboxRelays),
link: `/events?id=${nprofileEncode(event.pubkey, $activeInboxRelays)}`,
});
ids.push({
label: "nevent",
value: neventEncode(event, $activeInboxRelays),
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`,
});
ids.push({ label: "pubkey", value: event.pubkey });
} else {
ids.push({
label: "nevent",
value: neventEncode(event, $activeInboxRelays),
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`,
});
try {
const naddr = naddrEncode(event, $activeInboxRelays);
ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` });
} catch {}
ids.push({
label: "id",
value: event.id,
link: `/events?id=${event.id}`,
});
}
return ids;
}
function navigateToIdentifier(link: string) {
goto(link);
}
// Compute LNURL on mount if lud16 exists
$effect(() => {
const p = props.profile;
if (p?.lud16) {
try {
const [name, domain] = p.lud16.split("@");
const url = lnurlpWellKnownUrl(domain, name);
const words = bech32.toWords(new TextEncoder().encode(url));
lnurl = bech32.encode("lnurl", words);
} catch {
lnurl = null;
}
} else {
lnurl = null;
}
});
// Compute community/list status when event changes
$effect(() => {
const ev = props.event;
if (!ev?.pubkey) {
communityStatus = null;
isInUserLists = null;
return;
}
// isInUserLists: prefer prop.profile hint, else cached profileData, else fetch
if (props.profile && typeof props.profile.isInUserLists === "boolean") {
isInUserLists = props.profile.isInUserLists;
} else {
const cachedProfileData = (ev as any).profileData;
if (
cachedProfileData &&
typeof cachedProfileData.isInUserLists === "boolean"
) {
isInUserLists = cachedProfileData.isInUserLists;
} else {
fetchCurrentUserLists()
.then((lists) => {
isInUserLists = isPubkeyInUserLists(ev.pubkey, lists);
})
.catch(() => {
isInUserLists = false;
});
}
}
// community status: prefer map if provided, else check
if (
props.communityStatusMap &&
props.communityStatusMap[ev.pubkey] !== undefined
) {
communityStatus = props.communityStatusMap[ev.pubkey];
} else {
checkCommunity(ev.pubkey)
.then((status) => {
communityStatus = status;
})
.catch(() => {
communityStatus = false;
});
}
});
</script>
<Card
size="xl"
class="main-leather p-0 overflow-hidden rounded-lg border border-primary-200 dark:border-primary-700"
>
{#if props.profile?.banner}
<div class="card-image-container">
<LazyImage
src={props.profile.banner}
alt="Profile banner"
eventId={props.event.id}
className="card-banner"
/>
</div>
{:else}
<div
class="w-full h-60"
style={`background-color: ${generateDarkPastelColor(props.event.id)};`}
></div>
{/if}
<div class={`p-6 flex flex-col relative`}>
<Avatar
size="xl"
src={props.profile?.picture ?? null}
alt="Avatar"
class="card-avatar-container"
/>
<div class="flex flex-col gap-3">
<Heading tag="h1" class="h-leather mb-2">{displayName()}</Heading>
{#if props.user?.npub}
<CopyToClipboard displayText={shortNpub()} copyText={props.user.npub} />
{/if}
{#if props.event}
<div class="flex items-center gap-2 min-w-0">
{#if props.profile?.nip05}
<span class="profile-nip05-badge">{props.profile.nip05}</span>
{/if}
{#if communityStatus === true}
<div
class="community-status-indicator"
title="Has posted to the community"
>
<svg
class="community-status-icon"
fill="currentColor"
viewBox="0 0 24 24"
><path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/></svg
>
</div>
{/if}
{#if isInUserLists === true}
<div
class="user-list-indicator"
title="In your lists (follows, etc.)"
>
<svg
class="user-list-icon"
fill="currentColor"
viewBox="0 0 24 24"
><path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/></svg
>
</div>
{/if}
</div>
{/if}
</div>
{#if props.profile?.about}
<div class="prose dark:prose-invert card-prose">
{@render basicMarkup(props.profile.about, ndk)}
</div>
{/if}
<div class="flex flex-wrap gap-4 text-sm">
{#if props.profile?.website}
<a
href={props.profile.website}
rel="noopener"
class="text-primary-600 dark:text-primary-400 hover:underline break-all"
target="_blank">{props.profile.website}</a
>
{/if}
</div>
<div class="flex flex-row flex-wrap justify-end gap-4 text-sm">
{#if props.profile?.lud16}
<Button
color="alternative"
size="xs"
onclick={() => (lnModalOpen = true)}>⚡ {props.profile.lud16}</Button
>
{/if}
<Button size="xs" color="alternative"
>Identifiers <ChevronDownOutline class="ms-2 h-6 w-6" /></Button
>
<Dropdown simple>
{#each getIdentifiers(props.event, props.profile) as identifier}
<DropdownItem
><CopyToClipboard
displayText={identifier.label}
copyText={identifier.value}
/></DropdownItem
>
{/each}
</Dropdown>
{#if props.isOwn}
<Button
class="!mb-0"
size="xs"
onclick={() => goto("/profile/notifications")}>Notifications</Button
>
<Button
class="!mb-0"
size="xs"
onclick={() => goto("/profile/my-notes")}>My notes</Button
>
{/if}
</div>
{#if props.loading}
<AAlert color="primary">Loading profile…</AAlert>
{/if}
{#if props.error}
<AAlert color="red">Error loading profile: {props.error}</AAlert>
{/if}
</div>
</Card>
{#if lnModalOpen}
<Modal
class="modal-leather"
title="Lightning Address"
bind:open={lnModalOpen}
outsideclose
size="sm"
>
{#if props.profile?.lud16}
<div>
<div class="flex flex-col items-center">
{@render userBadge(
props.user?.npub ?? toNpub(props.event.pubkey),
props.profile?.displayName ||
props.profile?.display_name ||
props.profile?.name ||
props.event?.pubkey ||
"",
ndk,
)}
<P class="break-all">{props.profile.lud16}</P>
</div>
<div class="flex flex-col items-center mt-3 space-y-4">
<P>Scan the QR code or copy the address</P>
{#if lnurl}
<P class="break-all overflow-wrap-anywhere">
<CopyToClipboard icon={false} displayText={lnurl}
></CopyToClipboard>
</P>
<QrCode value={lnurl} />
{:else}
<P>Couldn't generate address.</P>
{/if}
</div>
</div>
{/if}
</Modal>
{/if}

140
src/lib/a/forms/ACommentForm.svelte

@ -0,0 +1,140 @@ @@ -0,0 +1,140 @@
<script lang="ts">
/**
* @fileoverview ACommentForm Component - Alexandria
*
* A form component for creating and editing comments with markup support and preview functionality.
* Integrates with ATextareaWithPreview to provide rich text editing capabilities.
*
* @component
* @category Forms
*
* @prop {string} [content=""] - The comment content text (bindable)
* @prop {any} [extensions] - Additional extensions for markup processing
* @prop {boolean} [isSubmitting=false] - Whether form is currently submitting
* @prop {(content: string) => Promise<void>} [onSubmit] - Callback when form is submitted
*
* @example
* ```svelte
* <ACommentForm
* bind:content={commentText}
* {isSubmitting}
* onSubmit={handleCommentSubmit}
* />
* ```
*
* @example Basic comment form
* ```svelte
* <ACommentForm bind:content={comment} onSubmit={postComment} />
* ```
*
* @example Comment form with custom extensions
* ```svelte
* <ACommentForm
* bind:content={replyText}
* extensions={customMarkupExtensions}
* isSubmitting={posting}
* onSubmit={handleReply}
* />
* ```
*
* @features
* - Rich text editing with markdown-like syntax
* - Live preview of formatted content
* - Clear form functionality
* - Remove formatting option
* - Submit handling with loading states
* - Integration with user authentication
*
* @accessibility
* - Proper form labels and structure
* - Keyboard accessible controls
* - Screen reader friendly
* - Clear form validation feedback
*/
import { Button, Label } from "flowbite-svelte";
import { userStore } from "$lib/stores/userStore.ts";
import { parseBasicMarkup } from "$lib/utils/markup/basicMarkupParser.ts";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
import { getNdkContext } from "$lib/ndk.ts";
import { ATextareaWithPreview } from "$lib/a/index.ts";
const ndk = getNdkContext();
let {
// make content bindable
content = $bindable(""),
extensions,
isSubmitting = false,
onSubmit = () => {},
} = $props<{
content?: string;
extensions?: any;
isSubmitting?: boolean;
onSubmit?: (content: string) => Promise<void>;
}>();
function clearForm() {
content = "";
}
function removeFormatting() {
content = content
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/_(.*?)_/g, "$1")
.replace(/~~(.*?)~~/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/!\[(.*?)\]\(.*?\)/g, "$1")
.replace(/^>\s*/gm, "")
.replace(/^[-*]\s*/gm, "")
.replace(/^\d+\.\s*/gm, "")
.replace(/#(\w+)/g, "$1");
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
await onSubmit(content.trim());
}
</script>
<form novalidate onsubmit={handleSubmit}>
<Label for="editor" class="sr-only">Comment</Label>
<ATextareaWithPreview
id="editor"
label=""
rows={10}
bind:value={content}
placeholder="Write a comment"
parser={parseBasicMarkup}
previewSnippet={basicMarkup}
previewArg={ndk}
{extensions}
/>
<div class="flex flex-row justify-between mt-2">
<div class="flex flex-row flex-wrap gap-3 !m-0">
<Button
size="xs"
color="alternative"
onclick={removeFormatting}
class="!m-0">Remove Formatting</Button
>
<Button size="xs" color="alternative" class="!m-0" onclick={clearForm}
>Clear</Button
>
</div>
<Button
disabled={isSubmitting || !content.trim() || !$userStore.signedIn}
type="submit"
>
{#if !$userStore.signedIn}
Not Signed In
{:else if isSubmitting}
Publishing...
{:else}
Post Comment
{/if}
</Button>
</div>
</form>

170
src/lib/a/forms/AMarkupForm.svelte

@ -0,0 +1,170 @@ @@ -0,0 +1,170 @@
<script lang="ts">
/**
* @fileoverview AMarkupForm Component - Alexandria
*
* A comprehensive form component for creating content with subject/title and rich markup body.
* Provides advanced markup editing with preview, confirmation dialogs, and form management.
*
* @component
* @category Forms
*
* @prop {string} [subject=""] - The content title/subject (bindable)
* @prop {string} [content=""] - The main content body (bindable)
* @prop {boolean} [isSubmitting=false] - Whether form is currently submitting
* @prop {number} [clearSignal=0] - Signal to clear form (increment to trigger clear)
* @prop {(subject: string, content: string) => Promise<void>} [onSubmit] - Submit callback
*
* @example
* ```svelte
* <AMarkupForm
* bind:subject={title}
* bind:content={body}
* {isSubmitting}
* onSubmit={handlePublish}
* />
* ```
*
* @example Basic markup form
* ```svelte
* <AMarkupForm
* bind:subject={articleTitle}
* bind:content={articleContent}
* onSubmit={publishArticle}
* />
* ```
*
* @example Form with clear signal control
* ```svelte
* <AMarkupForm
* bind:subject={title}
* bind:content={body}
* clearSignal={resetCounter}
* isSubmitting={publishing}
* onSubmit={handleSubmit}
* />
* ```
*
* @features
* - Subject/title input field
* - Advanced markup editor with preview
* - Clear form functionality with confirmation dialog
* - Form validation and submission states
* - Integration with advanced markup parser
* - Responsive layout with proper spacing
*
* @accessibility
* - Proper form labels and structure
* - Keyboard accessible controls
* - Screen reader friendly
* - Modal dialogs with focus management
* - Clear form validation feedback
*/
import { Label, Input, Button, Modal } from "flowbite-svelte";
import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser";
import { ATextareaWithPreview } from "$lib/a/index.ts";
import { getNdkContext } from "$lib/ndk.ts";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
let {
subject = $bindable(""),
content = $bindable(""),
isSubmitting = false,
clearSignal = 0,
onSubmit = async (_subject: string, _content: string) => {},
} = $props<{
subject?: string;
content?: string;
isSubmitting?: boolean;
clearSignal?: number;
onSubmit?: (subject: string, content: string) => Promise<void> | void;
}>();
// Local UI state
let showConfirmDialog = $state(false);
// Track last clear signal to avoid clearing on mount if default matches
let _lastClearSignal = $state<number | null>(null);
$effect(() => {
if (clearSignal !== _lastClearSignal) {
if (_lastClearSignal !== null) {
subject = "";
content = "";
}
_lastClearSignal = clearSignal;
}
});
function clearForm() {
subject = "";
content = "";
}
function handleSubmit(e: Event) {
e.preventDefault();
showConfirmDialog = true;
}
async function confirmSubmit() {
showConfirmDialog = false;
await onSubmit(subject.trim(), content.trim());
}
function cancelSubmit() {
showConfirmDialog = false;
}
let ndk = getNdkContext();
</script>
<form class="space-y-4" onsubmit={handleSubmit} autocomplete="off">
<div>
<Label for="subject" class="mb-2">Subject</Label>
<Input
id="subject"
class="w-full"
placeholder="Issue subject"
bind:value={subject}
required
autofocus
/>
</div>
<div class="relative">
<ATextareaWithPreview
id="content"
label="Description"
bind:value={content}
placeholder="Describe your issue. Use the Eye toggle to preview rendered markup."
parser={parseAdvancedmarkup}
previewSnippet={basicMarkup}
previewArg={ndk}
/>
</div>
<div class="flex justify-end space-x-4">
<Button type="button" color="alternative" onclick={clearForm}
>Clear Form</Button
>
<Button type="submit" tabindex={0} disabled={isSubmitting}>
{#if isSubmitting}
Submitting...
{:else}
Submit Issue
{/if}
</Button>
</div>
</form>
<!-- Confirmation Dialog -->
<Modal bind:open={showConfirmDialog} size="sm" autoclose={false} class="w-full">
<div class="text-center">
<h3 class="mb-5 text-lg font-normal text-gray-700 dark:text-gray-300">
Would you like to submit the issue?
</h3>
<div class="flex justify-center gap-4">
<Button color="alternative" onclick={cancelSubmit}>Cancel</Button>
<Button color="primary" onclick={confirmSubmit}>Submit</Button>
</div>
</div>
</Modal>

137
src/lib/a/forms/ASearchForm.svelte

@ -0,0 +1,137 @@ @@ -0,0 +1,137 @@
<script lang="ts">
/**
* @fileoverview ASearchForm Component - Alexandria
*
* A search form component with loading states, keyboard handling, and flexible callback system.
* Provides a standardized search interface with clear functionality and user feedback.
*
* @component
* @category Forms
*
* @prop {string} searchQuery - The current search query text (bindable)
* @prop {boolean} searching - Whether a search is currently in progress
* @prop {boolean} loading - Whether data is being loaded
* @prop {boolean} isUserEditing - Whether user is actively editing the query (bindable)
* @prop {string} [placeholder] - Placeholder text for the search input
* @prop {(args: {clearInput: boolean, queryOverride?: string}) => void} [search] - Search callback
* @prop {() => void} [clear] - Clear callback
*
* @example
* ```svelte
* <ASearchForm
* bind:searchQuery={query}
* {searching}
* {loading}
* bind:isUserEditing={editing}
* search={handleSearch}
* clear={handleClear}
* />
* ```
*
* @example Basic search form
* ```svelte
* <ASearchForm
* bind:searchQuery={searchTerm}
* searching={isSearching}
* search={performSearch}
* clear={clearResults}
* />
* ```
*
* @example Custom placeholder and editing tracking
* ```svelte
* <ASearchForm
* bind:searchQuery={query}
* bind:isUserEditing={userTyping}
* searching={searching}
* placeholder="Search events, users, topics..."
* search={handleEventSearch}
* clear={resetSearch}
* />
* ```
*
* @features
* - Enter key triggers search
* - Loading spinner during operations
* - Clear button functionality
* - User editing state tracking
* - Flexible callback system
* - Accessible search interface
*
* @accessibility
* - Keyboard accessible (Enter to search)
* - Screen reader friendly with proper labels
* - Loading states clearly communicated
* - Focus management
*/
import { Button, Search, Spinner } from "flowbite-svelte";
// AI-NOTE: 2025-08-16 - This component centralizes search form behavior.
// Parent supplies callbacks `search` and `clear`. Two-way bindings use $bindable.
let {
searchQuery = $bindable(""),
searching = false,
loading = false,
isUserEditing = $bindable(false),
placeholder = "Enter event ID, nevent, naddr, d:tag-name, t:topic, or n:username...",
search,
clear,
}: {
searchQuery: string;
searching: boolean;
loading: boolean;
isUserEditing: boolean;
placeholder?: string;
search?: (args: { clearInput: boolean; queryOverride?: string }) => void;
clear?: () => void;
} = $props();
function handleKeydown(e: KeyboardEvent): void {
if (e.key === "Enter") {
search?.({ clearInput: true });
}
}
function triggerSearch(): void {
search?.({ clearInput: true });
}
function handleInput(): void {
isUserEditing = true;
}
function handleBlur(): void {
isUserEditing = false;
}
function handleClear(): void {
clear?.();
}
</script>
<form id="search-form" class="flex gap-2">
<Search
id="search-input"
class="justify-center"
bind:value={searchQuery}
onkeydown={handleKeydown}
oninput={handleInput}
onblur={handleBlur}
{placeholder}
/>
<Button onclick={triggerSearch} disabled={loading}>
{#if searching}
<Spinner class="mr-2 text-gray-600 dark:text-gray-300" size="5" />
{/if}
{searching ? "Searching..." : "Search"}
</Button>
<Button
onclick={handleClear}
color="alternative"
type="button"
disabled={loading}
>
Clear
</Button>
</form>

288
src/lib/a/forms/ATextareaWithPreview.svelte

@ -0,0 +1,288 @@ @@ -0,0 +1,288 @@
<script lang="ts">
/**
* @fileoverview ATextareaWithPreview Component - Alexandria
*
* A rich text editor with toolbar and live preview functionality for markup content.
* Provides formatting tools, preview toggle, and extensible parsing system.
*
* @component
* @category Forms
*
* @prop {string} value - The textarea content (bindable)
* @prop {string} [id="editor"] - HTML id for the textarea element
* @prop {string} [label=""] - Label text for the textarea
* @prop {number} [rows=10] - Number of textarea rows
* @prop {string} [placeholder=""] - Placeholder text
* @prop {(text: string, extensions?: any) => Promise<string>} parser - Async markup parser function
* @prop {snippet} previewSnippet - Svelte snippet for rendering preview content
* @prop {any} [previewArg] - Additional argument passed to preview snippet
* @prop {any} [extensions] - Extensions passed to the parser
*
* @example
* ```svelte
* <ATextareaWithPreview
* bind:value={content}
* {parser}
* {previewSnippet}
* previewArg={ndk}
* />
* ```
*
* @example Basic markup editor
* ```svelte
* <ATextareaWithPreview
* bind:value={content}
* parser={parseBasicMarkup}
* previewSnippet={basicMarkup}
* placeholder="Write your content..."
* />
* ```
*
* @example Advanced editor with extensions
* ```svelte
* <ATextareaWithPreview
* bind:value={articleContent}
* parser={parseAdvancedMarkup}
* previewSnippet={advancedMarkup}
* previewArg={ndkInstance}
* extensions={customExtensions}
* rows={15}
* />
* ```
*
* @features
* - Rich formatting toolbar (bold, italic, strikethrough, etc.)
* - Live preview toggle with eye icon
* - Support for links, images, quotes, lists
* - Hashtag and mention insertion
* - Extensible parser system
* - Keyboard shortcuts for formatting
*
* @accessibility
* - Proper form labels and ARIA attributes
* - Keyboard accessible toolbar buttons
* - Screen reader friendly with descriptive labels
* - Focus management between edit and preview modes
*/
import {
Textarea,
Toolbar,
ToolbarGroup,
ToolbarButton,
Label,
Button,
} from "flowbite-svelte";
import {
Bold,
Italic,
Strikethrough,
Quote,
Link2,
Image,
Hash,
List,
ListOrdered,
Eye,
PencilLine,
} from "@lucide/svelte";
// Reusable editor with toolbar (from ACommentForm) and toolbar-only Preview
let {
value = $bindable(""),
id = "editor",
label = "",
rows = 10,
placeholder = "",
// async parser that returns HTML string
parser = async (s: string) => s,
// snippet renderer and optional args
previewSnippet,
previewArg: previewArg = undefined,
// extra toolbar extensions (snippet returning toolbar buttons)
extensions,
} = $props<{
value?: string;
id?: string;
label?: string;
rows?: number;
placeholder?: string;
parser?: (s: string) => Promise<string> | string;
previewSnippet?: any; // Svelte snippet
previewArg?: any;
extensions?: any;
}>();
let preview = $state("");
let activeTab = $state<"write" | "preview">("write");
let wrapper: HTMLElement | null = null;
let isExpanded = $state(false);
const markupButtons = [
{ label: "Bold", icon: Bold, action: () => insertMarkup("**", "**") },
{ label: "Italic", icon: Italic, action: () => insertMarkup("_", "_") },
{
label: "Strike",
icon: Strikethrough,
action: () => insertMarkup("~~", "~~"),
},
{ label: "Link", icon: Link2, action: () => insertMarkup("[", "](url)") },
{ label: "Image", icon: Image, action: () => insertMarkup("![", "](url)") },
{ label: "Quote", icon: Quote, action: () => insertMarkup("> ", "") },
{ label: "List", icon: List, action: () => insertMarkup("* ", "") },
{
label: "Numbered List",
icon: ListOrdered,
action: () => insertMarkup("1. ", ""),
},
{ label: "Hashtag", icon: Hash, action: () => insertMarkup("#", "") },
];
function insertMarkup(prefix: string, suffix: string) {
const textarea = wrapper?.querySelector(
"textarea",
) as HTMLTextAreaElement | null;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = value.substring(start, end);
value =
value.substring(0, start) +
prefix +
selectedText +
suffix +
value.substring(end);
// Set cursor position after the inserted markup
setTimeout(() => {
textarea.focus();
textarea.selectionStart = textarea.selectionEnd =
start + prefix.length + selectedText.length + suffix.length;
}, 0);
}
function togglePreview() {
activeTab = activeTab === "write" ? "preview" : "write";
}
function toggleSize() {
isExpanded = !isExpanded;
}
$effect(() => {
if (activeTab !== "preview") return;
const src = value.trim();
if (!src) {
preview = "";
return;
}
Promise.resolve(parser(src)).then((html) => {
preview = html || "";
});
});
</script>
{#if label}
<Label for={id} class="mb-2">{label}</Label>
{/if}
<div bind:this={wrapper} class="rounded-lg">
<div class="min-h-[180px] relative">
{#if activeTab === "write"}
<div class="inset-0">
<Textarea
{id}
rows={isExpanded ? 30 : rows}
bind:value
classes={{
wrapper: "!m-0 p-0 h-full",
inner: "!m-0 !bg-transparent !dark:bg-transparent",
header: "!m-0 !bg-transparent !dark:bg-transparent",
footer: "!m-0 !bg-transparent",
addon: "!m-0 top-3 hidden md:flex",
div: "!m-0 !bg-transparent !dark:bg-transparent !border-0 !rounded-none !shadow-none !focus:ring-0",
}}
{placeholder}
>
{#snippet header()}
<Toolbar
embedded
class="flex-row !m-0 !dark:bg-transparent !bg-transparent"
>
<ToolbarGroup class="flex-row flex-wrap !m-0">
{#each markupButtons as button}
{@const TheIcon = button.icon}
<ToolbarButton
title={button.label}
color="dark"
size="md"
onclick={button.action}
>
<TheIcon size={24} />
</ToolbarButton>
{/each}
{#if extensions}
{@render extensions()}
{/if}
<ToolbarButton
title="Toggle preview"
color="dark"
size="md"
onclick={togglePreview}
>
<Eye size={24} />
</ToolbarButton>
</ToolbarGroup>
</Toolbar>
{/snippet}
</Textarea>
<Button
type="button"
size="xs"
class="absolute bottom-2 right-2 z-10 opacity-60 hover:opacity-100"
color="light"
onclick={toggleSize}
>
{isExpanded ? "⌃" : "⌄"}
</Button>
</div>
{:else}
<div
class="absolute rounded-lg inset-0 flex flex-col bg-white dark:bg-gray-900"
>
<div class="py-2 px-3 border-gray-200 dark:border-gray-700 border-b">
<Toolbar
embedded
class="flex-row !m-0 !dark:bg-transparent !bg-transparent"
>
<ToolbarGroup class="flex-row flex-wrap !m-0">
<ToolbarButton
title="Back to editor"
color="dark"
size="md"
onclick={togglePreview}
>
<PencilLine size={24} />
</ToolbarButton>
</ToolbarGroup>
</Toolbar>
</div>
<div
class="flex-1 overflow-auto px-4 py-2 max-w-none bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 prose-content markup-content rounded-b-lg"
>
{#if preview}
{#if previewSnippet}
{@render previewSnippet(preview, previewArg)}
{:else}
{@html preview}
{/if}
{:else}
<p class="text-xs text-gray-500">Nothing to preview</p>
{/if}
</div>
</div>
{/if}
</div>
</div>

29
src/lib/a/index.ts

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
// Alexandria Component Library - Main Export File
// Primitive Components
export { default as AAlert } from "./primitives/AAlert.svelte";
export { default as ADetails } from "./primitives/ADetails.svelte";
export { default as AInput } from "./primitives/AInput.svelte";
export { default as ANostrBadge } from "./primitives/ANostrBadge.svelte";
export { default as ANostrBadgeRow } from "./primitives/ANostrBadgeRow.svelte";
export { default as ANostrUser } from "./primitives/ANostrUser.svelte";
export { default as APagination } from "./primitives/APagination.svelte";
export { default as AThemeToggleMini } from "./primitives/AThemeToggleMini.svelte";
// Navigation Components
export { default as ANavbar } from "./nav/ANavbar.svelte";
export { default as AFooter } from "./nav/AFooter.svelte";
// Form Components
export { default as ACommentForm } from "./forms/ACommentForm.svelte";
export { default as AMarkupForm } from "./forms/AMarkupForm.svelte";
export { default as ASearchForm } from "./forms/ASearchForm.svelte";
export { default as ATextareaWithPreview } from "./forms/ATextareaWithPreview.svelte";
// Card Components
export { default as AEventPreview } from "./cards/AEventPreview.svelte";
export { default as AProfilePreview } from "./cards/AProfilePreview.svelte";
// Reader Components
export { default as ATechBlock } from "./reader/ATechBlock.svelte";
export { default as ATechToggle } from "./reader/ATechToggle.svelte";

64
src/lib/a/nav/AFooter.svelte

@ -0,0 +1,64 @@ @@ -0,0 +1,64 @@
<script>
/**
* @fileoverview AFooter Component - Alexandria
*
* A standardized footer component with copyright information and navigation links.
* Uses Flowbite's Footer components with Alexandria-specific styling and content.
* This component has no props - it renders a fixed footer structure.
*
* @component
* @category Navigation
*
* @example
* ```svelte
* <AFooter />
* ```
*
* @example Place at bottom of layout
* ```svelte
* <main>
* <!-- page content -->
* </main>
* <AFooter />
* ```
*
* @features
* - Copyright notice with GitCitadel attribution
* - Navigation links to About and Contact pages
* - Responsive layout that adapts to screen size
* - Consistent styling with Alexandria theme
* - Links to creator's nostr profile
*
* @accessibility
* - Semantic footer structure
* - Keyboard accessible navigation links
* - Screen reader friendly with proper link text
* - Responsive design for various screen sizes
*
* @integration
* - Typically placed at the bottom of page layouts
* - Uses Flowbite Footer components for consistency
* - Matches Alexandria's overall design system
*/
import {
Footer,
FooterCopyright,
FooterLink,
FooterLinkGroup,
} from "flowbite-svelte";
</script>
<Footer class="m-2">
<FooterCopyright
href="/events?id=npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz"
by="GitCitadel"
year={2025}
/>
<FooterLinkGroup
class="mt-3 flex flex-wrap items-center text-sm text-gray-500 sm:mt-0 dark:text-gray-400"
>
<FooterLink href="/about">About</FooterLink>
<FooterLink href="/contact">Contact</FooterLink>
</FooterLinkGroup>
</Footer>

148
src/lib/a/nav/ANavbar.svelte

@ -0,0 +1,148 @@ @@ -0,0 +1,148 @@
<script lang="ts">
/**
* @fileoverview ANavbar Component - Alexandria
*
* The main navigation bar component with responsive menu, user profile, and theme controls.
* Provides primary navigation for the Alexandria application with mega menu functionality.
* This component has no props - it renders a fixed navigation structure.
*
* @component
* @category Navigation
*
* @example
* ```svelte
* <ANavbar />
* ```
*
* @example Place at top of main layout
* ```svelte
* <ANavbar />
* <main>
* <!-- page content -->
* </main>
* ```
*
* @features
* - Responsive hamburger menu for mobile devices
* - Mega menu with categorized navigation items
* - User profile integration with sign-in/out functionality
* - Dark mode toggle
* - Brand logo and home link
* - Organized menu sections (Browse, Create, Learn, etc.)
* - Helpful descriptions for each navigation item
*
* @navigation
* - Browse: Publications, Events, Visualize
* - Create: Compose notes, Publish events
* - Learn: Getting Started, Relay Status
* - Profile: User-specific actions and settings
*
* @accessibility
* - Semantic navigation structure with proper ARIA attributes
* - Keyboard accessible menu items and dropdowns
* - Screen reader friendly with descriptive labels
* - Focus management for mobile menu
* - Proper heading hierarchy
*
* @integration
* - Uses Flowbite Navbar components for consistency
* - Integrates with Alexandria's theme system
* - Connects to user authentication state
* - Responsive design adapts to all screen sizes
*/
import {
DarkMode,
Navbar,
NavLi,
NavUl,
NavHamburger,
NavBrand,
MegaMenu,
P,
Heading
} from "flowbite-svelte";
import Profile from "$components/util/Profile.svelte";
import { ChevronDownOutline } from "flowbite-svelte-icons";
let menu2 = [
{ name: "Publications", href: "/", help: "Browse publications" },
{ name: "Events", href: "/events", help: "Search and engage with events" },
{
name: "Visualize",
href: "/visualize",
help: "Visualize connections between publications and authors",
},
{
name: "Compose notes",
href: "/new/compose",
help: "Create individual notes (30041 events)",
},
{
name: "Publish events",
href: "/events/compose",
help: "Create any kind",
},
{
name: "Getting Started",
href: "/start",
help: "A quick overview and tutorial",
},
{
name: "Relay Status",
href: "/about/relay-stats",
help: "Relay status and monitoring",
},
{ name: "About", href: "/about", help: "About the project" },
{
name: "Contact",
href: "/contact",
help: "Contact us or submit a bug report",
},
];
</script>
<Navbar
id="navi"
class="fixed start-0 top-0 z-50 flex flex-row bg-primary-0 dark:bg-primary-1000"
navContainerClass="flex-row items-center !p-0"
>
<NavBrand href="/" >
<div class="flex flex-col">
<Heading class="text-2xl font-bold mb-0">
Alexandria
</Heading>
<P class="text-xs font-semibold tracking-wide max-sm:max-w-[11rem] mb-0">
READ THE ORIGINAL. MAKE CONNECTIONS. CULTIVATE KNOWLEDGE.
</P>
</div>
</NavBrand>
<div class="flex md:order-2">
<Profile />
<NavHamburger />
</div>
<NavUl class="order-1 ml-auto items-center" classes={{ ul: "items-center" }}>
<NavLi class="cursor-pointer">
Explore<ChevronDownOutline
class="text-primary-800 ms-2 inline h-6 w-6 dark:text-white"
/>
</NavLi>
<MegaMenu items={menu2}>
{#snippet children({ item })}
<a
href={item.href}
class="block h-full rounded-lg p-3 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<div class="font-semibold dark:text-white">{item.name}</div>
<span class="text-sm font-light text-gray-500 dark:text-gray-400"
>{item.help}</span
>
</a>
{/snippet}
</MegaMenu>
<DarkMode />
</NavUl>
</Navbar>

370
src/lib/a/parse-components.js

@ -0,0 +1,370 @@ @@ -0,0 +1,370 @@
#!/usr/bin/env node
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* @typedef {Object} PropDefinition
* @property {string} name
* @property {string[]} type
* @property {string | null | undefined} default
* @property {string} description
* @property {boolean} required
*/
/**
* @typedef {Object} ExampleDefinition
* @property {string} name
* @property {string} code
*/
/**
* @typedef {Object} ComponentDefinition
* @property {string} name
* @property {string} description
* @property {string} category
* @property {PropDefinition[]} props
* @property {string[]} events
* @property {string[]} slots
* @property {ExampleDefinition[]} examples
* @property {string[]} features
* @property {string[]} accessibility
* @property {string} since
*/
/**
* Parse TSDoc comments from Svelte component files
*/
class ComponentParser {
constructor() {
/** @type {ComponentDefinition[]} */
this.components = [];
}
/**
* Extract TSDoc block from script content
* @param {string} content
* @returns {string | null}
*/
extractTSDoc(content) {
const scriptMatch = content.match(/<script[^>]*>([\s\S]*?)<\/script>/);
if (!scriptMatch) return null;
const scriptContent = scriptMatch[1];
const tsDocMatch = scriptContent.match(/\/\*\*\s*([\s\S]*?)\*\//);
if (!tsDocMatch) return null;
return tsDocMatch[1];
}
/**
* Parse TSDoc content into structured data
* @param {string} tsDocContent
* @returns {ComponentDefinition}
*/
parseTSDoc(tsDocContent) {
const lines = tsDocContent
.split("\n")
.map((line) => line.replace(/^\s*\*\s?/, "").trim());
/** @type {ComponentDefinition} */
const component = {
name: "",
description: "",
category: "",
props: [],
events: [],
slots: [],
examples: [],
features: [],
accessibility: [],
since: "1.0.0", // Default version
};
let currentSection = "description";
let currentExample = "";
let inCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip empty lines
if (!line) continue;
// Handle @tags
if (line.startsWith("@fileoverview")) {
const nameMatch = line.match(/@fileoverview\s+(\w+)\s+Component/);
if (nameMatch) {
component.name = nameMatch[1];
}
const descMatch = lines
.slice(i + 1)
.find((l) => l && !l.startsWith("@"))
?.trim();
if (descMatch) {
component.description = descMatch;
}
continue;
}
if (line.startsWith("@category")) {
component.category = line.replace("@category", "").trim();
continue;
}
if (line.startsWith("@prop")) {
const prop = this.parseProp(line);
if (prop) component.props.push(prop);
continue;
}
if (line.startsWith("@example")) {
currentSection = "example";
currentExample = line.replace("@example", "").trim();
if (currentExample) {
currentExample += "\n";
}
continue;
}
if (line.startsWith("@features")) {
currentSection = "features";
continue;
}
if (line.startsWith("@accessibility")) {
currentSection = "accessibility";
continue;
}
if (line.startsWith("@since")) {
component.since = line.replace("@since", "").trim();
continue;
}
// Handle content based on current section
if (currentSection === "example") {
if (line === "```svelte" || line === "```") {
inCodeBlock = !inCodeBlock;
if (!inCodeBlock && currentExample.trim()) {
component.examples.push({
name: currentExample.split("\n")[0] || "Example",
code: currentExample.trim(),
});
currentExample = "";
}
continue;
}
if (inCodeBlock) {
currentExample += line + "\n";
} else if (line.startsWith("@")) {
// New section started
i--; // Reprocess this line
currentSection = "description";
} else if (line && !line.startsWith("```")) {
currentExample = line + "\n";
}
continue;
}
if (currentSection === "features" && line.startsWith("-")) {
component.features.push(line.substring(1).trim());
continue;
}
if (currentSection === "accessibility" && line.startsWith("-")) {
component.accessibility.push(line.substring(1).trim());
}
}
return component;
}
/**
* Parse a @prop line into structured prop data
* @param {string} propLine
* @returns {PropDefinition | null}
*/
parseProp(propLine) {
// First, extract the type by finding balanced braces
const propMatch = propLine.match(/@prop\s+\{/);
if (!propMatch || propMatch.index === undefined) return null;
// Find the closing brace for the type
let braceCount = 1;
let typeEndIndex = propMatch.index + propMatch[0].length;
const lineAfterType = propLine.substring(typeEndIndex);
for (let i = 0; i < lineAfterType.length; i++) {
if (lineAfterType[i] === "{") braceCount++;
if (lineAfterType[i] === "}") braceCount--;
if (braceCount === 0) {
typeEndIndex += i;
break;
}
}
const typeStr = propLine
.substring(propMatch.index + propMatch[0].length, typeEndIndex)
.trim();
const restOfLine = propLine.substring(typeEndIndex + 1).trim();
// Parse the rest: [name=default] or name - description
const restMatch = restOfLine.match(
/(\[?)([^[\]\s=-]+)(?:=([^\]]*))?]?\s*-?\s*(.*)/,
);
if (!restMatch) return null;
const [, isOptional, name, defaultValue, description] = restMatch;
// Parse type - handle union types like "xs" | "s" | "m" | "l"
let type = [typeStr.trim()];
if (typeStr.includes("|") && !typeStr.includes("<")) {
type = typeStr.split("|").map((t) => t.trim().replace(/"/g, ""));
} else if (typeStr.includes('"') && !typeStr.includes("<")) {
// Handle quoted literal types
const literals = typeStr.match(/"[^"]+"/g);
if (literals) {
type = literals.map((l) => l.replace(/"/g, ""));
}
}
return {
name: name.trim(),
type: type,
default: defaultValue
? defaultValue.trim()
: isOptional
? undefined
: null,
description: description.trim(),
required: !isOptional,
};
}
/**
* Process a single Svelte file
* @param {string} filePath
* @returns {ComponentDefinition | null}
*/
processFile(filePath) {
try {
const content = fs.readFileSync(filePath, "utf-8");
const tsDocContent = this.extractTSDoc(content);
if (!tsDocContent) {
console.warn(`No TSDoc found in ${filePath}`);
return null;
}
const component = this.parseTSDoc(tsDocContent);
// If no name was extracted, use filename
if (!component.name) {
component.name = path.basename(filePath, ".svelte");
}
return component;
} catch (error) {
const errorMessage = error instanceof Error
? error.message
: String(error);
console.error(`Error processing ${filePath}:`, errorMessage);
return null;
}
}
/**
* Process all Svelte files in a directory recursively
* @param {string} dirPath
*/
processDirectory(dirPath) {
const items = fs.readdirSync(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
this.processDirectory(itemPath);
} else if (item.endsWith(".svelte")) {
const component = this.processFile(itemPath);
if (component) {
this.components.push(component);
}
}
}
}
/**
* Generate the final JSON output
*/
generateOutput() {
// Sort components by category and name
this.components.sort((a, b) => {
if (a.category !== b.category) {
return a.category.localeCompare(b.category);
}
return a.name.localeCompare(b.name);
});
return {
library: "Alexandria Component Library",
version: "1.0.0",
generated: new Date().toISOString(),
totalComponents: this.components.length,
categories: [...new Set(this.components.map((c) => c.category))].sort(),
components: this.components,
};
}
}
/**
* Main execution
*/
function main() {
const parser = new ComponentParser();
const aFolderPath = __dirname;
console.log("Parsing Alexandria components...");
console.log(`Source directory: ${aFolderPath}`);
if (!fs.existsSync(aFolderPath)) {
console.error(`Directory not found: ${aFolderPath}`);
process.exit(1);
}
// Process all components
parser.processDirectory(aFolderPath);
// Generate output
const output = parser.generateOutput();
// Write to file in the same directory (/a folder)
const outputPath = path.join(__dirname, "alexandria-components.json");
fs.writeFileSync(outputPath, JSON.stringify(output, null, 2));
console.log(`\n✅ Successfully parsed ${output.totalComponents} components`);
console.log(`📁 Categories: ${output.categories.join(", ")}`);
console.log(`💾 Output saved to: ${outputPath}`);
// Print summary
console.log("\n📊 Component Summary:");
/** @type {Record<string, number>} */
const categoryCounts = {};
output.components.forEach((c) => {
categoryCounts[c.category] = (categoryCounts[c.category] || 0) + 1;
});
Object.entries(categoryCounts).forEach(([category, count]) => {
console.log(` ${category}: ${count} components`);
});
}
// Run the script
main();

70
src/lib/a/primitives/AAlert.svelte

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
<script lang="ts">
/**
* @fileoverview AAlert Component - Alexandria
*
* A themed alert component based on Flowbite's Alert with consistent styling.
* Provides notifications, warnings, and informational messages with optional dismissal.
*
* @component
* @category Primitives
*
* @prop {string} [color] - Alert color theme (success, warning, error, info, etc.)
* @prop {boolean} [dismissable] - Whether alert can be dismissed by user
* @prop {snippet} children - Main alert content (required)
* @prop {snippet} [title] - Optional title section
* @prop {string} [classes] - Additional CSS classes to apply
*
* @example
* ```svelte
* <AAlert color="success" dismissable={true}>
* {#snippet title()}Success!{/snippet}
* {#snippet children()}Your changes have been saved.{/snippet}
* </AAlert>
* ```
*
* @example Simple alert
* ```svelte
* <AAlert color="info">
* {#snippet children()}This is an informational message.{/snippet}
* </AAlert>
* ```
*
* @example Alert with title and custom classes
* ```svelte
* <AAlert color="warning" classes="mt-4" dismissable={true}>
* {#snippet title()}Warning{/snippet}
* {#snippet children()}Please check your input.{/snippet}
* </AAlert>
* ```
*
* @features
* - Consistent "leather" theme styling
* - Built on Flowbite Alert component
* - Support for custom colors and dismissal
* - Flexible content with title and body sections
*
* @accessibility
* - Inherits Flowbite's accessibility features
* - Proper ARIA attributes for alerts
* - Keyboard accessible dismiss button when dismissable
*/
import { Alert } from "flowbite-svelte";
let { color, dismissable, children, title, classes } = $props<{
color?: string;
dismissable?: boolean;
children?: any;
title?: any;
classes?: string;
}>();
</script>
<Alert {color} {dismissable} class="alert-leather mb-4 {classes}">
{#if title}
<div class="flex">
<span class="text-lg font-medium">{@render title()}</span>
</div>
{/if}
{@render children()}
</Alert>

103
src/lib/a/primitives/ADetails.svelte

@ -0,0 +1,103 @@ @@ -0,0 +1,103 @@
<script lang="ts">
/**
* @fileoverview ADetails Component - Alexandria
*
* A collapsible details/summary element with enhanced styling and tech-aware functionality.
* Integrates with the techStore to automatically hide technical details based on user preference.
*
* @component
* @category Primitives
*
* @prop {string} [summary=""] - The summary text shown in the collapsible header
* @prop {boolean} [tech=false] - Whether this contains technical content (affects visibility)
* @prop {boolean} [defaultOpen=false] - Whether details should be open by default
* @prop {boolean} [forceHide=false] - Force hide content even when tech mode is on
* @prop {string} [class=""] - Additional CSS classes to apply
* @prop {snippet} children - The content to show/hide in the details body (required, via default slot)
*
* @example
* ```svelte
* <ADetails summary="Event Details" tech={true}>
* <p>Technical event information here...</p>
* </ADetails>
* ```
*
* @example Regular details block
* ```svelte
* <ADetails summary="More Information">
* <p>Additional content here...</p>
* </ADetails>
* ```
*
* @example Technical details with custom styling
* ```svelte
* <ADetails summary="Raw Event Data" tech={true} class="border-red-200">
* <pre>{JSON.stringify(event, null, 2)}</pre>
* </ADetails>
* ```
*
* @features
* - Respects global techStore setting for tech content
* - Animated chevron icon indicates open/closed state
* - "Technical" badge for tech-related details
* - Consistent themed styling with hover effects
* - Auto-closes tech details when techStore is disabled
*
* @accessibility
* - Uses semantic HTML details/summary elements
* - Keyboard accessible (Enter/Space to toggle)
* - Screen reader friendly with proper labeling
* - Clear visual indicators for state changes
*/
import { showTech } from "$lib/stores/techStore";
let {
summary = "",
tech = false,
defaultOpen = false,
forceHide = false,
class: className = "",
} = $props();
let open = $derived(defaultOpen);
$effect(() => {
if (tech && !$showTech) open = false;
});
function onToggle(e: Event) {
const el = e.currentTarget as HTMLDetailsElement;
open = el.open;
}
</script>
<details
{open}
ontoggle={onToggle}
class={`group rounded-lg border border-muted/20 bg-surface ${className}`}
data-kind={tech ? "tech" : "general"}
>
<summary
class="flex items-center gap-2 cursor-pointer list-none px-3 py-2 rounded-lg select-none hover:bg-primary/10"
>
<svg
class={`h-4 w-4 transition-transform ${open ? "rotate-90" : ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"><path d="M9 18l6-6-6-6" /></svg
>
<span class="font-medium">{summary}</span>
{#if tech}<span
class="ml-2 text-[10px] uppercase tracking-wide rounded px-1.5 py-0.5 border border-primary/30 text-primary bg-primary/5"
>Technical</span
>{/if}
<span class="ml-auto text-xs opacity-60 group-open:opacity-50"
>{open ? "Hide" : "Show"}</span
>
</summary>
{#if !(tech && !$showTech && forceHide)}<div
class="px-3 pb-3 pt-1 text-[0.95rem] leading-6"
>
<slot />
</div>{/if}
</details>

58
src/lib/a/primitives/AInput.svelte

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
<script lang="ts">
/**
* @fileoverview AInput Component - Alexandria
*
* A styled input field with consistent theming and focus states.
* Provides a standardized text input with Alexandria's design system.
*
* @component
* @category Primitives
*
* @prop {string} value - The input value (bindable)
* @prop {string} [class=""] - Additional CSS classes to apply
* @prop {string} [placeholder=""] - Placeholder text for the input
*
* @example
* ```svelte
* <AInput bind:value={searchQuery} placeholder="Enter search term..." />
* ```
*
* @example Basic input
* ```svelte
* <AInput bind:value={name} placeholder="Your name" />
* ```
*
* @example Custom styled input
* ```svelte
* <AInput
* bind:value={email}
* placeholder="Email address"
* class="max-w-md border-blue-300"
* />
* ```
*
* @features
* - Consistent themed styling with focus rings
* - Full width by default with customizable classes
* - Smooth focus transitions and hover effects
* - Integrates with Alexandria's color system
*
* @accessibility
* - Proper focus management with visible focus rings
* - Keyboard accessible
* - Supports all standard input attributes
* - Screen reader compatible
*/
let { value = $bindable(""), class: className = "", placeholder = "" } = $props<{
value?: string;
class?: string;
placeholder?: string;
}>();
</script>
<input
class={`w-full h-10 px-3 rounded-md border border-muted/30 bg-surface text-text placeholder:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/40 ${className}`}
bind:value
{placeholder}
/>

79
src/lib/a/primitives/ANostrBadge.svelte

@ -0,0 +1,79 @@ @@ -0,0 +1,79 @@
<script lang="ts">
/**
* @fileoverview ANostrBadge Component - Alexandria
*
* Displays a nostr badge (NIP-58) with image or fallback text representation.
* Shows badge thumbnails with proper sizing and accessibility features.
*
* @component
* @category Primitives
*
* @prop {DisplayBadge} badge - Badge object containing title, thumbUrl, etc. (required)
* @prop {"xs" | "s" | "m" | "l"} [size="s"] - Badge size (xs: 16px, s: 24px, m: 32px, l: 48px)
*
* @example
* ```svelte
* <ANostrBadge {badge} size="m" />
* ```
*
* @example Badge with image
* ```svelte
* <ANostrBadge badge={{title: "Developer", thumbUrl: "/badge.png"}} size="l" />
* ```
*
* @example Badge without image (shows first letter)
* ```svelte
* <ANostrBadge badge={{title: "Contributor"}} size="s" />
* ```
*
* @example In a list of badges
* ```svelte
* {#each userBadges as badge}
* <ANostrBadge {badge} size="xs" />
* {/each}
* ```
*
* @typedef {Object} DisplayBadge
* @property {string} title - Badge title
* @property {string} [thumbUrl] - Optional thumbnail URL
*
* @features
* - Displays badge thumbnail image when available
* - Fallback to first letter of title when no image
* - Multiple size options for different contexts
* - Lazy loading for performance
* - Proper aspect ratio and object-fit
*
* @accessibility
* - Alt text for badge images
* - Title attribute for hover information
* - Proper semantic structure
* - Loading and decoding optimizations
*/
import type { DisplayBadge } from "$lib/nostr/nip58";
let { badge, size = "s" }: { badge: DisplayBadge; size?: "xs" | "s" | "m" | "l" } = $props();
const px = { xs: 16, s: 24, m: 32, l: 48 }[size];
</script>
<span class="inline-flex items-center" title={badge.title}>
{#if badge.thumbUrl}
<img
src={badge.thumbUrl}
alt={badge.title}
width={px}
height={px}
loading="lazy"
decoding="async"
class="rounded-md border border-muted/20 object-cover"
/>
{:else}
<span
class="grid place-items-center rounded-md border border-muted/20 bg-surface text-xs"
style={`width:${px}px;height:${px}px`}
>
{badge.title.slice(0, 1)}
</span>
{/if}
</span>

66
src/lib/a/primitives/ANostrBadgeRow.svelte

@ -0,0 +1,66 @@ @@ -0,0 +1,66 @@
<script lang="ts">
/**
* @fileoverview ANostrBadgeRow Component - Alexandria
*
* Displays a horizontal row of nostr badges with optional limit and overflow indicator.
* Uses ANostrBadge components to render individual badges in a flex layout.
*
* @component
* @category Primitives
*
* @prop {DisplayBadge[]} [badges=[]] - Array of badge objects to display
* @prop {"xs" | "s" | "m" | "l"} [size="s"] - Size for all badges in the row
* @prop {number} [limit=6] - Maximum number of badges to show before truncating
*
* @example
* ```svelte
* <ANostrBadgeRow badges={userBadges} size="m" limit={4} />
* ```
*
* @example Show all badges
* ```svelte
* <ANostrBadgeRow badges={allBadges} limit={999} />
* ```
*
* @example Limited display with small badges
* ```svelte
* <ANostrBadgeRow badges={userBadges} size="xs" limit={3} />
* ```
*
* @example Profile header with medium badges
* ```svelte
* <ANostrBadgeRow badges={profileBadges} size="m" limit={5} />
* ```
*
* @features
* - Responsive flex layout with wrapping
* - Configurable display limit with overflow counter
* - Consistent spacing between badges
* - Shows "+N" indicator when badges exceed limit
* - Uses badge.def.id as key for efficient rendering
*
* @accessibility
* - Inherits accessibility from ANostrBadge components
* - Clear visual hierarchy with proper spacing
* - Overflow indicator provides context about hidden badges
*/
import type { DisplayBadge } from "$lib/nostr/nip58";
import ANostrBadge from "./ANostrBadge.svelte";
let { badges = [], size = "s", limit = 6 }: { badges?: DisplayBadge[]; size?: "xs" | "s" | "m" | "l"; limit?: number } = $props();
const shown = () => badges.slice(0, limit);
</script>
<div class="flex flex-wrap gap-1.5 items-center">
{#each shown() as b (b.def.id)}
<ANostrBadge badge={b} {size} />
{/each}
{#if badges.length > limit}
<span
class="text-[10px] px-1.5 py-0.5 rounded-md border border-muted/30 bg-surface/70"
>
+{badges.length - limit}
</span>
{/if}
</div>

218
src/lib/a/primitives/ANostrUser.svelte

@ -0,0 +1,218 @@ @@ -0,0 +1,218 @@
<script lang="ts">
/**
* @fileoverview ANostrUser Component - Alexandria
*
* Displays a nostr user with avatar, display name, npub, NIP-05 verification, and badges.
* Provides a comprehensive user representation with configurable display options.
*
* @component
* @category Primitives
*
* @prop {string} npub - The user's npub (required)
* @prop {string} [pubkey] - The user's public key (for NIP-05 verification)
* @prop {NostrProfile} [profile] - User profile metadata
* @prop {"sm" | "md" | "lg"} [size="md"] - Component size variant
* @prop {boolean} [showNpub=true] - Whether to show the shortened npub
* @prop {boolean} [showBadges=true] - Whether to display user badges
* @prop {boolean} [verifyNip05=true] - Whether to verify NIP-05 identifier
* @prop {boolean} [nip05Verified] - Override NIP-05 verification status
* @prop {DisplayBadge[] | null} [nativeBadges] - User's badges to display
* @prop {number} [badgeLimit=6] - Maximum badges to show
* @prop {string} [href] - Optional link URL (makes component clickable)
* @prop {string} [class=""] - Additional CSS classes
*
* @example
* ```svelte
* <ANostrUser
* {npub}
* {pubkey}
* {profile}
* size="lg"
* showBadges={true}
* />
* ```
*
* @example Basic user display
* ```svelte
* <ANostrUser {npub} {profile} />
* ```
*
* @example Large user card with all features
* ```svelte
* <ANostrUser
* {npub}
* {pubkey}
* {profile}
* size="lg"
* nativeBadges={userBadges}
* href="/profile/{npub}"
* />
* ```
*
* @example Compact user mention
* ```svelte
* <ANostrUser
* {npub}
* size="sm"
* showNpub={false}
* showBadges={false}
* />
* ```
*
* @features
* - Avatar display with fallback
* - Display name from profile or npub
* - NIP-05 verification with visual indicator
* - Badge integration via ANostrBadgeRow
* - Configurable sizing and display options
* - Optional linking capability
* - Loading states for verification
*
* @accessibility
* - Semantic user representation
* - Alt text for avatars
* - Screen reader friendly verification status
* - Keyboard accessible when linked
* - Proper focus management
*/
import type { NostrProfile } from "$lib/nostr/types";
import type { DisplayBadge } from "$lib/nostr/nip58";
import ANostrBadgeRow from "./ANostrBadgeRow.svelte";
import { shortenBech32, displayNameFrom } from "$lib/nostr/format";
import { verifyNip05 } from "$lib/nostr/nip05";
import { onMount } from "svelte";
let {
npub, // required
pubkey = undefined as string | undefined,
profile = undefined as NostrProfile | undefined,
size = "md" as "sm" | "md" | "lg",
showNpub = true,
showBadges = true,
verifyNip05: doVerify = true,
nip05Verified = undefined as boolean | undefined,
nativeBadges = null as DisplayBadge[] | null,
badgeLimit = 6,
href = undefined as string | undefined,
class: className = "",
} = $props();
// Derived view-model
let displayName = displayNameFrom(npub, profile);
let shortNpub = shortenBech32(npub, true);
let avatarUrl = profile?.picture ?? "";
let nip05 = profile?.nip05 ?? "";
// NIP-05 verify
let computedVerified = $state(false);
let loadingVerify = $state(false);
onMount(async () => {
if (nip05Verified !== undefined) {
computedVerified = nip05Verified;
return;
}
if (!doVerify || !nip05 || !pubkey) return;
loadingVerify = true;
computedVerified = await verifyNip05(nip05, pubkey);
loadingVerify = false;
});
// Sizing map
const sizes = {
sm: {
avatar: "h-6 w-6",
gap: "gap-2",
name: "text-sm",
meta: "text-[11px]",
},
md: {
avatar: "h-8 w-8",
gap: "gap-2.5",
name: "text-base",
meta: "text-xs",
},
lg: { avatar: "h-10 w-10", gap: "gap-3", name: "text-lg", meta: "text-sm" },
}[size];
</script>
{#if href}
<a {href} class={`inline-flex items-center ${sizes.gap} ${className}`}>
<Content />
</a>
{:else}
<div class={`inline-flex items-center ${sizes.gap} ${className}`}>
<Content />
</div>
{/if}
<!-- component content as a fragment (no extra <script> blocks, no JSX) -->
{#snippet Content()}
<span
class={`shrink-0 rounded-full overflow-hidden bg-muted/20 border border-muted/30 ${sizes.avatar}`}
>
{#if avatarUrl}
<img src={avatarUrl} alt="" class="h-full w-full object-cover" />
{:else}
<span class="h-full w-full grid place-items-center text-xs opacity-70">
{displayName.slice(0, 1).toUpperCase()}
</span>
{/if}
</span>
<span class="min-w-0">
<span class={`flex items-center gap-1 font-medium ${sizes.name}`}>
<span class="truncate">{displayName}</span>
{#if nip05 && (computedVerified || loadingVerify)}
<span
class="inline-flex items-center"
title={computedVerified ? `NIP-05 verified: ${nip05}` : "Verifying…"}
>
{#if computedVerified}
<!-- Verified check -->
<svg
class="h-4 w-4 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6L9 17l-5-5" />
</svg>
{:else}
<!-- Loading ring -->
<svg
class="h-4 w-4 animate-pulse opacity-70"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
</svg>
{/if}
</span>
{/if}
</span>
<span class={`flex items-center gap-2 text-muted/80 ${sizes.meta}`}>
{#if nip05}<span class="truncate" title={nip05}>{nip05}</span>{/if}
{#if showNpub}<span class="truncate opacity-80" title={npub}
>{shortNpub}</span
>{/if}
</span>
{#if showBadges}
<span class="mt-1 block">
<slot name="badges">
{#if nativeBadges}
<ANostrBadgeRow badges={nativeBadges} limit={badgeLimit} size="s" />
{/if}
</slot>
</span>
{/if}
</span>
{/snippet}

131
src/lib/a/primitives/APagination.svelte

@ -0,0 +1,131 @@ @@ -0,0 +1,131 @@
<script lang="ts">
/**
* @fileoverview APagination Component - Alexandria
*
* A pagination component for navigating through multiple pages of content.
* Provides previous/next navigation with page information and item counts.
*
* @component
* @category Primitives
*
* @prop {number} currentPage - Current page number (1-based, bindable)
* @prop {number} totalPages - Total number of pages available
* @prop {boolean} hasNextPage - Whether there is a next page available
* @prop {boolean} hasPreviousPage - Whether there is a previous page available
* @prop {number} [totalItems=0] - Total number of items across all pages
* @prop {string} [itemsLabel="items"] - Label for items (e.g., "posts", "events")
* @prop {string} [className=""] - Additional CSS classes to apply
*
* @example
* ```svelte
* <APagination
* bind:currentPage={page}
* totalPages={10}
* hasNextPage={page < 10}
* hasPreviousPage={page > 1}
* totalItems={100}
* itemsLabel="events"
* />
* ```
*
* @example Basic pagination
* ```svelte
* <APagination
* bind:currentPage={currentPage}
* totalPages={Math.ceil(totalEvents / pageSize)}
* hasNextPage={currentPage < totalPages}
* hasPreviousPage={currentPage > 1}
* />
* ```
*
* @example With custom item labels and styling
* ```svelte
* <APagination
* bind:currentPage={page}
* totalPages={pageCount}
* hasNextPage={hasNext}
* hasPreviousPage={hasPrev}
* totalItems={eventCount}
* itemsLabel="nostr events"
* className="border-2 border-primary"
* />
* ```
*
* @features
* - Bindable current page for reactive updates
* - Previous/Next button navigation
* - Page information display with item counts
* - Disabled state for unavailable navigation
* - Only renders when totalPages > 1
*
* @accessibility
* - Keyboard accessible buttons
* - Disabled buttons have proper cursor and opacity
* - Clear page information for screen readers
* - Semantic button elements
*/
type Props = {
currentPage: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
totalItems?: number;
itemsLabel?: string;
className?: string;
};
let {
currentPage = $bindable<number>(1),
totalPages = 1,
hasNextPage = false,
hasPreviousPage = false,
totalItems = 0,
itemsLabel = "items",
className = "",
} = $props<{
currentPage: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
totalItems?: number;
itemsLabel?: string;
className?: string;
}>();
function next() {
if (hasNextPage) currentPage = currentPage + 1;
}
function previous() {
if (hasPreviousPage) currentPage = currentPage - 1;
}
</script>
{#if totalPages > 1}
<div
class={`mt-4 flex flex-row items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg ${className}`}
>
<div class="text-sm !mb-0 text-gray-600 dark:text-gray-400">
Page {currentPage} of {totalPages} ({totalItems} total {itemsLabel})
</div>
<div class="flex flex-row items-center gap-2">
<button
class="px-3 py-1 !mb-0 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={previous}
disabled={!hasPreviousPage}
>
Previous
</button>
<span class="!mb-0 text-sm text-gray-600 dark:text-gray-400">
{currentPage} / {totalPages}
</span>
<button
class="px-3 py-1 !mb-0 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={next}
disabled={!hasNextPage}
>
Next
</button>
</div>
</div>
{/if}

77
src/lib/a/primitives/AThemeToggleMini.svelte

@ -0,0 +1,77 @@ @@ -0,0 +1,77 @@
<script lang="ts">
/**
* @fileoverview AThemeToggleMini Component - Alexandria
*
* A compact theme selector dropdown that allows users to switch between available themes.
* Integrates with the themeStore to persist and apply theme changes across the app.
* This component has no props - it manages its own state internally.
*
* @component
* @category Primitives
*
* @example
* ```svelte
* <AThemeToggleMini />
* ```
*
* @example Place in navigation or settings area
* ```svelte
* <div class="flex items-center gap-4">
* <span>Appearance:</span>
* <AThemeToggleMini />
* </div>
* ```
*
* @features
* - Dropdown with radio buttons for theme selection
* - Automatic persistence via themeStore
* - Shows current theme in button label
* - Available themes: Light, Ocean, Forest
* - Instant theme application on selection
*
* @accessibility
* - Keyboard accessible dropdown navigation
* - Radio buttons for clear selection state
* - Screen reader friendly with proper labels
* - Focus management within dropdown
*
* @integration
* - Works with Alexandria's theme system
* - Automatically applies CSS custom properties
* - Persists selection in localStorage
* - Updates all themed components instantly
*/
import { ChevronDownOutline } from "flowbite-svelte-icons";
import { Button, Dropdown, DropdownGroup, Radio } from "flowbite-svelte";
import { onMount } from "svelte";
import { setTheme, theme as themeStore } from "$lib/stores/themeStore";
let theme = $state<string>("light");
onMount(() => {
return themeStore.subscribe((v) => (theme = String(v)));
});
// Persist and apply whenever the selection changes
$effect(() => {
setTheme(theme);
});
</script>
<Button>
Theme {theme}<ChevronDownOutline class="ms-2 inline h-6 w-6" />
</Button>
<Dropdown simple class="w-44">
<DropdownGroup class="space-y-3 p-3">
<li>
<Radio name="group1" bind:group={theme} value="light">Light</Radio>
</li>
<li>
<Radio name="group1" bind:group={theme} value="ocean">Ocean</Radio>
</li>
<li>
<Radio name="group1" bind:group={theme} value="forrest">Forrest</Radio>
</li>
</DropdownGroup>
</Dropdown>

64
src/lib/a/reader/ATechBlock.svelte

@ -0,0 +1,64 @@ @@ -0,0 +1,64 @@
<script lang="ts">
/**
* @fileoverview ATechBlock Component - Alexandria
*
* A collapsible container for technical details that can be shown/hidden based on user preference.
* Works with the ATechToggle component and techStore to manage visibility of developer information.
*
* @component
* @category Reader
*
* @prop {string} [title="Technical details"] - The title shown when block is hidden
* @prop {string} [className=""] - Additional CSS classes to apply
* @prop {snippet} content - The technical content to show/hide (required)
*
* @example
* ```svelte
* <ATechBlock title="Raw Event Data">
* {#snippet content()}
* <pre>{JSON.stringify(event, null, 2)}</pre>
* {/snippet}
* </ATechBlock>
* ```
*
* @example Custom title and styling
* ```svelte
* <ATechBlock title="Event JSON" className="border-red-200">
* {#snippet content()}
* <code>{eventData}</code>
* {/snippet}
* </ATechBlock>
* ```
*
* @features
* - Respects global showTech setting from techStore
* - Individual reveal button when globally hidden
* - Accessible with proper ARIA attributes
* - Useful for hiding nostr event data, debug info, etc.
*
* @accessibility
* - Keyboard accessible reveal button
* - Screen reader friendly with descriptive labels
* - Proper semantic HTML structure
*/
import { showTech } from "$lib/stores/techStore.ts";
let revealed = $state(false);
let { title = "Technical details", className = "", content } = $props();
let hidden = $derived(!$showTech && !revealed);
</script>
{#if hidden}
<div
class="rounded-md border border-dashed border-muted/40 bg-surface/60 px-3 py-2 my-6 flex items-center gap-3 {className}"
>
<span class="text-xs opacity-70">{title} hidden</span>
<button
class="ml-auto text-sm underline hover:no-underline"
onclick={() => (revealed = true)}>Reveal this block</button
>
</div>
{:else}
{@render content()}
{/if}

36
src/lib/a/reader/ATechToggle.svelte

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
<script lang="ts">
/**
* @fileoverview ATechToggle Component - Alexandria
*
* A toggle switch that controls the visibility of technical details throughout the app.
* Works in conjunction with ATechBlock components to show/hide nostr-specific developer information.
*
* @component
* @category Reader
*
* @example
* ```svelte
* <ATechToggle />
* ```
*
* @features
* - Persists setting in localStorage via techStore
* - Accessible with ARIA labels and keyboard navigation
* - Automatically updates all ATechBlock components when toggled
* - Useful for nostr developers who want to see raw event data and technical details
*
* @accessibility
* - Uses proper ARIA labeling
* - Keyboard accessible (Space/Enter to toggle)
* - Screen reader friendly with descriptive label
*/
import { showTech } from "$lib/stores/techStore.ts";
import { Toggle, P } from "flowbite-svelte";
let label = "Show technical details";
</script>
<div class="inline-flex items-center gap-2 select-none my-3">
<Toggle bind:checked={$showTech} aria-label={label} />
<P class="text-sm">{label}</P>
</div>

229
src/lib/components/CommentBox.svelte

@ -1,10 +1,14 @@ @@ -1,10 +1,14 @@
<script lang="ts">
import { Button, Textarea, Alert, Modal, Input } from "flowbite-svelte";
import { Button, Alert, Modal, Input, ToolbarButton } from "flowbite-svelte";
import { UserOutline } from "flowbite-svelte-icons";
import { nip19 } from "nostr-tools";
import { toNpub } from "$lib/utils/nostrUtils";
import { searchProfiles } from "$lib/utils/search_utility";
import type { NostrProfile } from "$lib/utils/search_types";
import type {
NostrProfile,
} from "$lib/utils/search_utility";
import { userStore } from "$lib/stores/userStore";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import {
@ -17,7 +21,8 @@ @@ -17,7 +21,8 @@
import { tick } from "svelte";
import { goto } from "$app/navigation";
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
import { ACommentForm } from "$lib/a";
import { AtSign } from "@lucide/svelte";
const props = $props<{
event: NDKEvent;
@ -155,7 +160,7 @@ @@ -155,7 +160,7 @@
async function handleSubmit(
useOtherRelays = false,
useSecondaryRelays = false,
useSecondaryRelays = false
) {
isSubmitting = true;
error = null;
@ -372,16 +377,21 @@ @@ -372,16 +377,21 @@
}
</script>
{#snippet commentExtensions()}
<ToolbarButton title="Mention" color="dark" size="md" onclick={() => { showMentionModal = true; }}><AtSign size={24} /></ToolbarButton>
<ToolbarButton title="Insert Wikilink" color="dark" size="md" onclick={() => { showWikilinkModal = true; }}>
<span class="icon-wiki">[[ ]]</span>
</ToolbarButton>
{/snippet}
<div class="w-full space-y-4">
<div class="flex flex-wrap gap-2">
{#each markupButtons as button}
<Button size="xs" onclick={button.action}>{button.label}</Button>
{/each}
<Button size="xs" color="alternative" onclick={removeFormatting}
>Remove Formatting</Button
>
<Button size="xs" color="alternative" onclick={clearForm}>Clear</Button>
</div>
<ACommentForm
bind:content={content}
{isSubmitting}
onSubmit={() => handleSubmit()}
extensions={commentExtensions}
/>
<!-- Mention Modal -->
<Modal
@ -436,81 +446,85 @@ @@ -436,81 +446,85 @@
>
<ul class="space-y-1 p-2">
{#each mentionResults as profile}
<button
type="button"
class="w-full text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 p-2 rounded flex items-center gap-3"
onclick={() => selectMention(profile)}
>
{#if profile.isInUserLists}
<div
class="flex-shrink-0 w-6 h-6 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists"
>
<svg
class="w-4 h-4 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
<li>
<div
role="button"
tabindex="0"
class="w-full text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 p-2 rounded flex items-center gap-3"
onclick={() => selectMention(profile)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectMention(profile); } }}
>
{#if profile.isInUserLists}
<div
class="flex-shrink-0 w-6 h-6 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{:else if profile.pubkey && communityStatus[profile.pubkey]}
<div
class="flex-shrink-0 w-6 h-6 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-4 h-4 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
<svg
class="w-4 h-4 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{:else if profile.pubkey && communityStatus[profile.pubkey]}
<div
class="flex-shrink-0 w-6 h-6 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else}
<div class="flex-shrink-0 w-6 h-6"></div>
{/if}
{#if profile.picture}
<img
src={profile.picture}
alt="Profile"
class="w-8 h-8 rounded-full object-cover flex-shrink-0"
/>
{:else}
<div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0 flex items-center justify-center">
<UserOutline class="w-4 h-4 text-gray-600 dark:text-gray-300" />
</div>
{/if}
<div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold truncate">
{profile.displayName || profile.name || "anon"}
</span>
{#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1">
<svg
class="inline w-4 h-4 text-primary-500"
fill="none"
stroke="currentColor"
stroke-width="2"
class="w-4 h-4 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/></svg
>
{profile.nip05}
</span>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else}
<div class="flex-shrink-0 w-6 h-6"></div>
{/if}
<span class="text-xs text-gray-400 font-mono truncate"
>{shortenNpub(profile.pubkey)}</span
>
{#if profile.picture}
<img
src={profile.picture}
alt="Profile"
class="w-8 h-8 rounded-full object-cover flex-shrink-0"
/>
{:else}
<div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0 flex items-center justify-center">
<UserOutline class="w-4 h-4 text-gray-600 dark:text-gray-300" />
</div>
{/if}
<div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold truncate">
{profile.displayName || profile.name || "anon"}
</span>
{#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1">
<svg
class="inline w-4 h-4 text-primary-500"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/></svg
>
{profile.nip05}
</span>
{/if}
<span class="text-xs text-gray-400 font-mono truncate"
>{shortenNpub(profile.pubkey)}</span
>
</div>
</div>
</button>
</li>
{/each}
</ul>
</div>
@ -558,22 +572,6 @@ @@ -558,22 +572,6 @@
</div>
</Modal>
<div class="space-y-4">
<div>
<Textarea
bind:value={content}
placeholder="Write your comment..."
rows={10}
class="w-full"
/>
</div>
<div
class="prose dark:prose-invert max-w-none p-4 border border-gray-300 dark:border-gray-700 rounded-lg"
>
{@render basicMarkup(content, ndk)}
</div>
</div>
{#if error}
<Alert color="red" dismissable>
{error}
@ -605,43 +603,6 @@ @@ -605,43 +603,6 @@
</Alert>
{/if}
<div class="flex flex-col sm:flex-row justify-end items-end sm:items-center gap-4">
{#if userProfile}
<div class="flex items-center gap-2 text-sm min-w-0 flex-shrink">
{#if userProfile.picture}
<img
src={userProfile.picture}
alt={userProfile.name || "Profile"}
class="w-8 h-8 rounded-full object-cover flex-shrink-0"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
/>
{:else}
<div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center flex-shrink-0">
<UserOutline class="w-4 h-4 text-gray-600 dark:text-gray-300" />
</div>
{/if}
<span class="text-gray-900 dark:text-gray-100 truncate">
{userProfile.displayName ||
userProfile.name ||
"anon"}
</span>
</div>
{/if}
<Button
onclick={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !$userStore.pubkey}
class="w-auto min-w-[120px]"
>
{#if !$userStore.pubkey}
Not Signed In
{:else if isSubmitting}
Publishing...
{:else}
Post Comment
{/if}
</Button>
</div>
{#if !$userStore.pubkey}
<Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your

2
src/lib/components/CommentViewer.svelte

@ -219,7 +219,7 @@ @@ -219,7 +219,7 @@
if (!isFetching) {
fetchComments();
}
}, 2000); // Wait 2 seconds before retry
}, 10000); // Wait 10 seconds before retry
}
});

361
src/lib/components/EventDetails.svelte

@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
import { activeInboxRelays } from "$lib/ndk";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import ProfileHeader from "$components/cards/ProfileHeader.svelte";
import { AProfilePreview } from "$lib/a";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils";
@ -22,6 +22,9 @@ @@ -22,6 +22,9 @@
import { getNdkContext } from "$lib/ndk";
import type { UserProfile } from "$lib/models/user_profile";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
import ATechBlock from "$lib/a/reader/ATechBlock.svelte";
import { Accordion, AccordionItem, Heading } from "flowbite-svelte";
import RelayActions from "$components/RelayActions.svelte";
const {
event,
@ -218,6 +221,32 @@ @@ -218,6 +221,32 @@
return { text: `${tag[0]}:${tag[1]}` };
}
// Navigation for tag buttons (moved out of template)
function handleTagGoto(value: string) {
if (!value) return;
if (
value.startsWith("naddr") ||
value.startsWith("nevent") ||
value.startsWith("npub") ||
value.startsWith("nprofile") ||
value.startsWith("note")
) {
goto(`/events?id=${value}`);
} else if (value.startsWith("/")) {
goto(value);
} else if (value.startsWith("d:")) {
const dTag = value.substring(2);
goto(`/events?d=${encodeURIComponent(dTag)}`);
} else if (value.startsWith("t:")) {
const tTag = value.substring(2);
goto(`/events?t=${encodeURIComponent(tTag)}`);
} else if (/^[0-9a-fA-F]{64}$/.test(value)) {
navigateToEvent(value);
} else {
goto(`/events?id=${value}`);
}
}
$effect(() => {
if (!event?.pubkey) {
authorDisplayName = undefined;
@ -236,12 +265,10 @@ @@ -236,12 +265,10 @@
// --- Identifier helpers ---
function getIdentifiers(
event: NDKEvent,
profile: any,
_profile: any,
): { label: string; value: string; link?: string }[] {
const ids: { label: string; value: string; link?: string }[] = [];
if (event.kind === 0) {
// NIP-05
const nip05 = profile?.nip05 || getMatchingTags(event, "nip05")[0]?.[1];
// npub
const npub = toNpub(event.pubkey);
if (npub)
@ -278,6 +305,10 @@ @@ -278,6 +305,10 @@
return ids;
}
function navigateToIdentifier(link: string) {
goto(link);
}
onMount(() => {
function handleInternalLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement;
@ -299,43 +330,38 @@ @@ -299,43 +330,38 @@
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100 break-words">
{@render basicMarkup(getEventTitle(event), ndk)}
</h2>
{/if}
<!-- Notifications (for profile events) -->
{#if event.kind === 0}
<Notifications {event} />
{/if}
<div class="flex items-center space-x-2 min-w-0">
{#if toNpub(event.pubkey)}
<div class="flex items-center space-x-2 min-w-0">
{#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400 min-w-0"
>Author: {@render userBadge(
toNpub(event.pubkey) as string,
profile?.display_name || undefined,
ndk,
)}</span
>Author: {@render userBadge(
toNpub(event.pubkey) || '',
profile?.display_name || undefined,
ndk,
)}</span
>
{:else}
{:else}
<span class="text-gray-600 dark:text-gray-400 min-w-0 break-words"
>Author: {profile?.display_name || event.pubkey}</span
>Author: {profile?.display_name || event.pubkey}</span
>
{/if}
</div>
{/if}
</div>
<div class="flex items-center space-x-2 min-w-0">
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span>
<span class="font-mono flex-shrink-0">{event.kind}</span>
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0"
<div class="flex items-center space-x-2 min-w-0">
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span>
<span class="font-mono flex-shrink-0">{event.kind}</span>
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0"
>({getEventTypeDisplay(event)})</span
>
</div>
>
</div>
<div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300">Summary:</span>
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0">
{@render basicMarkup(getEventSummary(event), ndk)}
<div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300">Summary:</span>
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0">
{@render basicMarkup(getEventSummary(event), ndk)}
</div>
</div>
</div>
{/if}
<!-- Containing Publications -->
<ContainingIndexes {event} />
@ -347,54 +373,54 @@ @@ -347,54 +373,54 @@
<div class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border max-w-full overflow-hidden">
<div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300 font-semibold">Content:</span>
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0">
{#if isRepost}
<!-- Repost content handling -->
{#if repostKinds.includes(event.kind)}
<!-- Kind 6 and 16 reposts - stringified JSON content -->
<div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{event.kind === 6 ? 'Reposted content:' : 'Generic reposted content:'}
</div>
{@render repostContent(event.content)}
</div>
{:else if event.kind === 1 && event.getMatchingTags("q").length > 0}
<!-- Quote repost - kind 1 with q tag -->
<div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Quote repost:
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}>
{#if isRepost}
<!-- Repost content handling -->
{#if repostKinds.includes(event.kind)}
<!-- Kind 6 and 16 reposts - stringified JSON content -->
<div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{event.kind === 6 ? 'Reposted content:' : 'Generic reposted content:'}
</div>
{@render repostContent(event.content)}
</div>
{@render quotedContent(event, [], ndk)}
{#if content}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Added comment:
</div>
{#if repostKinds.includes(kind)}
{@html content}
{:else}
{@render basicMarkup(content, ndk)}
{/if}
{:else if event.kind === 1 && event.getMatchingTags("q").length > 0}
<!-- Quote repost - kind 1 with q tag -->
<div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Quote repost:
</div>
{@render quotedContent(event, [], ndk)}
{#if content}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Added comment:
</div>
{#if repostKinds.includes(kind)}
{@html content}
{:else}
{@render basicMarkup(content, ndk)}
{/if}
</div>
{/if}
</div>
{/if}
{:else}
<!-- Regular content -->
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}>
{#if repostKinds.includes(kind)}
{@html content}
{:else}
{@render basicMarkup(content, ndk)}
{/if}
</div>
{/if}
{:else}
<!-- Regular content -->
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}>
{#if repostKinds.includes(kind)}
{@html content}
{:else}
{@render basicMarkup(content, ndk)}
{#if shouldTruncate}
<button
class="mt-2 text-primary-700 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-200"
onclick={() => (showFullContent = true)}>Show more</button
>
{/if}
</div>
{#if shouldTruncate}
<button
class="mt-2 text-primary-700 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-200"
onclick={() => (showFullContent = true)}>Show more</button
>
{/if}
{/if}
</div>
</div>
</div>
@ -402,126 +428,83 @@ @@ -402,126 +428,83 @@
<!-- If event is profile -->
{#if event.kind === 0}
<ProfileHeader
{event}
{profile}
{communityStatusMap}
/>
<AProfilePreview event={event} profile={profile} communityStatusMap={communityStatusMap} />
{/if}
<!-- Raw Event JSON -->
<details
class="relative w-full max-w-2xl md:max-w-full bg-primary-50 dark:bg-primary-900 rounded p-4 overflow-hidden"
>
<summary
class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2"
>
Show details
</summary>
<!-- Identifiers Section -->
<div class="mb-4 max-w-full overflow-hidden">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Identifiers:</h4>
<div class="flex flex-col gap-2 min-w-0">
{#each getIdentifiers(event, profile) as identifier}
<div class="flex items-center gap-2 min-w-0">
<span class="text-gray-600 dark:text-gray-400 flex-shrink-0">{identifier.label}:</span>
<div class="flex-1 min-w-0 flex items-center gap-2">
{#if identifier.link}
<a
href={identifier.link}
class="font-mono text-sm text-primary-700 dark:text-primary-300 hover:text-primary-900 dark:hover:text-primary-100 break-all cursor-pointer"
title={identifier.value}
>
{identifier.value.slice(0, 20)}...{identifier.value.slice(-8)}
</a>
{:else}
<span class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all" title={identifier.value}>
{identifier.value.slice(0, 20)}...{identifier.value.slice(-8)}
</span>
{/if}
<ATechBlock>
{#snippet content()}
<Heading tag="h3" class="h-leather my-6">
Technical details
</Heading>
<Accordion flush class="w-full">
<AccordionItem open={false} >
{#snippet header()}Identifiers{/snippet}
{#if event}
<div class="flex flex-col gap-2">
{#each getIdentifiers(event, profile) as identifier}
<div class="grid grid-cols-[max-content_minmax(0,1fr)_max-content] items-start gap-2 min-w-0">
<span class="min-w-24 text-gray-600 dark:text-gray-400">{identifier.label}:</span>
<div class="min-w-0">
{#if identifier.link}
<button class="font-mono text-sm text-primary-700 dark:text-primary-300 hover:text-primary-900 dark:hover:text-primary-100 break-all cursor-pointer bg-transparent border-none p-0 text-left"
onclick={() => navigateToIdentifier(identifier.link)}>
{identifier.value}
</button>
{:else}
<span class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all">{identifier.value}</span>
{/if}
</div>
<div class="justify-self-end">
<CopyToClipboard displayText="" copyText={identifier.value} />
</div>
</div>
{/each}
</div>
{/if}
</AccordionItem>
<!-- Event Tags Section -->
{#if event.tags && event.tags.length}
<AccordionItem open={false}>
{#snippet header()}
Tags
{/snippet}
<div class="flex flex-wrap gap-2 break-words min-w-0">
{#each event.tags as tag}
{@const tagInfo = getTagButtonInfo(tag)}
{#if tagInfo.text && tagInfo.gotoValue}
<button
onclick={() => handleTagGoto(tagInfo.gotoValue || "")}
class="text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100 break-all max-w-full"
>
{tagInfo.text}
</button>
{/if}
{/each}
</div>
</AccordionItem>
{/if}
<AccordionItem open={false} contentClass="relative">
{#snippet header()}Event JSON{/snippet}
<div class="absolute top-5 right-0 z-10">
<CopyToClipboard
displayText=""
copyText={identifier.value}
copyText={JSON.stringify(event.rawEvent(), null, 2)}
/>
</div>
</div>
{/each}
</div>
</div>
<!-- Event Tags Section -->
{#if event.tags && event.tags.length}
<div class="mb-4 max-w-full overflow-hidden">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Event Tags:</h4>
<div class="flex flex-wrap gap-2 break-words min-w-0">
{#each event.tags as tag}
{@const tagInfo = getTagButtonInfo(tag)}
{#if tagInfo.text && tagInfo.gotoValue}
<button
onclick={() => {
// Handle different types of gotoValue
if (
tagInfo.gotoValue!.startsWith("naddr") ||
tagInfo.gotoValue!.startsWith("nevent") ||
tagInfo.gotoValue!.startsWith("npub") ||
tagInfo.gotoValue!.startsWith("nprofile") ||
tagInfo.gotoValue!.startsWith("note")
) {
// For naddr, nevent, npub, nprofile, note - navigate directly
goto(`/events?id=${tagInfo.gotoValue!}`);
} else if (tagInfo.gotoValue!.startsWith("/")) {
// For relative URLs - navigate directly
goto(tagInfo.gotoValue!);
} else if (tagInfo.gotoValue!.startsWith("d:")) {
// For d-tag searches - navigate to d-tag search
const dTag = tagInfo.gotoValue!.substring(2);
goto(`/events?d=${encodeURIComponent(dTag)}`);
} else if (tagInfo.gotoValue!.startsWith("t:")) {
// For t-tag searches - navigate to t-tag search
const tTag = tagInfo.gotoValue!.substring(2);
goto(`/events?t=${encodeURIComponent(tTag)}`);
} else if (/^[0-9a-fA-F]{64}$/.test(tagInfo.gotoValue!)) {
// AI-NOTE: E-tag navigation may cause comment feed update issues
// When navigating to a new event via e-tag, the CommentViewer component
// may experience timing issues that result in:
// - Empty comment feeds even when comments exist
// - UI flashing between different thread views
// - Delayed comment loading
// This is likely due to race conditions between event prop changes
// and comment fetching in the CommentViewer component.
navigateToEvent(tagInfo.gotoValue!);
} else {
// For other cases, try direct navigation
goto(`/events?id=${tagInfo.gotoValue!}`);
}
}}
class="text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100 break-all max-w-full"
>
{tagInfo.text}
</button>
{/if}
{/each}
</div>
</div>
{/if}
{#if event}
<pre class="p-4 wrap-break-word bg-highlight dark:bg-primary-900">
<code class="text-wrap">{JSON.stringify(event.rawEvent(), null, 2)}</code>
</pre>
{/if}
</AccordionItem>
<!-- Raw Event JSON Section -->
<div class="mb-4 max-w-full overflow-hidden">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Raw Event JSON:</h4>
<div class="relative min-w-0">
<div class="absolute top-0 right-0 z-10">
<CopyToClipboard
displayText=""
copyText={JSON.stringify(event.rawEvent(), null, 2)}
/>
</div>
<pre
class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-4 mt-2 font-mono break-words whitespace-pre-wrap min-w-0"
style="line-height: 1.7; font-size: 1rem;">
{JSON.stringify(event.rawEvent(), null, 2)}
</pre>
</div>
</div>
</details>
<AccordionItem open={true}>
{#snippet header()}Relay Info{/snippet}
<RelayActions {event} />
</AccordionItem>
</Accordion>
{/snippet}
</ATechBlock>
</div>

4
src/lib/components/EventRenderLevelLimit.svelte

@ -42,14 +42,14 @@ @@ -42,14 +42,14 @@
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 dark:text-white"
class="w-20 bg-primary-50 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white"
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-400 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
class="px-3 py-1 bg-primary-50 dark:bg-primary-1000 border border-gray-400 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
Update
</button>

15
src/lib/components/EventSearch.svelte

@ -18,6 +18,7 @@ @@ -18,6 +18,7 @@
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import { isEventId } from "$lib/utils/nostr_identifiers";
import type { SearchType } from "$lib/models/search_type";
import { AAlert } from "$lib/a";
// Props definition
let {
@ -903,21 +904,15 @@ @@ -903,21 +904,15 @@
<!-- Error Display -->
{#if showError}
<div
class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg"
role="alert"
>
<AAlert color="red">
{localError || error}
</div>
</AAlert>
{/if}
<!-- Success Display -->
{#if showSuccess}
<div
class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg"
role="alert"
>
<AAlert color="green">
{getResultMessage()}
</div>
</AAlert>
{/if}
</div>

246
src/lib/components/Notifications.svelte

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<script lang="ts">
import "../../styles/notifications.css";
import { Heading, P } from "flowbite-svelte";
import { Heading, P, Avatar, Button, Modal } from "flowbite-svelte";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation";
@ -9,10 +9,9 @@ @@ -9,10 +9,9 @@
import { anonymousRelays } from "$lib/consts";
import { getKind24RelaySet } from "$lib/utils/kind24_utils";
import { createSignedEvent } from "$lib/utils/nostrEventService";
import { Modal, Button } from "flowbite-svelte";
import { searchProfiles } from "$lib/utils/search_utility";
import type { NostrProfile } from "$lib/utils/search_types";
import { PlusOutline, ReplyOutline, UserOutline } from "flowbite-svelte-icons";
import { ReplyOutline, UserOutline } from "flowbite-svelte-icons";
import {
getNotificationType,
fetchAuthorProfiles,
@ -25,11 +24,21 @@ @@ -25,11 +24,21 @@
import { repostKinds } from "$lib/consts";
import { getNdkContext } from "$lib/ndk";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
const { event } = $props<{ event: NDKEvent }>();
import { AAlert, APagination } from "$lib/a";
const ndk = getNdkContext();
// Helper: hide broken images (avoid TS assertions in template)
function hideImg(e: Event) {
const el = e.target as HTMLImageElement | null;
if (el) el.style.display = 'none';
}
// Mode typing and setter to avoid TS in template
type Mode = "to-me" | "from-me" | "public-messages";
const modes: Mode[] = ["to-me", "from-me", "public-messages"];
function setNotificationMode(m: Mode) { notificationMode = m; }
// Handle navigation events from quoted messages
$effect(() => {
if (typeof window !== 'undefined') {
@ -61,7 +70,7 @@ @@ -61,7 +70,7 @@
let allFromMeNotifications = $state<NDKEvent[]>([]); // All fetched "from-me" notifications
let allPublicMessages = $state<NDKEvent[]>([]); // All fetched public messages
let currentPage = $state(1);
let itemsPerPage = 20; // Show 20 items per page
let itemsPerPage = 10;
let hasFetchedToMe = $state(false); // Track if we've already fetched "to-me" data
let hasFetchedFromMe = $state(false); // Track if we've already fetched "from-me" data
let hasFetchedPublic = $state(false); // Track if we've already fetched public messages
@ -641,27 +650,13 @@ @@ -641,27 +650,13 @@
}
}
// AI-NOTE: Pagination navigation functions
function nextPage() {
if (hasNextPage) {
currentPage++;
updateDisplayedItems();
}
}
function previousPage() {
if (hasPreviousPage) {
currentPage--;
updateDisplayedItems();
}
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
updateDisplayedItems();
}
}
// Pagination navigation
$effect (() => {
console.log(`[Pagination] Mode: ${notificationMode}, Current Page: ${currentPage}, Total Pages: ${totalPages}`);
updateDisplayedItems();
// scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
});
// AI-NOTE: Update displayed items based on current page
function updateDisplayedItems() {
@ -688,8 +683,9 @@ @@ -688,8 +683,9 @@
// Check if user is viewing their own profile
$effect(() => {
if ($userStore.signedIn && $userStore.pubkey && event.pubkey) {
isOwnProfile = $userStore.pubkey.toLowerCase() === event.pubkey.toLowerCase();
// Only operate for a logged-in user; treat the logged-in user's profile as the source
if ($userStore.signedIn && $userStore.pubkey) {
isOwnProfile = true;
} else {
isOwnProfile = false;
}
@ -835,36 +831,33 @@ @@ -835,36 +831,33 @@
</script>
{#if isOwnProfile && $userStore.signedIn}
<div class="mb-6 w-full overflow-x-hidden">
<div class="flex items-center justify-between mb-4">
<Heading tag="h3" class="h-leather">Notifications</Heading>
<div class="flex items-center gap-3">
<!-- New Message Button -->
<Button
color="primary"
size="sm"
onclick={() => openNewMessageModal()}
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium"
<Heading tag="h3" class="h-leather">Notifications</Heading>
<div class="flex flex-row items-center gap-3">
<!-- New Message Button -->
<Button
color="primary"
size="sm"
onclick={() => openNewMessageModal()}
class="flex !mb-0 items-center gap-1.5 px-3 py-1.5 text-sm font-medium"
>
New Message
</Button>
<!-- Mode toggle -->
<div class="flex flex-row bg-gray-300 dark:bg-gray-700 rounded-lg p-1">
{#each modes as mode}
{@const modeLabel = mode === "to-me" ? "To Me" : mode === "from-me" ? "From Me" : "Public Messages"}
<button
class={`mode-toggle-button px-3 py-1 text-sm !mb-0 font-medium rounded-md ${notificationMode === mode ? 'active' : 'inactive'}`}
onclick={() => setNotificationMode(mode)}
>
New Message
</Button>
<!-- Mode toggle -->
<div class="flex bg-gray-300 dark:bg-gray-700 rounded-lg p-1">
{#each ["to-me", "from-me", "public-messages"] as mode}
{@const modeLabel = mode === "to-me" ? "To Me" : mode === "from-me" ? "From Me" : "Public Messages"}
<button
class="mode-toggle-button px-3 py-1 text-sm font-medium rounded-md {notificationMode === mode ? 'active' : 'inactive'}"
onclick={() => notificationMode = mode as "to-me" | "from-me" | "public-messages"}
>
{modeLabel}
</button>
{/each}
</div>
{modeLabel}
</button>
{/each}
</div>
</div>
{#if loading}
<div class="flex items-center justify-center py-8 min-h-96">
<div class="notifications-loading-spinner rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
@ -873,28 +866,21 @@ @@ -873,28 +866,21 @@
</span>
</div>
{:else if error}
<div class="p-4 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 rounded-lg">
<AAlert color="red">
<P>Error loading {notificationMode === "public-messages" ? "public messages" : "notifications"}: {error}</P>
</div>
</AAlert>
{:else if notificationMode === "public-messages"}
{#if publicMessages.length === 0}
<div class="p-4 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-lg">
<P>No public messages found.</P>
</div>
<AAlert color="blue"><P>No public messages found.</P></AAlert>
{:else}
<div class="max-h-[72rem] overflow-y-auto overflow-x-hidden">
<div>
{#if filteredByUser}
<div class="filter-indicator mb-4 p-3 rounded-lg">
<div class="flex items-center justify-between">
<span class="text-sm text-blue-700 dark:text-blue-300">
Filtered by user: @{authorProfiles.get(filteredByUser)?.displayName || authorProfiles.get(filteredByUser)?.name || "anon"}
</span>
<button
class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline font-medium"
onclick={clearFilter}
>
<div class="flex flex-row items-center justify-between gap-3">
<AAlert color="blue"><P>Filtered by user: @{authorProfiles.get(filteredByUser)?.displayName || authorProfiles.get(filteredByUser)?.name || "anon"}</P></AAlert>
<Button size="xs" color="blue" onclick={clearFilter}>
Clear Filter
</button>
</Button>
</div>
</div>
{/if}
@ -903,21 +889,14 @@ @@ -903,21 +889,14 @@
{@const authorProfile = authorProfiles.get(message.pubkey)}
{@const isFromUser = message.pubkey === $userStore.pubkey}
<div class="message-container p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm" data-event-id="{message.id}">
<div class="flex items-start gap-3 {isFromUser ? 'flex-row-reverse' : ''}">
<div class="flex items-start gap-3 {isFromUser ? 'flex-row-reverse' : 'flex-row'}">
<!-- Author Profile Picture and Name -->
<div class="flex-shrink-0 relative">
<div class="flex flex-col items-center gap-2 {isFromUser ? 'items-end' : 'items-start'}">
<div class="flex flex-col items-center justify-center gap-2">
{#if authorProfile?.picture}
<img
src={authorProfile.picture}
alt="Author avatar"
class="w-10 h-10 rounded-full object-cover border border-gray-200 dark:border-gray-600"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
/>
<Avatar src={authorProfile.picture} onerror={hideImg} border></Avatar>
{:else}
<div class="profile-picture-fallback w-10 h-10 rounded-full flex items-center justify-center border border-gray-200 dark:border-gray-600">
<UserOutline class="w-5 h-5 text-gray-600 dark:text-gray-300" />
</div>
<Avatar border />
{/if}
<div class="w-24 text-center">
<span class="text-xs font-medium text-gray-900 dark:text-gray-100 break-words">
@ -928,7 +907,7 @@ @@ -928,7 +907,7 @@
<!-- Filter button for non-user messages -->
{#if !isFromUser}
<div class="mt-2 flex justify-center gap-1">
<div class="mt-2 flex flex-row justify-center gap-1">
<!-- Reply button -->
<button
class="reply-button w-6 h-6 border border-gray-400 dark:border-gray-500 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full flex items-center justify-center text-xs transition-colors"
@ -944,7 +923,7 @@ @@ -944,7 +923,7 @@
</button>
<!-- Filter button -->
<button
class="filter-button w-6 h-6 border border-gray-400 dark:border-gray-500 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full flex items-center justify-center text-xs transition-colors {filteredByUser === message.pubkey ? 'filter-button-active bg-gray-200 dark:bg-gray-600 border-gray-500 dark:border-gray-400' : ''}"
class={`filter-button w-6 h-6 border border-gray-400 dark:border-gray-500 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full flex items-center justify-center text-xs transition-colors ${filteredByUser === message.pubkey ? 'filter-button-active bg-gray-200 dark:bg-gray-600 border-gray-500 dark:border-gray-400' : ''}`}
onclick={() => filterByUser(message.pubkey)}
title="Filter by this user"
aria-label="Filter by this user"
@ -959,20 +938,20 @@ @@ -959,20 +938,20 @@
<!-- Message Content -->
<div class="message-content flex-1 min-w-0 {isFromUser ? 'text-right' : ''}">
<div class="flex items-center gap-2 mb-2 {isFromUser ? 'justify-end' : ''}">
<span class="text-xs font-medium text-primary-600 dark:text-primary-400 bg-primary-100 dark:bg-primary-900 px-2 py-1 rounded">
<div class="flex flex-row items-center gap-2 mb-2 {isFromUser ? 'justify-end' : 'justify-start'}">
<span class="text-xs !mb-0 font-medium text-primary-600 dark:text-primary-400 bg-primary-100 dark:bg-primary-900 px-2 py-1 rounded">
{isFromUser ? 'Your Message' : 'Public Message'}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{message.created_at ? formatDate(message.created_at) : "Unknown date"}
</span>
<button
class="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-200 underline font-mono"
class="text-xs !mb-0 text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-200 underline font-mono"
onclick={() => navigateToEvent(getNeventUrl(message))}
title="Click to view event"
>
{getNeventUrl(message).slice(0, 16)}...
</button>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-auto">
{message.created_at ? formatDate(message.created_at) : "Unknown date"}
</span>
</div>
@ -1029,40 +1008,24 @@ @@ -1029,40 +1008,24 @@
<!-- Pagination Controls -->
{#if totalPages > 1}
<div class="mt-4 flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="text-sm text-gray-600 dark:text-gray-400">
Page {currentPage} of {totalPages} ({allPublicMessages.length} total messages)
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={previousPage}
disabled={!hasPreviousPage}
>
Previous
</button>
<span class="text-sm text-gray-600 dark:text-gray-400">
{currentPage} / {totalPages}
</span>
<button
class="px-3 py-1 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={nextPage}
disabled={!hasNextPage}
>
Next
</button>
</div>
</div>
<APagination
bind:currentPage
{totalPages}
{hasNextPage}
{hasPreviousPage}
totalItems={allPublicMessages.length}
itemsLabel="messages"
/>
{/if}
</div>
{/if}
{:else}
{#if notifications.length === 0}
<div class="p-4 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-lg">
<AAlert color="blue">
<P>No notifications {notificationMode === "to-me" ? "received" : "sent"} found.</P>
</div>
</AAlert>
{:else}
<div class="max-h-[72rem] overflow-y-auto overflow-x-hidden space-y-4">
<div class="space-y-4">
{#each notifications.slice(0, 100) as notification}
{@const authorProfile = authorProfiles.get(notification.pubkey)}
<div class="message-container p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
@ -1075,7 +1038,7 @@ @@ -1075,7 +1038,7 @@
src={authorProfile.picture}
alt="Author avatar"
class="w-10 h-10 rounded-full object-cover border border-gray-200 dark:border-gray-600"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
onerror={hideImg}
/>
{:else}
<div class="profile-picture-fallback w-10 h-10 rounded-full flex items-center justify-center border border-gray-200 dark:border-gray-600">
@ -1155,36 +1118,19 @@ @@ -1155,36 +1118,19 @@
<!-- Pagination Controls -->
{#if totalPages > 1}
<div class="mt-4 flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="text-sm text-gray-600 dark:text-gray-400">
Page {currentPage} of {totalPages} ({notificationMode === "to-me" ? allToMeNotifications.length : allFromMeNotifications.length} total notifications)
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={previousPage}
disabled={!hasPreviousPage}
>
Previous
</button>
<span class="text-sm text-gray-600 dark:text-gray-400">
{currentPage} / {totalPages}
</span>
<button
class="px-3 py-1 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={nextPage}
disabled={!hasNextPage}
>
Next
</button>
</div>
</div>
<APagination
bind:currentPage
{totalPages}
{hasNextPage}
{hasPreviousPage}
totalItems={notificationMode === 'to-me' ? allToMeNotifications.length : allFromMeNotifications.length}
itemsLabel="notifications"
/>
{/if}
</div>
{/if}
{/if}
</div>
<!-- New Message Modal -->
<Modal bind:open={showNewMessageModal} size="lg" class="w-full">
<div class="modal-content p-6">
@ -1301,7 +1247,7 @@ @@ -1301,7 +1247,7 @@
color="primary"
onclick={sendNewMessage}
disabled={isComposingMessage || selectedRecipients.length === 0 || !newMessageContent.trim()}
class="flex items-center gap-2 {isComposingMessage || selectedRecipients.length === 0 || !newMessageContent.trim() ? 'button-disabled' : ''}"
class={`flex items-center gap-2 ${isComposingMessage || selectedRecipients.length === 0 || !newMessageContent.trim() ? 'button-disabled' : ''}`}
>
{#if isComposingMessage}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
@ -1326,7 +1272,7 @@ @@ -1326,7 +1272,7 @@
placeholder="Search display name, name, NIP-05, or npub..."
bind:value={recipientSearch}
bind:this={recipientSearchInput}
class="search-input w-full rounded-lg border border-gray-300 bg-gray-50 text-gray-900 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 p-2.5 {recipientLoading ? 'pr-10' : ''}"
class={`search-input w-full rounded-lg border border-gray-300 bg-gray-50 text-gray-900 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 ${recipientLoading ? 'pr-10' : ''}`}
/>
{#if recipientLoading}
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
@ -1347,16 +1293,14 @@ @@ -1347,16 +1293,14 @@
selectRecipient(profile);
}}
disabled={isAlreadySelected}
class="recipient-selection-button w-full flex items-center gap-3 p-3 text-left bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 {isAlreadySelected ? 'opacity-50 cursor-not-allowed' : ''}"
class={`recipient-selection-button w-full flex items-center gap-3 p-3 text-left bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 ${isAlreadySelected ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{#if profile.picture}
<img
src={profile.picture}
alt="Profile"
class="w-8 h-8 rounded-full object-cover border border-gray-200 dark:border-gray-600 flex-shrink-0"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
onerror={hideImg}
/>
{:else}
<div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0 flex items-center justify-center">
@ -1423,4 +1367,4 @@ @@ -1423,4 +1367,4 @@
</div>
</div>
</Modal>
{/if}
{/if}

33
src/lib/components/RelayStatus.svelte

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
<script lang="ts">
import { Button, Alert } from "flowbite-svelte";
import { Button, P, Heading } from "flowbite-svelte";
import {
ndkSignedIn,
testRelayConnection,
@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
} from "$lib/ndk";
import { onMount } from "svelte";
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { AAlert } from '$lib/a/index.ts';
const ndk = getNdkContext();
@ -116,32 +117,32 @@ @@ -116,32 +117,32 @@
}
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Relay Connection Status</h3>
<div class="space-y-4 w-full max-w-3xl flex flex-col self-center p-4">
<div class="flex flex-col gap-3 items-center justify-between">
<Heading tag="h1">Relay Connection Status</Heading>
<Button size="sm" onclick={runRelayTests} disabled={testing}>
{testing ? "Testing..." : "Refresh"}
</Button>
</div>
{#if !$ndkSignedIn}
<Alert color="yellow">
<AAlert color="yellow">
<span class="font-medium">Anonymous Mode</span>
<p class="mt-1 text-sm">
You are not signed in. Some relays require authentication and may not be
accessible. Sign in to access all relays.
</p>
</Alert>
</AAlert>
{/if}
<div class="space-y-2">
<div class="flex flex-col space-y-2">
{#each relayStatuses as status}
<div class="flex items-center justify-between p-3">
<div class="flex flex-row items-center justify-between p-3">
<div class="flex-1">
<div class="font-medium">{status.url}</div>
<div class="text-sm {getStatusColor(status)}">
<P class="font-medium">{status.url}</P>
<P class="text-sm {getStatusColor(status)}">
{getStatusText(status)}
</div>
</P>
</div>
<div
class="w-3 h-3 rounded-full {getStatusColor(status).replace(
@ -154,11 +155,11 @@ @@ -154,11 +155,11 @@
</div>
{#if relayStatuses.some((s) => s.requiresAuth && !$ndkSignedIn)}
<Alert color="orange">
<span class="font-medium">Authentication Required</span>
<p class="mt-1 text-sm">
<AAlert color="orange">
<P class="font-medium">Authentication Required</P>
<P class="mt-1 text-sm">
Some relays require authentication. Sign in to access these relays.
</p>
</Alert>
</P>
</AAlert>
{/if}
</div>

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

@ -156,11 +156,11 @@ @@ -156,11 +156,11 @@
</div>
{#if communityStatus === true}
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
class="community-status-indicator"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
class="community-status-icon"
fill="currentColor"
viewBox="0 0 24 24"
>
@ -174,11 +174,11 @@ @@ -174,11 +174,11 @@
{/if}
{#if isInUserLists === true}
<div
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
class="user-list-indicator"
title="In your lists (follows, etc.)"
>
<svg
class="w-3 h-3 text-red-600 dark:text-red-400"
class="user-list-icon"
fill="currentColor"
viewBox="0 0 24 24"
>
@ -194,24 +194,24 @@ @@ -194,24 +194,24 @@
</div>
<div class="min-w-0">
<div class="mt-2 flex flex-col gap-4">
<dl class="grid grid-cols-1 gap-y-2">
<dl class="card-metadata-grid">
{#if profile.name}
<div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">Name:</dt>
<dd class="min-w-0 break-words">{profile.name}</dd>
<dt class="card-metadata-label">Name:</dt>
<dd class="card-metadata-value">{profile.name}</dd>
</div>
{/if}
{#if profile.displayName}
<div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">Display Name:</dt>
<dd class="min-w-0 break-words">{profile.displayName}</dd>
<dt class="card-metadata-label">Display Name:</dt>
<dd class="card-metadata-value">{profile.displayName}</dd>
</div>
{/if}
{#if profile.about}
<div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">About:</dt>
<dd class="min-w-0 break-words">
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0">
<dt class="card-metadata-label">About:</dt>
<dd class="card-metadata-value">
<div class="prose dark:prose-invert card-prose">
{@render basicMarkup(profile.about, ndk)}
</div>
</dd>
@ -219,8 +219,8 @@ @@ -219,8 +219,8 @@
{/if}
{#if profile.website}
<div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">Website:</dt>
<dd class="min-w-0 break-all">
<dt class="card-metadata-label">Website:</dt>
<dd class="card-metadata-value">
<a
href={profile.website}
class="underline text-primary-700 dark:text-primary-200"
@ -231,8 +231,8 @@ @@ -231,8 +231,8 @@
{/if}
{#if profile.lud16}
<div class="flex items-center gap-2 mt-4 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">Lightning:</dt>
<dd class="min-w-0 break-all">
<dt class="card-metadata-label">Lightning:</dt>
<dd class="card-metadata-value">
<Button
class="btn-leather"
color="primary"
@ -244,14 +244,14 @@ @@ -244,14 +244,14 @@
{/if}
{#if profile.nip05}
<div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">NIP-05:</dt>
<dd class="min-w-0 break-all">{profile.nip05}</dd>
<dt class="card-metadata-label">NIP-05:</dt>
<dd class="card-metadata-value">{profile.nip05}</dd>
</div>
{/if}
{#each identifiers as id}
<div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">{id.label}:</dt>
<dd class="min-w-0 break-all">
<dt class="card-metadata-label">{id.label}:</dt>
<dd class="card-metadata-value">
{#if id.link}
<button
class="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 underline hover:no-underline transition-colors"

107
src/lib/components/event_input/eventServices.ts

@ -14,35 +14,47 @@ import { anonymousRelays } from "$lib/consts"; @@ -14,35 +14,47 @@ import { anonymousRelays } from "$lib/consts";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { removeMetadataFromContent } from "$lib/utils/asciidoc_metadata";
import { build30040EventSet } from "$lib/utils/event_input_utils";
import type { EventData, TagData, PublishResult, LoadEventResult } from "./types";
import type {
EventData,
LoadEventResult,
PublishResult,
TagData,
} from "./types";
/**
* Converts TagData array to NDK-compatible format
*/
function convertTagsToNDKFormat(tags: TagData[]): string[][] {
return tags
.filter(tag => tag.key.trim() !== "")
.map(tag => [tag.key, ...tag.values]);
.filter((tag) => tag.key.trim() !== "")
.map((tag) => [tag.key, ...tag.values]);
}
/**
* Publishes an event to relays
*/
export async function publishEvent(ndk: any, eventData: EventData, tags: TagData[]): Promise<PublishResult> {
export async function publishEvent(
ndk: any,
eventData: EventData,
tags: TagData[],
): Promise<PublishResult> {
if (!ndk) {
return { success: false, error: "NDK context not available" };
}
const userState = get(userStore);
const pubkey = userState.pubkey;
if (!pubkey) {
return { success: false, error: "User not logged in." };
}
const pubkeyString = String(pubkey);
if (!/^[a-fA-F0-9]{64}$/.test(pubkeyString)) {
return { success: false, error: "Invalid public key: must be a 64-character hex string." };
return {
success: false,
error: "Invalid public key: must be a 64-character hex string.",
};
}
const baseEvent = { pubkey: pubkeyString, created_at: eventData.createdAt };
@ -56,48 +68,56 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData @@ -56,48 +68,56 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
if (Number(eventData.kind) === 30040) {
console.log("=== 30040 EVENT CREATION START ===");
console.log("Creating 30040 event set with content:", eventData.content);
try {
// Get the current d and title values from the UI
const dTagValue = tags.find(tag => tag.key === "d")?.values[0] || "";
const titleTagValue = tags.find(tag => tag.key === "title")?.values[0] || "";
const dTagValue = tags.find((tag) => tag.key === "d")?.values[0] || "";
const titleTagValue = tags.find((tag) =>
tag.key === "title"
)?.values[0] || "";
// Convert multi-value tags to the format expected by build30040EventSet
// Filter out d and title tags since we'll add them manually
const compatibleTags: [string, string][] = tags
.filter(tag => tag.key.trim() !== "" && tag.key !== "d" && tag.key !== "title")
.map(tag => [tag.key, tag.values[0] || ""] as [string, string]);
.filter((tag) =>
tag.key.trim() !== "" && tag.key !== "d" && tag.key !== "title"
)
.map((tag) => [tag.key, tag.values[0] || ""] as [string, string]);
const { indexEvent, sectionEvents } = build30040EventSet(
eventData.content,
compatibleTags,
baseEvent,
ndk,
);
// Override the d and title tags with the UI values if they exist
const finalTags = indexEvent.tags.filter(tag => tag[0] !== "d" && tag[0] !== "title");
const finalTags = indexEvent.tags.filter((tag) =>
tag[0] !== "d" && tag[0] !== "title"
);
if (dTagValue) {
finalTags.push(["d", dTagValue]);
}
if (titleTagValue) {
finalTags.push(["title", titleTagValue]);
}
// Update the index event with the correct tags
indexEvent.tags = finalTags;
console.log("Index event:", indexEvent);
console.log("Section events:", sectionEvents);
// Publish all 30041 section events first, then the 30040 index event
events = [...sectionEvents, indexEvent];
console.log("Total events to publish:", events.length);
console.log("=== 30040 EVENT CREATION END ===");
} catch (error) {
console.error("Error in build30040EventSet:", error);
return {
success: false,
error: `Failed to build 30040 event set: ${error instanceof Error ? error.message : "Unknown error"}`
return {
success: false,
error: `Failed to build 30040 event set: ${
error instanceof Error ? error.message : "Unknown error"
}`,
};
}
} else {
@ -109,7 +129,7 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData @@ -109,7 +129,7 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
if (eventData.kind === 30040 || eventData.kind === 30041) {
finalContent = removeMetadataFromContent(eventData.content);
}
// Prefix Nostr addresses before publishing
const prefixedContent = prefixNostrAddresses(finalContent);
@ -150,7 +170,7 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData @@ -150,7 +170,7 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
tags: event.tags.map((tag) => tag.map(String)),
content: String(event.content),
};
if (
typeof window !== "undefined" &&
window.nostr &&
@ -178,12 +198,15 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData @@ -178,12 +198,15 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
...get(activeOutboxRelays),
...get(activeInboxRelays),
];
console.log("publishEvent: Publishing to relays:", relays);
console.log("publishEvent: Anonymous relays:", anonymousRelays);
console.log("publishEvent: Active outbox relays:", get(activeOutboxRelays));
console.log(
"publishEvent: Active outbox relays:",
get(activeOutboxRelays),
);
console.log("publishEvent: Active inbox relays:", get(activeInboxRelays));
let published = false;
for (const relayUrl of relays) {
@ -234,18 +257,20 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData @@ -234,18 +257,20 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
}
} catch (signError) {
console.error("Error signing/publishing event:", signError);
return {
success: false,
error: `Failed to sign event: ${signError instanceof Error ? signError.message : "Unknown error"}`
return {
success: false,
error: `Failed to sign event: ${
signError instanceof Error ? signError.message : "Unknown error"
}`,
};
}
}
if (atLeastOne) {
return {
success: true,
return {
success: true,
eventId: lastEventId || undefined,
relays: relaysPublished
relays: relaysPublished,
};
} else {
return { success: false, error: "Failed to publish to any relay." };
@ -255,16 +280,22 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData @@ -255,16 +280,22 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
/**
* Loads an event by its hex ID
*/
export async function loadEvent(ndk: any, eventId: string): Promise<LoadEventResult | null> {
export async function loadEvent(
ndk: any,
eventId: string,
): Promise<LoadEventResult | null> {
if (!ndk) {
throw new Error("NDK context not available");
}
console.log("loadEvent: Starting search for event ID:", eventId);
console.log("loadEvent: NDK pool relays:", Array.from(ndk.pool.relays.values()).map((r: any) => r.url));
console.log(
"loadEvent: NDK pool relays:",
Array.from(ndk.pool.relays.values()).map((r: any) => r.url),
);
console.log("loadEvent: Active inbox relays:", get(activeInboxRelays));
console.log("loadEvent: Active outbox relays:", get(activeOutboxRelays));
const foundEvent = await fetchEventWithFallback(ndk, eventId, 10000);
if (foundEvent) {
@ -279,7 +310,7 @@ export async function loadEvent(ndk: any, eventId: string): Promise<LoadEventRes @@ -279,7 +310,7 @@ export async function loadEvent(ndk: any, eventId: string): Promise<LoadEventRes
// Convert NDK tags format to our format
const tags: TagData[] = foundEvent.tags.map((tag: string[]) => ({
key: tag[0] || "",
values: tag.slice(1)
values: tag.slice(1),
}));
return { eventData, tags };

2
src/lib/components/event_input/types.ts

@ -32,7 +32,7 @@ export interface LoadEventResult { @@ -32,7 +32,7 @@ export interface LoadEventResult {
}
export interface EventPreview {
type: 'standard_event' | '30040_index_event' | 'error';
type: "standard_event" | "30040_index_event" | "error";
event?: {
id: string;
pubkey: string;

37
src/lib/components/event_input/validation.ts

@ -6,22 +6,25 @@ import { get } from "svelte/store"; @@ -6,22 +6,25 @@ import { get } from "svelte/store";
import { userStore } from "$lib/stores/userStore";
import type { EventData, TagData, ValidationResult } from "./types";
import {
validateNotAsciidoc,
validateAsciiDoc,
validate30040EventSet,
validateAsciiDoc,
validateNotAsciidoc,
} from "$lib/utils/event_input_utils";
/**
* Validates an event and its tags
*/
export function validateEvent(eventData: EventData, tags: TagData[]): ValidationResult {
export function validateEvent(
eventData: EventData,
tags: TagData[],
): ValidationResult {
const userState = get(userStore);
const pubkey = userState.pubkey;
if (!pubkey) {
return { valid: false, reason: "Not logged in." };
}
// Content validation - 30040 events don't require content
if (eventData.kind !== 30040 && !eventData.content.trim()) {
return { valid: false, reason: "Content required." };
@ -32,25 +35,27 @@ export function validateEvent(eventData: EventData, tags: TagData[]): Validation @@ -32,25 +35,27 @@ export function validateEvent(eventData: EventData, tags: TagData[]): Validation
const v = validateNotAsciidoc(eventData.content);
if (!v.valid) return v;
}
if (eventData.kind === 30040) {
// Check for required tags
const versionTag = tags.find(t => t.key === "version");
const dTag = tags.find(t => t.key === "d");
const titleTag = tags.find(t => t.key === "title");
if (!versionTag || !versionTag.values[0] || versionTag.values[0].trim() === "") {
const versionTag = tags.find((t) => t.key === "version");
const dTag = tags.find((t) => t.key === "d");
const titleTag = tags.find((t) => t.key === "title");
if (
!versionTag || !versionTag.values[0] || versionTag.values[0].trim() === ""
) {
return { valid: false, reason: "30040 events require a 'version' tag." };
}
if (!dTag || !dTag.values[0] || dTag.values[0].trim() === "") {
return { valid: false, reason: "30040 events require a 'd' tag." };
}
if (!titleTag || !titleTag.values[0] || titleTag.values[0].trim() === "") {
return { valid: false, reason: "30040 events require a 'title' tag." };
}
// Validate content format if present
if (eventData.content.trim()) {
const v = validate30040EventSet(eventData.content);
@ -58,7 +63,7 @@ export function validateEvent(eventData: EventData, tags: TagData[]): Validation @@ -58,7 +63,7 @@ export function validateEvent(eventData: EventData, tags: TagData[]): Validation
if (v.warning) return { valid: true, warning: v.warning };
}
}
if (eventData.kind === 30041 || eventData.kind === 30818) {
const v = validateAsciiDoc(eventData.content);
if (!v.valid) return v;
@ -86,5 +91,5 @@ export function isValidTagKey(key: string): boolean { @@ -86,5 +91,5 @@ export function isValidTagKey(key: string): boolean {
* Validates that a tag has at least one value
*/
export function isValidTag(tag: TagData): boolean {
return isValidTagKey(tag.key) && tag.values.some(v => v.trim().length > 0);
return isValidTagKey(tag.key) && tag.values.some((v) => v.trim().length > 0);
}

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

@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
SidebarGroup,
SidebarWrapper,
Heading,
CloseButton,
CloseButton, uiHelpers
} from "flowbite-svelte";
import { getContext, onDestroy, onMount } from "svelte";
import {
@ -23,6 +23,7 @@ @@ -23,6 +23,7 @@
import type { SveltePublicationTree } from "./svelte_publication_tree.svelte";
import TableOfContents from "./TableOfContents.svelte";
import type { TableOfContents as TocType } from "./table_of_contents.svelte";
import ArticleNav from "$components/util/ArticleNav.svelte";
let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{
rootAddress: string;
@ -143,6 +144,10 @@ @@ -143,6 +144,10 @@
let currentBlogEvent: null | NDKEvent = $state(null);
const isLeaf = $derived(indexEvent.kind === 30041);
const tocSidebarUi = uiHelpers();
const closeTocSidebar = tocSidebarUi.close;
const isTocOpen = $state($publicationColumnVisibility.toc);
function isInnerActive() {
return currentBlog !== null && $publicationColumnVisibility.inner;
}
@ -247,172 +252,178 @@ @@ -247,172 +252,178 @@
// #endregion
</script>
<!-- Table of contents -->
{#if publicationType !== "blog" || !isLeaf}
{#if $publicationColumnVisibility.toc}
<Sidebar
activeUrl={`#${activeAddress ?? ""}`}
asideClass="fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10 bg-primary-0 dark:bg-primary-1000 px-5 w-80 left-0 pt-4 md:!pr-16 overflow-y-auto border border-l-4 rounded-lg border-primary-200 dark:border-primary-800 my-4"
activeClass="flex items-center p-2 bg-primary-50 dark:bg-primary-800 p-2 rounded-lg"
nonActiveClass="flex items-center p-2 hover:bg-primary-50 dark:hover:bg-primary-800 p-2 rounded-lg"
>
<CloseButton
onclick={closeToc}
class="btn-leather absolute top-4 right-4 hover:bg-primary-50 dark:hover:bg-primary-800"
/>
<TableOfContents
{rootAddress}
{toc}
depth={2}
onSectionFocused={(address: string) =>
publicationTree.setBookmark(address)}
onLoadMore={() => {
if (!isLoading && !isDone && publicationTree) {
loadMore(4);
}
<!-- Add gap & items-start so sticky sidebars size correctly -->
<div class="relative grid gap-4 items-start grid-cols-[1fr_3fr_1fr] grid-rows-[auto_1fr]">
<!-- Full-width ArticleNav row -->
<ArticleNav
publicationType={publicationType}
rootId={indexEvent.id}
indexEvent={indexEvent}
/>
<!-- Three-column row -->
<div class="contents">
<!-- Table of contents -->
<div class="mt-[70px] relative {$publicationColumnVisibility.toc ? 'w-64' : 'w-auto'}">
{#if publicationType !== "blog" && !isLeaf}
{#if $publicationColumnVisibility.toc}
<Sidebar
class="z-10 ml-2 fixed top-[162px] max-h-[calc(100vh-165px)] overflow-y-auto dark:bg-primary-900 bg-primary-50 rounded"
activeUrl={`#${activeAddress ?? ""}`}
classes={{
div: 'dark:bg-primary-900 bg-primary-50',
active: 'bg-primary-100 dark:bg-primary-800 p-2 rounded-lg',
nonactive: 'bg-primary-50 dark:bg-primary-900',
}}
/>
</Sidebar>
{/if}
{/if}
<!-- Default publications -->
{#if $publicationColumnVisibility.main}
<div class="flex flex-col p-4 space-y-4 overflow-auto max-w-2xl flex-grow-2">
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details event={indexEvent} />
</div>
<!-- Publication sections/cards -->
{#each leaves as leaf, i}
{#if leaf == null}
<Alert class="flex space-x-2">
<ExclamationCircleOutline class="w-5 h-5" />
Error loading content. One or more events could not be loaded.
</Alert>
{:else}
{@const address = leaf.tagAddress()}
<PublicationSection
{rootAddress}
{leaves}
{address}
{publicationTree}
{toc}
ref={(el) => onPublicationSectionMounted(el, address)}
/>
{/if}
{/each}
<div class="flex justify-center my-4">
{#if isLoading}
<Button disabled color="primary">Loading...</Button>
{:else if !isDone}
<Button color="primary" onclick={() => loadMore(1)}>Show More</Button>
{:else}
<p class="text-gray-500 dark:text-gray-400">
You've reached the end of the publication.
</p>
>
<SidebarWrapper>
<CloseButton color="secondary" class="m-2 dark:text-primary-100" onclick={closeToc} ></CloseButton>
<TableOfContents
{rootAddress}
{toc}
depth={2}
onSectionFocused={(address: string) => publicationTree.setBookmark(address)}
onLoadMore={() => {
if (!isLoading && !isDone && publicationTree) {
loadMore(4);
}
}}
/>
</SidebarWrapper>
</Sidebar>
{/if}
{/if}
</div>
</div>
{/if}
<!-- Blog list -->
{#if $publicationColumnVisibility.blog}
<div
class={`flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1 ${isInnerActive() ? "discreet" : ""}`}
>
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details event={indexEvent} />
</div>
<!-- List blog excerpts -->
{#each leaves as leaf, i}
{#if leaf}
<BlogHeader
rootId={leaf.tagAddress()}
event={leaf}
onBlogUpdate={loadBlog}
active={!isInnerActive()}
/>
{/if}
{/each}
</div>
{/if}
{#if isInnerActive()}
{#key currentBlog}
<div
class="flex flex-col p-4 max-w-3xl overflow-auto flex-grow-2 max-h-[calc(100vh-146px)] sticky top-[146px]"
>
{#each leaves as leaf, i}
{#if leaf && leaf.tagAddress() === currentBlog}
<div class="mt-[70px]">
<!-- Default publications -->
{#if $publicationColumnVisibility.main}
<!-- Remove overflow-auto so page scroll drives it -->
<div class="flex flex-col p-4 space-y-4 max-w-3xl flex-grow-2 mx-auto">
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details event={leaf} />
<Details event={indexEvent} />
</div>
<!-- Publication sections/cards -->
{#each leaves as leaf, i}
{#if leaf == null}
<Alert class="flex space-x-2">
<ExclamationCircleOutline class="w-5 h-5" />
Error loading content. One or more events could not be loaded.
</Alert>
{:else}
{@const address = leaf.tagAddress()}
<PublicationSection
{rootAddress}
{leaves}
{address}
{publicationTree}
{toc}
ref={(el) => onPublicationSectionMounted(el, address)}
/>
{/if}
{/each}
<div class="flex justify-center my-4">
{#if isLoading}
<Button disabled color="primary">Loading...</Button>
{:else if !isDone}
<Button color="primary" onclick={() => loadMore(1)}>Show More</Button>
{:else}
<p class="text-gray-500 dark:text-gray-400">
You've reached the end of the publication.
</p>
{/if}
</div>
</div>
{/if}
<PublicationSection
{rootAddress}
{leaves}
address={leaf.tagAddress()}
{publicationTree}
{toc}
ref={(el) => setLastElementRef(el, i)}
/>
<Card class="ArticleBox !hidden card-leather min-w-full mt-4">
<Interactions rootId={currentBlog} />
</Card>
{/if}
{/each}
</div>
{/key}
{/if}
{#if $publicationColumnVisibility.discussion}
<Sidebar class="sidebar-leather right-0 md:!pl-8">
<SidebarWrapper>
<SidebarGroup class="sidebar-group-leather">
<div class="flex justify-between items-baseline">
<Heading tag="h1" class="h-leather !text-lg">Discussion</Heading>
<Button
class="btn-leather hidden sm:flex z-30 !p-1 bg-primary-50 dark:bg-gray-800"
outline
onclick={closeDiscussion}
<!-- Blog list -->
{#if $publicationColumnVisibility.blog}
<!-- Remove overflow-auto -->
<div
class={`flex flex-col p-4 space-y-4 max-w-xl flex-grow-1 ${isInnerActive() ? "discreet" : ""}`}
>
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<CloseOutline />
</Button>
</div>
<div class="flex flex-col space-y-4">
<!-- TODO
alternative for other publications and
when blog is not opened, but discussion is opened from the list
-->
{#if showBlogHeader() && currentBlog && currentBlogEvent}
<BlogHeader
rootId={currentBlog}
event={currentBlogEvent}
onBlogUpdate={loadBlog}
active={true}
/>
{/if}
<div class="flex flex-col w-full space-y-4">
<Card class="ArticleBox card-leather w-full grid max-w-xl">
<div class="flex flex-col my-2">
<span>Unknown</span>
<span class="text-gray-500">1.1.1970</span>
</div>
<div class="flex flex-col flex-grow space-y-4">
This is a very intelligent comment placeholder that applies to
all the content equally well.
</div>
</Card>
<Details event={indexEvent} />
</div>
<!-- List blog excerpts -->
{#each leaves as leaf, i}
{#if leaf}
<BlogHeader
rootId={leaf.tagAddress()}
event={leaf}
onBlogUpdate={loadBlog}
active={!isInnerActive()}
/>
{/if}
{/each}
</div>
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if}
{/if}
{#if isInnerActive()}
{#key currentBlog}
<!-- Remove overflow-auto & sticky; allow page scroll -->
<div class="flex flex-col p-4 max-w-3xl flex-grow-2">
<!-- ...existing code... -->
</div>
{/key}
{/if}
</div>
<div class="mt-[70px] relative {$publicationColumnVisibility.discussion ? 'w-64' : 'w-auto'}">
<!-- Discussion sidebar -->
{#if $publicationColumnVisibility.discussion}
<Sidebar
class="z-10 ml-4 fixed top-[162px] h-[calc(100vh-165px)] overflow-y-auto"
classes={{
div: 'bg-transparent'
}}
>
<SidebarWrapper>
<SidebarGroup>
<div class="flex justify-between items-baseline">
<Heading tag="h1" class="h-leather !text-lg">Discussion</Heading>
<Button
class="btn-leather hidden sm:flex z-30 !p-1 bg-primary-50 dark:bg-gray-800"
outline
onclick={closeDiscussion}
>
<CloseOutline />
</Button>
</div>
<div class="flex flex-col space-y-4">
<!-- TODO
alternative for other publications and
when blog is not opened, but discussion is opened from the list
-->
{#if showBlogHeader() && currentBlog && currentBlogEvent}
<BlogHeader
rootId={currentBlog}
event={currentBlogEvent}
onBlogUpdate={loadBlog}
active={true}
/>
{/if}
<div class="flex flex-col w-full space-y-4">
<Card class="ArticleBox card-leather w-full grid max-w-xl">
<div class="flex flex-col my-2">
<span>Unknown</span>
<span class="text-gray-500">1.1.1970</span>
</div>
<div class="flex flex-col flex-grow space-y-4">
This is a very intelligent comment placeholder that applies to
all the content equally well.
</div>
</Card>
</div>
</div>
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if}
</div>
</div>
</div>

2
src/lib/components/publications/PublicationFeed.svelte

@ -680,7 +680,7 @@ @@ -680,7 +680,7 @@
>
{#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id}
<Skeleton divClass="skeleton-leather w-full" size="lg" />
<Skeleton classes={{wrapper: "skeleton-leather w-full"}} size="lg" />
{/each}
{:else if eventsInView.length > 0}
{#each eventsInView as event}

66
src/lib/components/publications/PublicationHeader.svelte

@ -49,50 +49,46 @@ @@ -49,50 +49,46 @@
</script>
{#if title != null && href != null}
<Card class="ArticleBox card-leather w-full h-48 flex flex-row space-x-2 relative">
<div
class="flex-shrink-0 w-40 h-40 overflow-hidden rounded flex items-center justify-center p-2 -mt-2"
>
<Card class="ArticleBox max-h-52 card-leather w-full relative flex flex-col sm:flex-row sm:space-x-2 overflow-hidden">
<!-- Image block: full width on mobile, fixed side on md+ -->
<div class="w-full sm:min-w-40 sm:w-40 overflow-hidden flex items-center justify-center sm:rounded-l rounded-t sm:rounded-t-none">
{#if image}
<LazyImage
src={image}
alt={title || "Publication image"}
<LazyImage
src={image}
alt={title || 'Publication image'}
eventId={event.id}
className="w-full h-full object-cover"
/>
{:else}
<div
class="w-full h-full rounded"
<div
class="w-full h-full"
style="background-color: {generateDarkPastelColor(event.id)};"
>
</div>
></div>
{/if}
</div>
<div class="flex flex-col flex-grow min-w-0 overflow-hidden">
<div class="flex flex-col flex-grow min-w-0 overflow-hidden">
<a href="/{href}" class="flex flex-col space-y-2 h-full min-w-0 overflow-hidden">
<div class="flex-grow pt-2 min-w-0 overflow-hidden">
<h2 class="text-lg font-bold line-clamp-2 break-words overflow-hidden" {title}>{title}</h2>
<h3 class="text-base font-normal mt-2 break-words overflow-hidden">
by
{#if authorPubkey != null}
{@render userBadge(authorPubkey, author, ndk)}
{:else}
<span class="truncate">{author}</span>
{/if}
</h3>
</div>
{#if version != "1"}
<h3 class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-auto break-words overflow-hidden">version: {version}</h3>
{/if}
</a>
<!-- Content -->
<div class="flex flex-col min-w-0 p-3 sm:p-2 w-full gap-2">
<a href="/{href}" class="flex flex-col space-y-2 flex-1 min-w-0 overflow-hidden decoration-none hover:underline">
<div class="min-w-0">
<h2 class="text-lg font-bold line-clamp-2 break-words overflow-hidden decoration-none" {title}>{title}</h2>
<h3 class="text-base font-normal mt-2 break-words overflow-hidden decoration-none">
{#if authorPubkey != null}
by {@render userBadge(authorPubkey, author, ndk)}
{:else}
<span class="line-clamp-1 inline">by {author}</span>
{/if}
</h3>
</div>
</a>
<div class="flex flex-row w-full justify-between">
{#if version != '1'}
<h3 class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-auto break-words overflow-hidden">version: {version}</h3>
{/if}
<div class="flex ml-auto">
<CardActions {event} />
</div>
</div>
</div>
<!-- Position CardActions at bottom-right -->
<div class="absolute bottom-2 right-2">
<CardActions {event} />
</div>
</Card>
{/if}

9
src/lib/components/publications/TableOfContents.svelte

@ -158,20 +158,21 @@ @@ -158,20 +158,21 @@
{@const expanded = toc.expandedMap.get(address) ?? false}
{@const isLeaf = toc.leaves.has(address)}
{@const isVisible = isEntryVisible(address)}
{@const isLastEntry = index === entries.length - 1}
{#if isLeaf}
<SidebarItem
label={entry.title}
href={`#${address}`}
spanClass="px-2 text-ellipsis"
class={`${isVisible ? "toc-highlight" : ""} ${isLastEntry ? "pb-4" : ""}`}
class={`${isVisible ? "toc-highlight" : ""} `}
onclick={() => handleSectionClick(address)}
/>
>
<!-- Empty for now - could add icons or labels here in the future -->
</SidebarItem>
{:else}
{@const childDepth = depth + 1}
<SidebarDropdownWrapper
label={entry.title}
btnClass="flex items-center p-2 w-full font-normal text-gray-900 rounded-lg transition duration-75 group hover:bg-primary-50 dark:text-white dark:hover:bg-primary-800 {isVisible ? 'toc-highlight' : ''} {isLastEntry ? 'pb-4' : ''}"
btnClass="flex items-center p-2 w-full font-normal text-gray-900 rounded-lg transition duration-75 group hover:bg-primary-50 dark:text-white dark:hover:bg-primary-800 {isVisible ? 'toc-highlight' : ''} "
bind:isOpen={() => expanded, (open) => setEntryExpanded(address, open)}
>
<Self rootAddress={address} depth={childDepth} {toc} {onSectionFocused} {onLoadMore} />

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

@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
GlobeOutline,
ChartOutline,
} from "flowbite-svelte-icons";
import { Button } from "flowbite-svelte";
import { Button, P } from "flowbite-svelte";
import { publicationColumnVisibility } from "$lib/stores";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
@ -152,7 +152,7 @@ @@ -152,7 +152,7 @@
</script>
<nav
class="Navbar navbar-leather flex fixed top-[100px] sm:top-[106px] w-full min-h-[70px] px-2 sm:px-4 py-2.5 z-10 transition-transform duration-300 {isVisible
class="Navbar navbar-leather col-span-3 flex fixed top-[100px] sm:top-[92px] w-full min-h-[70px] px-2 sm:px-4 py-2.5 z-10 transition-transform duration-300 {isVisible
? 'translate-y-0'
: '-translate-y-full'}"
>
@ -191,14 +191,14 @@ @@ -191,14 +191,14 @@
{/if}
</div>
<div class="flex flex-col flex-grow text justify-center items-center">
<p class="max-w-[60vw] line-ellipsis">
<P class="max-w-[60vw] line-ellipsis">
<b class="text-nowrap">{title}</b>
</p>
<p>
</P>
<P>
<span class="whitespace-nowrap"
>by {@render userBadge(pubkey, author, ndk)}</span
>
</p>
</P>
</div>
<div class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8">
{#if $publicationColumnVisibility.inner}

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

@ -134,7 +134,7 @@ @@ -134,7 +134,7 @@
<Button
type="button"
id="dots-{event.id}"
class=" hover:bg-primary-0 dark:text-highlight dark:hover:bg-primary-800 p-1 dots"
class=" hover:bg-primary-50 dark:text-highlight dark:hover:bg-primary-800 p-1 dots"
color="none"
data-popover-target="popover-actions"
>
@ -203,7 +203,7 @@ @@ -203,7 +203,7 @@
</div>
{/if}
<div class="flex flex-col col space-y-5 justify-center align-middle">
<h1 class="text-3xl font-bold mt-5">{title || "Untitled"}</h1>
<h1 class="text-3xl font-bold mt-0">{title || "Untitled"}</h1>
<h2 class="text-base font-bold">
by
{#if originalAuthor}

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

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import {
ClipboardCheckOutline,
ClipboardCleanOutline,
@ -42,15 +43,12 @@ @@ -42,15 +43,12 @@
}
</script>
<button class="btn-leather w-full text-left" onclick={copyToClipboard}>
<button class="btn-leather w-full text-left dark:text-primary-100 p-1 rounded-xs cursor-pointer" onclick={copyToClipboard}>
{#if copied}
<ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else}
{#if icon === ClipboardCleanOutline}
<ClipboardCleanOutline class="inline mr-2" />
{:else if icon === ClipboardCheckOutline}
<ClipboardCheckOutline class="inline mr-2" />
{/if}
{@const TheIcon = icon}
<TheIcon class="inline { displayText !== '' ? 'mr-2' : ''}" />
{displayText}
{/if}
</button>

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

@ -1,18 +1,9 @@ @@ -1,18 +1,9 @@
<script lang="ts">
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import NetworkStatus from "$components/NetworkStatus.svelte";
import {
logoutUser,
userStore,
loginWithExtension,
loginWithAmber,
loginWithNpub
} from "$lib/stores/userStore";
import {
ArrowRightToBracketOutline,
UserOutline,
} from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte";
import { loginWithAmber, loginWithExtension, loginWithNpub, logoutUser, userStore } from "$lib/stores/userStore";
import { Avatar, Dropdown, DropdownGroup, DropdownHeader, DropdownItem, P } from "flowbite-svelte";
import { Book, Globe, Loader, Smartphone } from "@lucide/svelte";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
@ -22,8 +13,6 @@ @@ -22,8 +13,6 @@
const ndk = getNdkContext();
let { isNav = false } = $props<{ isNav?: boolean }>();
// UI state for login functionality
let isLoadingExtension: boolean = $state(false);
let isLoadingAmber: boolean = $state(false);
@ -31,7 +20,6 @@ @@ -31,7 +20,6 @@
let nostrConnectUri: string | undefined = $state(undefined);
let showQrCode: boolean = $state(false);
let qrCodeDataUrl: string | undefined = $state(undefined);
let loginButtonRef: HTMLElement | undefined = $state();
let resultTimeout: ReturnType<typeof setTimeout> | null = null;
let profileAvatarId = "profile-avatar-btn";
let showAmberFallback = $state(false);
@ -118,8 +106,7 @@ @@ -118,8 +106,7 @@
// Reset the refresh flag when user logs out
$effect(() => {
const currentUser = userState;
if (!currentUser.signedIn) {
if (!userState.signedIn) {
hasRefreshedProfile = false;
}
});
@ -390,162 +377,130 @@ @@ -390,162 +377,130 @@
<div class="relative h-fit my-auto">
{#if !userState.signedIn}
<!-- Login button -->
<div class="group">
<button
bind:this={loginButtonRef}
id="login-avatar"
class="h-6 w-6 rounded-full bg-gray-300 flex items-center justify-center cursor-pointer hover:bg-gray-400 transition-colors"
>
<UserOutline class="h-4 w-4 text-gray-600" />
</button>
<Popover
placement="bottom"
triggeredBy="#login-avatar"
class="popover-leather w-[200px]"
trigger="click"
>
<div class="flex flex-col space-y-2">
<h3 class="text-lg font-bold mb-2">Login with...</h3>
<button
class="btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50"
onclick={handleBrowserExtensionLogin}
disabled={isLoadingExtension || isLoadingAmber}
>
<Avatar size="xs" id="login-menu"/>
<Dropdown placement="bottom" triggeredBy="#login-menu" class="min-w-xs">
<DropdownGroup>
<DropdownHeader>Login with...</DropdownHeader>
<DropdownItem
class="w-full"
onclick={handleBrowserExtensionLogin}
disabled={isLoadingExtension || isLoadingAmber}>
<span class="w-full flex items-center justify-start gap-3">
{#if isLoadingExtension}
🔄 Connecting...
<Loader size={16} class="inline" /> Connecting...
{:else}
🌐 Browser extension
<Globe size={16} class="inline" /> Browser extension
{/if}
</button>
<button
class="btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50"
onclick={handleAmberLogin}
disabled={isLoadingAmber || isLoadingExtension}
</span></DropdownItem>
<DropdownItem
class="w-full"
onclick={handleAmberLogin}
disabled={isLoadingAmber || isLoadingExtension}
>
{#if isLoadingAmber}
🔄 Connecting...
<span class="w-full flex items-center justify-start gap-3">
{#if isLoadingAmber}
<Loader size={16} class="inline" /> Connecting...
{:else}
📱 Amber: NostrConnect
<Smartphone size={16} class="inline" /> Amber: NostrConnect
{/if}
</button>
<button
class="btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500"
onclick={handleReadOnlyLogin}
>
📖 npub (read only)
</button>
<div class="border-t border-gray-200 pt-2 mt-2">
<div class="text-xs text-gray-500 mb-1">Network Status:</div>
<NetworkStatus />
</div>
</div>
</Popover>
{#if result}
<div
class="absolute right-0 top-10 z-50 bg-gray-100 p-3 rounded text-sm break-words whitespace-pre-line max-w-lg shadow-lg border border-gray-300"
</span>
</DropdownItem>
<DropdownItem
class="w-full"
onclick={handleReadOnlyLogin}
>
{result}
<button
class="ml-2 text-gray-500 hover:text-gray-700"
onclick={() => (result = null)}>✖</button
>
</div>
{/if}
</div>
<span class="w-full flex items-center justify-start gap-3">
<Book size={16} class="inline" /> npub (read only)
</span>
</DropdownItem>
{#if result}
<DropdownHeader class="flex gap-3">
<P class="text-xs">
{result}
</P>
<button
class="inline ml-2 text-gray-500 hover:text-gray-700"
onclick={() => (result = null)}>✖</button
>
</DropdownHeader>
{/if}
</DropdownGroup>
<DropdownGroup>
<DropdownHeader>
<NetworkStatus />
</DropdownHeader>
</DropdownGroup>
</Dropdown>
{:else}
<!-- User profile -->
<div class="group">
<button
class="h-6 w-6 rounded-full p-0 border-0 bg-transparent cursor-pointer"
id={profileAvatarId}
type="button"
aria-label="Open profile menu"
>
{#if !pfp}
<div class="h-6 w-6 rounded-full bg-gray-300 animate-pulse cursor-pointer"></div>
<Avatar
src={pfp}
alt={username || "User"}
aria-label="Open profile menu"
size="xs" id={profileAvatarId}/>
<Dropdown placement="bottom" triggeredBy="#{profileAvatarId}" class="min-w-xs">
<DropdownHeader>
{#if username}
<span class="block text-sm">{username}</span>
<span class="block truncate text-sm font-medium">@{tag}</span>
{:else if !pfp}
<span>Loading profile...</span>
{:else}
<Avatar
rounded
class="h-6 w-6 cursor-pointer"
src={pfp}
alt={username || "User"}
/>
<span>Loading...</span>
{/if}
</button>
<Popover
placement="bottom"
triggeredBy={`#${profileAvatarId}`}
class="popover-leather w-[220px]"
trigger="click"
>
<div class="flex flex-row justify-between space-x-4">
<div class="flex flex-col">
{#if username}
<h3 class="text-lg font-bold">{username}</h3>
{#if isNav}<h4 class="text-base">@{tag}</h4>{/if}
{:else if !pfp}
<h3 class="text-lg font-bold">Loading profile...</h3>
{:else}
<h3 class="text-lg font-bold">Loading...</h3>
{/if}
<ul class="space-y-2 mt-2">
<li>
<CopyToClipboard
displayText={shortenNpub(npub) || "Loading..."}
copyText={npub || ""}
/>
</li>
<li>
<button
class="hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0 text-left"
onclick={handleViewProfile}
>
<UserOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/><span class="underline">View notifications</span>
</button>
</li>
<li class="text-xs text-gray-500">
{#if userState.loginMethod === "extension"}
Logged in with extension
{:else if userState.loginMethod === "amber"}
Logged in with Amber
{:else if userState.loginMethod === "npub"}
Logged in with npub
{:else}
Unknown login method
{/if}
</li>
<li>
<NetworkStatus />
</li>
{#if isNav}
<li>
<button
id="sign-out-button"
class="btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500"
onclick={handleSignOutClick}
>
<ArrowRightToBracketOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/> Sign out
</button>
</li>
{:else}
<!-- li>
<button
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500'
>
<FileSearchOutline class='mr-1 !h-6 inline !fill-none dark:!fill-none' /> More content
</button>
</li -->
{/if}
</ul>
</div>
</div>
</Popover>
</div>
</DropdownHeader>
<DropdownGroup>
<DropdownItem class="w-full">
<CopyToClipboard
displayText={shortenNpub(npub) || "Loading..."}
copyText={npub || ""}
/>
</DropdownItem>
</DropdownGroup>
<DropdownGroup>
<DropdownItem
class="w-full flex items-center justify-start"
onclick={() => goto('/profile')}
>
View profile
</DropdownItem>
<DropdownItem
class="w-full flex items-center justify-start "
onclick={() => goto('/profile/my-notes')}
>
My notes
</DropdownItem>
<DropdownItem
class="w-full flex items-center justify-start"
onclick={() => goto('/profile/notifications')}
>
Notifications
</DropdownItem>
</DropdownGroup>
<DropdownGroup>
<DropdownHeader class="text-xs">
{#if userState.loginMethod === "extension"}
Logged in with extension
{:else if userState.loginMethod === "amber"}
Logged in with Amber
{:else if userState.loginMethod === "npub"}
Logged in with npub
{:else}
Unknown login method
{/if}
</DropdownHeader>
<DropdownHeader><NetworkStatus /></DropdownHeader>
</DropdownGroup>
<DropdownGroup>
<DropdownItem
id="sign-out-button"
class="w-full flex items-center justify-start "
onclick={handleSignOutClick}
>
Sign out
</DropdownItem>
</DropdownGroup>
</Dropdown>
{/if}
</div>

80
src/lib/data_structures/publication_tree.ts

@ -253,7 +253,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -253,7 +253,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// Clear all nodes except the root to force fresh loading
const rootAddress = this.#root.address;
this.#nodes.clear();
this.#nodes.set(rootAddress, new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)));
this.#nodes.set(
rootAddress,
new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)),
);
// Clear events cache to ensure fresh data
this.#events.clear();
this.#eventCache.clear();
@ -496,18 +499,20 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -496,18 +499,20 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!this.#cursor.target) {
return { done, value: null };
}
const address = this.#cursor.target.address;
// AI-NOTE: Check if this node has already been visited
if (this.#visitedNodes.has(address)) {
console.debug(`[PublicationTree] Skipping already visited node: ${address}`);
console.debug(
`[PublicationTree] Skipping already visited node: ${address}`,
);
return { done: false, value: null };
}
// Mark this node as visited
this.#visitedNodes.add(address);
const value = (await this.getEvent(address)) ?? null;
return { done, value };
}
@ -762,8 +767,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -762,8 +767,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
#addNode(address: string, parentNode: PublicationTreeNode) {
// AI-NOTE: Add debugging to track node addition
console.debug(`[PublicationTree] Adding node ${address} to parent ${parentNode.address}`);
console.debug(
`[PublicationTree] Adding node ${address} to parent ${parentNode.address}`,
);
const lazyNode = new Lazy<PublicationTreeNode>(() =>
this.#resolveNode(address, parentNode)
);
@ -902,7 +909,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -902,7 +909,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.#eventCache.set(address, fetchedEvent);
this.#events.set(address, fetchedEvent);
return await this.#buildNodeFromEvent(fetchedEvent, address, parentNode);
return await this.#buildNodeFromEvent(
fetchedEvent,
address,
parentNode,
);
}
} catch (error) {
console.debug(
@ -1017,7 +1028,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -1017,7 +1028,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// AI-NOTE: Remove e-tag processing from synchronous method
// E-tags should be resolved asynchronously in #resolveNode method
// Adding raw event IDs here causes duplicate processing
console.debug(`[PublicationTree] Found ${eTags.length} e-tags but skipping processing in buildNodeFromEvent`);
console.debug(
`[PublicationTree] Found ${eTags.length} e-tags but skipping processing in buildNodeFromEvent`,
);
}
const node: PublicationTreeNode = {
@ -1033,13 +1046,18 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -1033,13 +1046,18 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// Now directly adds child nodes to current node's children array
// Add children in the order they appear in the a-tags to preserve section order
// Use sequential processing to ensure order is maintained
console.log(`[PublicationTree] Adding ${childAddresses.length} children in order:`, childAddresses);
console.log(
`[PublicationTree] Adding ${childAddresses.length} children in order:`,
childAddresses,
);
for (const childAddress of childAddresses) {
console.log(`[PublicationTree] Adding child: ${childAddress}`);
try {
// Add the child node directly to the current node's children
this.#addNode(childAddress, node);
console.log(`[PublicationTree] Successfully added child: ${childAddress}`);
console.log(
`[PublicationTree] Successfully added child: ${childAddress}`,
);
} catch (error) {
console.warn(
`[PublicationTree] Error adding child ${childAddress} for ${node.address}:`,
@ -1060,24 +1078,44 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -1060,24 +1078,44 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (event.kind === 30040) {
// Check if this 30040 has any children (a-tags only, since e-tags are handled separately)
const hasChildren = event.tags.some((tag) => tag[0] === "a");
console.debug(`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${event.tags.find(t => t[0] === 'd')?.[1]} - hasChildren: ${hasChildren}, type: ${hasChildren ? 'Branch' : 'Leaf'}`);
return hasChildren ? PublicationTreeNodeType.Branch : PublicationTreeNodeType.Leaf;
console.debug(
`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${
event.tags.find((t) => t[0] === "d")?.[1]
} - hasChildren: ${hasChildren}, type: ${
hasChildren ? "Branch" : "Leaf"
}`,
);
return hasChildren
? PublicationTreeNodeType.Branch
: PublicationTreeNodeType.Leaf;
}
// Zettel kinds are always leaves
if ([30041, 30818, 30023].includes(event.kind)) {
console.debug(`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${event.tags.find(t => t[0] === 'd')?.[1]} - Zettel kind, type: Leaf`);
console.debug(
`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${
event.tags.find((t) => t[0] === "d")?.[1]
} - Zettel kind, type: Leaf`,
);
return PublicationTreeNodeType.Leaf;
}
// For other kinds, check if they have children (a-tags only)
const hasChildren = event.tags.some((tag) => tag[0] === "a");
console.debug(`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${event.tags.find(t => t[0] === 'd')?.[1]} - hasChildren: ${hasChildren}, type: ${hasChildren ? 'Branch' : 'Leaf'}`);
return hasChildren ? PublicationTreeNodeType.Branch : PublicationTreeNodeType.Leaf;
console.debug(
`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${
event.tags.find((t) => t[0] === "d")?.[1]
} - hasChildren: ${hasChildren}, type: ${
hasChildren ? "Branch" : "Leaf"
}`,
);
return hasChildren
? PublicationTreeNodeType.Branch
: PublicationTreeNodeType.Leaf;
}
// #endregion

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

@ -258,7 +258,7 @@ @@ -258,7 +258,7 @@
id="tag-type-select"
bind:value={selectedTagType}
onchange={onTagSettingsChange}
class="w-full text-xs bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white mt-1"
class="w-full text-xs bg-primary-50 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white mt-1"
>
<option value="t">Hashtags</option>
<option value="author">Authors</option>

9
src/lib/nostr/event.ts

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
export type NostrEvent = {
id: string;
kind: number;
pubkey: string;
created_at: number;
tags: string[][];
content: string;
};
export type AddressPointer = string;

22
src/lib/nostr/format.ts

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
export function shortenBech32(
id: string,
keepPrefix = true,
head = 8,
tail = 6,
) {
if (!id) return "";
const i = id.indexOf("1");
const prefix = i > 0 ? id.slice(0, i) : "";
const data = i > 0 ? id.slice(i + 1) : id;
const short = data.length > head + tail
? `${"${"}data.slice(0,head)}…${"${"}data.slice(-tail)}`
: data;
return keepPrefix && prefix ? `${"${"}prefix}1${"${"}short}` : short;
}
export function displayNameFrom(
npub: string,
p?: { name?: string; display_name?: string; nip05?: string },
) {
return (p?.display_name?.trim() || p?.name?.trim() ||
(p?.nip05 && p.nip05.split("@")[0]) || shortenBech32(npub, true));
}

20
src/lib/nostr/nip05.ts

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
export async function verifyNip05(
nip05: string,
pubkeyHex: string,
): Promise<boolean> {
try {
if (!nip05 || !pubkeyHex) return false;
const [name, domain] = nip05.toLowerCase().split("@");
if (!name || !domain) return false;
const url =
`https://${"${"}domain}/.well-known/nostr.json?name=${"${"}encodeURIComponent(name)}`;
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) return false;
const json = await res.json();
const found = json?.names?.[name];
return typeof found === "string" &&
found.toLowerCase() === pubkeyHex.toLowerCase();
} catch {
return false;
}
}

147
src/lib/nostr/nip58.ts

@ -0,0 +1,147 @@ @@ -0,0 +1,147 @@
import type { AddressPointer, NostrEvent } from "./event";
export type BadgeDefinition = {
kind: 30009;
id: string;
pubkey: string;
d: string;
a: AddressPointer;
name?: string;
description?: string;
image?: { url: string; size?: string } | null;
thumbs: { url: string; size?: string }[];
};
export type BadgeAward = {
kind: 8;
id: string;
pubkey: string;
a: AddressPointer;
recipients: { pubkey: string; relay?: string }[];
};
export type ProfileBadges = {
kind: 30008;
id: string;
pubkey: string;
pairs: { a: AddressPointer; awardId: string; relay?: string }[];
};
export const isKind = (e: NostrEvent, k: number) => e.kind === k;
const val = (tags: string[][], name: string) =>
tags.find((t) => t[0] === name)?.[1];
const vals = (tags: string[][], name: string) =>
tags.filter((t) => t[0] === name).map((t) => t.slice(1));
export function parseBadgeDefinition(e: NostrEvent): BadgeDefinition | null {
if (e.kind !== 30009) return null;
const d = val(e.tags, "d");
if (!d) return null;
const a: AddressPointer = `30009:${"${"}e.pubkey}:${"${"}d}`;
const name = val(e.tags, "name") || undefined;
const description = val(e.tags, "description") || undefined;
const imageTag = vals(e.tags, "image")[0];
const image = imageTag ? { url: imageTag[0], size: imageTag[1] } : null;
const thumbs = vals(e.tags, "thumb").map(([url, size]) => ({ url, size }));
return {
kind: 30009,
id: e.id,
pubkey: e.pubkey,
d,
a,
name,
description,
image,
thumbs,
};
}
export function parseBadgeAward(e: NostrEvent): BadgeAward | null {
if (e.kind !== 8) return null;
const atag = vals(e.tags, "a")[0];
if (!atag) return null;
const a: AddressPointer = atag[0];
const recipients = vals(e.tags, "p").map(([pubkey, relay]) => ({
pubkey,
relay,
}));
return { kind: 8, id: e.id, pubkey: e.pubkey, a, recipients };
}
export function parseProfileBadges(e: NostrEvent): ProfileBadges | null {
if (e.kind !== 30008) return null;
const d = val(e.tags, "d");
if (d !== "profile_badges") return null;
const pairs: { a: AddressPointer; awardId: string; relay?: string }[] = [];
for (let i = 0; i < e.tags.length; i++) {
const t = e.tags[i];
if (t[0] === "a") {
const a = t[1];
const nxt = e.tags[i + 1];
if (nxt && nxt[0] === "e") {
pairs.push({ a, awardId: nxt[1], relay: nxt[2] });
i++;
}
}
}
return { kind: 30008, id: e.id, pubkey: e.pubkey, pairs };
}
export type DisplayBadge = {
def: BadgeDefinition;
award: BadgeAward | null;
issuer: string;
thumbUrl: string | null;
title: string;
};
export function pickThumb(
def: BadgeDefinition,
prefer: ("16" | "32" | "64" | "256" | "512")[] = ["32", "64", "256"],
): string | null {
for (const p of prefer) {
const t = def.thumbs.find((t) => (t.size || "").startsWith(p + "x"));
if (t) return t.url;
}
return def.image?.url || null;
}
export function buildDisplayBadgesForUser(
userPubkey: string,
defs: BadgeDefinition[],
awards: BadgeAward[],
profileBadges?: ProfileBadges | null,
opts: { issuerWhitelist?: Set<string>; max?: number } = {},
): DisplayBadge[] {
const byA = new Map<string, BadgeDefinition>(defs.map((d) => [d.a, d]));
const byAwardId = new Map<string, BadgeAward>(awards.map((a) => [a.id, a]));
const isWhitelisted = (issuer: string) =>
!opts.issuerWhitelist || opts.issuerWhitelist.has(issuer);
let out: DisplayBadge[] = [];
if (profileBadges && profileBadges.pubkey === userPubkey) {
for (const { a, awardId } of profileBadges.pairs) {
const def = byA.get(a);
if (!def) {
continue;
}
const award = byAwardId.get(awardId) || null;
if (
award &&
(award.a !== a ||
!award.recipients.find((r) => r.pubkey === userPubkey))
) continue;
if (!isWhitelisted(def.pubkey)) continue;
out.push({
def,
award,
issuer: def.pubkey,
thumbUrl: pickThumb(def),
title: def.name || def.d,
});
}
} else {for (const aw of awards) {
if (!aw.recipients.find((r) => r.pubkey === userPubkey)) continue;
const def = byA.get(aw.a);
if (!def) continue;
if (!isWhitelisted(def.pubkey)) continue;
out.push({
def,
award: aw,
issuer: def.pubkey,
thumbUrl: pickThumb(def),
title: def.name || def.d,
});
}}
if (opts.max && out.length > opts.max) out = out.slice(0, opts.max);
return out;
}

9
src/lib/nostr/types.ts

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
export type NostrProfile = {
name?: string;
display_name?: string;
picture?: string;
about?: string;
nip05?: string;
lud16?: string;
badges?: Array<{ label: string; color?: string }>;
};

10
src/lib/snippets/UserSnippets.svelte

@ -19,7 +19,7 @@ @@ -19,7 +19,7 @@
{@const p = profile as UserProfile}
<span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
class="npub-badge bg-transparent border-none !p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{p.displayName ||
@ -32,7 +32,7 @@ @@ -32,7 +32,7 @@
{@const debugError = console.error("Error fetching profile for", npub, ":", error)}
<span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
class="npub-badge bg-transparent border-none !p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{npub.slice(0, 8) + "..." + npub.slice(-4)}
@ -43,7 +43,7 @@ @@ -43,7 +43,7 @@
{#await createProfileLinkWithVerification(npub as string, displayText, ndk)}
<span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
class="npub-badge bg-transparent border-none !p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{displayText}
@ -52,7 +52,7 @@ @@ -52,7 +52,7 @@
{:then html}
<span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
class="npub-badge bg-transparent border-none !p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{displayText}
@ -62,7 +62,7 @@ @@ -62,7 +62,7 @@
{:catch}
<span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
class="npub-badge bg-transparent border-none !p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{displayText}

34
src/lib/stores/techStore.ts

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
/**
* Tech Store - Alexandria
*
* This store manages the "show technical details" user setting for the Alexandria app.
*
* Use case:
* - Used with the ATechBlock component to hide nostr-specific developer details (e.g., raw event blocks) behind a collapsed section.
* - Users can toggle visibility of these technical details via the ATechToggle component.
* - If a user is a nostr developer, they can set their profile to always show technical details by default.
* - The setting is persisted in localStorage and reflected in the DOM via a data attribute for styling purposes.
*
* Example usage:
* <ATechBlock content={...} />
* <ATechToggle />
*
* This enables a cleaner UI for non-developers, while providing easy access to advanced information for developers.
*/
import { writable } from "svelte/store";
const KEY = "alexandria/showTech";
// Default false unless explicitly set to 'true' in localStorage
const initial = typeof localStorage !== "undefined"
? localStorage.getItem(KEY) === "true"
: false;
export const showTech = writable<boolean>(initial);
showTech.subscribe((v) => {
if (typeof document !== "undefined") {
document.documentElement.dataset.tech = v ? "on" : "off";
localStorage.setItem(KEY, String(v));
}
});

18
src/lib/stores/themeStore.ts

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
import { writable } from "svelte/store";
const KEY = "alexandria/theme";
const initial =
(typeof localStorage !== "undefined" && localStorage.getItem(KEY)) ||
"light";
export const theme = writable(initial);
theme.subscribe((v) => {
if (typeof document !== "undefined") {
document.documentElement.dataset.theme = String(v);
localStorage.setItem(KEY, String(v));
}
});
export const setTheme = (t: string) => theme.set(t);

7
src/lib/stores/userStore.ts

@ -16,7 +16,6 @@ import { @@ -16,7 +16,6 @@ import {
import { loginStorageKey } from "../consts.ts";
import { nip19 } from "nostr-tools";
export interface UserState {
pubkey: string | null;
npub: string | null;
@ -248,7 +247,11 @@ export async function loginWithExtension(ndk: NDK) { @@ -248,7 +247,11 @@ export async function loginWithExtension(ndk: NDK) {
/**
* Login with Amber (NIP-46)
*/
export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser, ndk: NDK) {
export async function loginWithAmber(
amberSigner: NDKSigner,
user: NDKUser,
ndk: NDK,
) {
if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login
const npub = user.npub;

3
src/lib/styles/cva.ts

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
import { cva, type VariantProps } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
export { cva, twMerge, type VariantProps };

48
src/lib/utils/cache_manager.ts

@ -1,11 +1,11 @@ @@ -1,11 +1,11 @@
import { unifiedProfileCache } from './npubCache';
import { searchCache } from './searchCache';
import { indexEventCache } from './indexEventCache';
import { clearRelaySetCache } from '../ndk';
import { unifiedProfileCache } from "./npubCache";
import { searchCache } from "./searchCache";
import { indexEventCache } from "./indexEventCache";
import { clearRelaySetCache } from "../ndk";
/**
* Clears all application caches
*
*
* Clears:
* - unifiedProfileCache (profile metadata)
* - searchCache (search results)
@ -13,18 +13,18 @@ import { clearRelaySetCache } from '../ndk'; @@ -13,18 +13,18 @@ import { clearRelaySetCache } from '../ndk';
* - relaySetCache (relay configuration)
*/
export function clearAllCaches(): void {
console.log('[CacheManager] Clearing all application caches...');
console.log("[CacheManager] Clearing all application caches...");
// Clear in-memory caches
unifiedProfileCache.clear();
searchCache.clear();
indexEventCache.clear();
clearRelaySetCache();
// Clear localStorage caches
clearLocalStorageCaches();
console.log('[CacheManager] All caches cleared successfully');
console.log("[CacheManager] All caches cleared successfully");
}
/**
@ -32,41 +32,43 @@ export function clearAllCaches(): void { @@ -32,41 +32,43 @@ export function clearAllCaches(): void {
* This is useful when profile pictures or metadata are stale
*/
export function clearProfileCaches(): void {
console.log('[CacheManager] Clearing profile-specific caches...');
console.log("[CacheManager] Clearing profile-specific caches...");
// Clear unified profile cache
unifiedProfileCache.clear();
// Clear profile-related search results
// Note: searchCache doesn't have a way to clear specific types, so we clear all
// This is acceptable since profile searches are the most common
searchCache.clear();
console.log('[CacheManager] Profile caches cleared successfully');
console.log("[CacheManager] Profile caches cleared successfully");
}
/**
* Clears localStorage caches
*/
function clearLocalStorageCaches(): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
const keysToRemove: string[] = [];
// Find all localStorage keys that start with 'alexandria'
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('alexandria')) {
if (key && key.startsWith("alexandria")) {
keysToRemove.push(key);
}
}
// Remove the keys
keysToRemove.forEach(key => {
keysToRemove.forEach((key) => {
localStorage.removeItem(key);
});
console.log(`[CacheManager] Cleared ${keysToRemove.length} localStorage items`);
console.log(
`[CacheManager] Cleared ${keysToRemove.length} localStorage items`,
);
}
/**

53
src/lib/utils/event_input_utils.ts

@ -206,7 +206,6 @@ function extractMarkdownTopHeader(content: string): string | null { @@ -206,7 +206,6 @@ function extractMarkdownTopHeader(content: string): string | null {
// Event Construction
// =========================
/**
* Builds a set of events for a 30040 publication: one 30040 index event and one 30041 event per section.
* Each 30041 gets a d-tag (normalized section header) and a title tag (raw section header).
@ -263,31 +262,33 @@ export function build30040EventSet( @@ -263,31 +262,33 @@ export function build30040EventSet(
console.log("Index event:", { documentTitle, indexDTag });
// Create section events with their metadata
const sectionEvents: NDKEvent[] = parsed.sections.map((section: any, i: number) => {
const sectionDTag = `${indexDTag}-${normalizeDTagValue(section.title)}`;
console.log(`Creating section ${i}:`, {
title: section.title,
dTag: sectionDTag,
content: section.content,
metadata: section.metadata,
});
// Convert section metadata to tags
const sectionMetadataTags = metadataToTags(section.metadata);
return new NDKEventClass(ndk, {
kind: 30041,
content: section.content,
tags: [
...tags,
...sectionMetadataTags,
["d", sectionDTag],
["title", section.title],
],
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
});
const sectionEvents: NDKEvent[] = parsed.sections.map(
(section: any, i: number) => {
const sectionDTag = `${indexDTag}-${normalizeDTagValue(section.title)}`;
console.log(`Creating section ${i}:`, {
title: section.title,
dTag: sectionDTag,
content: section.content,
metadata: section.metadata,
});
// Convert section metadata to tags
const sectionMetadataTags = metadataToTags(section.metadata);
return new NDKEventClass(ndk, {
kind: 30041,
content: section.content,
tags: [
...tags,
...sectionMetadataTags,
["d", sectionDTag],
["title", section.title],
],
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
},
);
// Create proper a tags with format: kind:pubkey:d-tag
const aTags = sectionEvents.map((event) => {

5
src/lib/utils/event_search.ts

@ -10,7 +10,10 @@ import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; @@ -10,7 +10,10 @@ import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
/**
* Search for a single event by ID or filter
*/
export async function searchEvent(query: string, ndk: NDK): Promise<NDKEvent | null> {
export async function searchEvent(
query: string,
ndk: NDK,
): Promise<NDKEvent | null> {
if (!ndk) {
console.warn("[Search] No NDK instance available");
return null;

2
src/lib/utils/image_utils.ts

@ -21,4 +21,4 @@ export function generateDarkPastelColor(seed: string): string { @@ -21,4 +21,4 @@ export function generateDarkPastelColor(seed: string): string {
return `#${r.toString(16).padStart(2, "0")}${
g.toString(16).padStart(2, "0")
}${b.toString(16).padStart(2, "0")}`;
}
}

48
src/lib/utils/markup/advancedMarkupParser.ts

@ -399,31 +399,37 @@ function processInlineCodeMath(content: string): string { @@ -399,31 +399,37 @@ function processInlineCodeMath(content: string): string {
// Check if the code content contains math expressions
const hasInlineMath = /\$((?:[^$\\]|\\.)*?)\$/.test(codeContent);
const hasDisplayMath = /\$\$[\s\S]*?\$\$/.test(codeContent);
if (!hasInlineMath && !hasDisplayMath) {
// No math found, return the original inline code
return match;
}
// Process display math ($$...$$) first to avoid conflicts with inline math
let processedContent = codeContent.replace(/\$\$([\s\S]*?)\$\$/g, (mathMatch: string, mathContent: string) => {
// Skip empty math expressions
if (!mathContent.trim()) {
return mathMatch;
}
return `<span class="math-display">\\[${mathContent}\\]</span>`;
});
// Process inline math ($...$) after display math
// Use a more sophisticated regex that handles escaped dollar signs
processedContent = processedContent.replace(/\$((?:[^$\\]|\\.)*?)\$/g, (mathMatch: string, mathContent: string) => {
// Skip empty math expressions
if (!mathContent.trim()) {
return mathMatch;
}
return `<span class="math-inline">\\(${mathContent}\\)</span>`;
});
// Process display math ($$...$$) first to avoid conflicts with inline math
let processedContent = codeContent.replace(
/\$\$([\s\S]*?)\$\$/g,
(mathMatch: string, mathContent: string) => {
// Skip empty math expressions
if (!mathContent.trim()) {
return mathMatch;
}
return `<span class="math-display">\\[${mathContent}\\]</span>`;
},
);
// Process inline math ($...$) after display math
// Use a more sophisticated regex that handles escaped dollar signs
processedContent = processedContent.replace(
/\$((?:[^$\\]|\\.)*?)\$/g,
(mathMatch: string, mathContent: string) => {
// Skip empty math expressions
if (!mathContent.trim()) {
return mathMatch;
}
return `<span class="math-inline">\\(${mathContent}\\)</span>`;
},
);
return `\`${processedContent}\``;
});
}

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

@ -1,14 +1,12 @@ @@ -1,14 +1,12 @@
import NDK from "@nostr-dev-kit/ndk";
import {
processBasicFormatting,
processBasicFormatting,
processBlockquotes,
processEmojiShortcodes,
processNostrIdentifiersInText,
processWikilinks,
} from "./markupUtils.ts";
export function preProcessBasicMarkup(text: string): string {
try {
// Process basic text formatting first
@ -26,7 +24,10 @@ export function preProcessBasicMarkup(text: string): string { @@ -26,7 +24,10 @@ export function preProcessBasicMarkup(text: string): string {
}
}
export async function postProcessBasicMarkup(text: string, ndk?: NDK): Promise<string> {
export async function postProcessBasicMarkup(
text: string,
ndk?: NDK,
): Promise<string> {
try {
// Process Nostr identifiers last
let processedText = await processNostrIdentifiersInText(text, ndk);
@ -40,7 +41,10 @@ export async function postProcessBasicMarkup(text: string, ndk?: NDK): Promise<s @@ -40,7 +41,10 @@ export async function postProcessBasicMarkup(text: string, ndk?: NDK): Promise<s
}
}
export async function parseBasicMarkup(text: string, ndk?: NDK): Promise<string> {
export async function parseBasicMarkup(
text: string,
ndk?: NDK,
): Promise<string> {
if (!text) return "";
try {

5
src/lib/utils/markup/embeddedMarkupParser.ts

@ -1,4 +1,7 @@ @@ -1,4 +1,7 @@
import { postProcessBasicMarkup, preProcessBasicMarkup } from "./basicMarkupParser.ts";
import {
postProcessBasicMarkup,
preProcessBasicMarkup,
} from "./basicMarkupParser.ts";
import { processNostrIdentifiersWithEmbeddedEvents } from "./markupUtils.ts";
/**

75
src/lib/utils/markup/markupUtils.ts

@ -80,7 +80,10 @@ export function replaceAlexandriaNostrLinks(text: string): string { @@ -80,7 +80,10 @@ export function replaceAlexandriaNostrLinks(text: string): string {
return text;
}
export function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string {
export function renderListGroup(
lines: string[],
typeHint?: "ol" | "ul",
): string {
function parseList(
start: number,
indent: number,
@ -322,7 +325,9 @@ export async function processNostrIdentifiersInText( @@ -322,7 +325,9 @@ export async function processNostrIdentifiersInText(
metadata = await getUserMetadata(identifier, ndk);
} else {
// Fallback when NDK is not available - just use the identifier
metadata = { name: identifier.slice(0, 8) + "..." + identifier.slice(-4) };
metadata = {
name: identifier.slice(0, 8) + "..." + identifier.slice(-4),
};
}
const displayText = metadata.displayName || metadata.name;
const link = createProfileLink(identifier, displayText);
@ -391,15 +396,19 @@ export function processAllNostrIdentifiers(text: string): string { @@ -391,15 +396,19 @@ export function processAllNostrIdentifiers(text: string): string {
// Pattern for prefixed nostr identifiers (nostr:npub1, nostr:note1, etc.)
// This handles both full identifiers and partial ones that might appear in content
const prefixedNostrPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
const prefixedNostrPattern =
/nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
// Pattern for bare nostr identifiers (npub1, note1, nevent1, naddr1)
// Exclude matches that are part of URLs to avoid breaking existing links
const bareNostrPattern = /(?<!https?:\/\/[^\s]*)(?<!wss?:\/\/[^\s]*)(?<!nostr:)(npub1|note1|nevent1|naddr1)[a-zA-Z0-9]{20,}/g;
const bareNostrPattern =
/(?<!https?:\/\/[^\s]*)(?<!wss?:\/\/[^\s]*)(?<!nostr:)(npub1|note1|nevent1|naddr1)[a-zA-Z0-9]{20,}/g;
// Process prefixed nostr identifiers first
const prefixedMatches = Array.from(processedText.matchAll(prefixedNostrPattern));
const prefixedMatches = Array.from(
processedText.matchAll(prefixedNostrPattern),
);
// Process them in reverse order to avoid index shifting issues
for (let i = prefixedMatches.length - 1; i >= 0; i--) {
const match = prefixedMatches[i];
@ -407,11 +416,12 @@ export function processAllNostrIdentifiers(text: string): string { @@ -407,11 +416,12 @@ export function processAllNostrIdentifiers(text: string): string {
const matchIndex = match.index ?? 0;
// Create shortened display text
const identifier = fullMatch.replace('nostr:', '');
const identifier = fullMatch.replace("nostr:", "");
const displayText = `${identifier.slice(0, 8)}...${identifier.slice(-4)}`;
// Create clickable link
const replacement = `<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="${fullMatch}">${displayText}</a>`;
const replacement =
`<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="${fullMatch}">${displayText}</a>`;
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement +
@ -420,7 +430,7 @@ export function processAllNostrIdentifiers(text: string): string { @@ -420,7 +430,7 @@ export function processAllNostrIdentifiers(text: string): string {
// Process bare nostr identifiers
const bareMatches = Array.from(processedText.matchAll(bareNostrPattern));
// Process them in reverse order to avoid index shifting issues
for (let i = bareMatches.length - 1; i >= 0; i--) {
const match = bareMatches[i];
@ -429,9 +439,10 @@ export function processAllNostrIdentifiers(text: string): string { @@ -429,9 +439,10 @@ export function processAllNostrIdentifiers(text: string): string {
// Create shortened display text
const displayText = `${fullMatch.slice(0, 8)}...${fullMatch.slice(-4)}`;
// Create clickable link with nostr: prefix for the href
const replacement = `<a href="/events?id=nostr:${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="nostr:${fullMatch}">${displayText}</a>`;
const replacement =
`<a href="/events?id=nostr:${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="nostr:${fullMatch}">${displayText}</a>`;
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement +
@ -439,9 +450,12 @@ export function processAllNostrIdentifiers(text: string): string { @@ -439,9 +450,12 @@ export function processAllNostrIdentifiers(text: string): string {
}
// Also handle any remaining truncated prefixed identifiers that might be cut off or incomplete
const truncatedPrefixedPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{8,}/g;
const truncatedPrefixedMatches = Array.from(processedText.matchAll(truncatedPrefixedPattern));
const truncatedPrefixedPattern =
/nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{8,}/g;
const truncatedPrefixedMatches = Array.from(
processedText.matchAll(truncatedPrefixedPattern),
);
for (let i = truncatedPrefixedMatches.length - 1; i >= 0; i--) {
const match = truncatedPrefixedMatches[i];
const [fullMatch] = match;
@ -451,11 +465,14 @@ export function processAllNostrIdentifiers(text: string): string { @@ -451,11 +465,14 @@ export function processAllNostrIdentifiers(text: string): string {
if (fullMatch.length >= 30) continue; // Full identifiers are at least 30 chars
// Create display text for truncated identifiers
const identifier = fullMatch.replace('nostr:', '');
const displayText = identifier.length > 12 ? `${identifier.slice(0, 8)}...${identifier.slice(-4)}` : identifier;
const identifier = fullMatch.replace("nostr:", "");
const displayText = identifier.length > 12
? `${identifier.slice(0, 8)}...${identifier.slice(-4)}`
: identifier;
// Create clickable link
const replacement = `<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="${fullMatch}">${displayText}</a>`;
const replacement =
`<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="${fullMatch}">${displayText}</a>`;
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement +
@ -463,9 +480,12 @@ export function processAllNostrIdentifiers(text: string): string { @@ -463,9 +480,12 @@ export function processAllNostrIdentifiers(text: string): string {
}
// Handle truncated bare identifiers
const truncatedBarePattern = /(?<!https?:\/\/[^\s]*)(?<!wss?:\/\/[^\s]*)(?<!nostr:)(npub1|note1|nevent1|naddr1)[a-zA-Z0-9]{8,}/g;
const truncatedBareMatches = Array.from(processedText.matchAll(truncatedBarePattern));
const truncatedBarePattern =
/(?<!https?:\/\/[^\s]*)(?<!wss?:\/\/[^\s]*)(?<!nostr:)(npub1|note1|nevent1|naddr1)[a-zA-Z0-9]{8,}/g;
const truncatedBareMatches = Array.from(
processedText.matchAll(truncatedBarePattern),
);
for (let i = truncatedBareMatches.length - 1; i >= 0; i--) {
const match = truncatedBareMatches[i];
const [fullMatch] = match;
@ -475,10 +495,13 @@ export function processAllNostrIdentifiers(text: string): string { @@ -475,10 +495,13 @@ export function processAllNostrIdentifiers(text: string): string {
if (fullMatch.length >= 30) continue; // Full identifiers are at least 30 chars
// Create display text for truncated identifiers
const displayText = fullMatch.length > 12 ? `${fullMatch.slice(0, 8)}...${fullMatch.slice(-4)}` : fullMatch;
const displayText = fullMatch.length > 12
? `${fullMatch.slice(0, 8)}...${fullMatch.slice(-4)}`
: fullMatch;
// Create clickable link
const replacement = `<a href="/events?id=nostr:${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="nostr:${fullMatch}">${displayText}</a>`;
const replacement =
`<a href="/events?id=nostr:${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="nostr:${fullMatch}">${displayText}</a>`;
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement +

22
src/lib/utils/nostrUtils.ts

@ -7,9 +7,9 @@ import type { Filter } from "./search_types.ts"; @@ -7,9 +7,9 @@ import type { Filter } from "./search_types.ts";
import {
anonymousRelays,
communityRelays,
localRelays,
searchRelays,
secondaryRelays,
localRelays,
} from "../consts.ts";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
@ -205,22 +205,22 @@ export async function processNostrIdentifiers( @@ -205,22 +205,22 @@ export async function processNostrIdentifiers(
if (/https?:\/\/$|www\.$/i.test(before)) {
return true;
}
// Check if the match is part of a larger URL structure
// Look for common URL patterns that might contain nostr identifiers
const beforeContext = text.slice(Math.max(0, index - 50), index);
const afterContext = text.slice(index, Math.min(text.length, index + 50));
// Check if there's a URL-like structure around the match
const urlPatterns = [
/https?:\/\/[^\s]*$/i, // URL starting with http(s)://
/www\.[^\s]*$/i, // URL starting with www.
/[^\s]*\.(com|org|net|io|eu|co|me|app|dev)[^\s]*$/i, // Common TLDs
/[^\s]*\/[^\s]*$/i, // Path-like structures
/https?:\/\/[^\s]*$/i, // URL starting with http(s)://
/www\.[^\s]*$/i, // URL starting with www.
/[^\s]*\.(com|org|net|io|eu|co|me|app|dev)[^\s]*$/i, // Common TLDs
/[^\s]*\/[^\s]*$/i, // Path-like structures
];
const combinedContext = beforeContext + afterContext;
return urlPatterns.some(pattern => pattern.test(combinedContext));
return urlPatterns.some((pattern) => pattern.test(combinedContext));
}
// Process profiles (npub and nprofile)
@ -440,8 +440,8 @@ export async function fetchEventWithFallback( @@ -440,8 +440,8 @@ export async function fetchEventWithFallback(
// AI-NOTE: Include ALL available relays for comprehensive event discovery
// This ensures we don't miss events that might be on any available relay
allRelays = [
...secondaryRelays,
...searchRelays,
...secondaryRelays,
...searchRelays,
...anonymousRelays,
...inboxRelays, // Include user's inbox relays
...outboxRelays, // Include user's outbox relays

99
src/lib/utils/npubCache.ts

@ -59,14 +59,18 @@ class UnifiedProfileCache { @@ -59,14 +59,18 @@ class UnifiedProfileCache {
/**
* Get profile data, fetching fresh data if needed
*/
async getProfile(identifier: string, ndk?: NDK, force = false): Promise<NpubMetadata> {
async getProfile(
identifier: string,
ndk?: NDK,
force = false,
): Promise<NpubMetadata> {
const cleanId = identifier.replace(/^nostr:/, "");
// Check cache first (unless forced)
if (!force && this.cache.has(cleanId)) {
const entry = this.cache.get(cleanId)!;
const now = Date.now();
// Return cached data if not expired
if ((now - entry.timestamp) < this.maxAge) {
console.log("UnifiedProfileCache: Returning cached profile:", cleanId);
@ -81,8 +85,13 @@ class UnifiedProfileCache { @@ -81,8 +85,13 @@ class UnifiedProfileCache {
/**
* Fetch profile from all available relays and cache it
*/
private async fetchAndCacheProfile(identifier: string, ndk?: NDK): Promise<NpubMetadata> {
const fallback = { name: `${identifier.slice(0, 8)}...${identifier.slice(-4)}` };
private async fetchAndCacheProfile(
identifier: string,
ndk?: NDK,
): Promise<NpubMetadata> {
const fallback = {
name: `${identifier.slice(0, 8)}...${identifier.slice(-4)}`,
};
try {
if (!ndk) {
@ -92,7 +101,10 @@ class UnifiedProfileCache { @@ -92,7 +101,10 @@ class UnifiedProfileCache {
const decoded = nip19.decode(identifier);
if (!decoded) {
console.warn("UnifiedProfileCache: Failed to decode identifier:", identifier);
console.warn(
"UnifiedProfileCache: Failed to decode identifier:",
identifier,
);
return fallback;
}
@ -103,11 +115,17 @@ class UnifiedProfileCache { @@ -103,11 +115,17 @@ class UnifiedProfileCache {
} else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey;
} else {
console.warn("UnifiedProfileCache: Unsupported identifier type:", decoded.type);
console.warn(
"UnifiedProfileCache: Unsupported identifier type:",
decoded.type,
);
return fallback;
}
console.log("UnifiedProfileCache: Fetching fresh profile for pubkey:", pubkey);
console.log(
"UnifiedProfileCache: Fetching fresh profile for pubkey:",
pubkey,
);
// Use fetchEventWithFallback to search ALL available relays
const profileEvent = await fetchEventWithFallback(ndk, {
@ -116,7 +134,10 @@ class UnifiedProfileCache { @@ -116,7 +134,10 @@ class UnifiedProfileCache {
});
if (!profileEvent || !profileEvent.content) {
console.warn("UnifiedProfileCache: No profile event found for:", pubkey);
console.warn(
"UnifiedProfileCache: No profile event found for:",
pubkey,
);
return fallback;
}
@ -147,7 +168,6 @@ class UnifiedProfileCache { @@ -147,7 +168,6 @@ class UnifiedProfileCache {
console.log("UnifiedProfileCache: Cached fresh profile:", metadata);
return metadata;
} catch (e) {
console.error("UnifiedProfileCache: Error fetching profile:", e);
return fallback;
@ -160,7 +180,7 @@ class UnifiedProfileCache { @@ -160,7 +180,7 @@ class UnifiedProfileCache {
getCached(identifier: string): NpubMetadata | undefined {
const cleanId = identifier.replace(/^nostr:/, "");
const entry = this.cache.get(cleanId);
if (entry) {
const now = Date.now();
if ((now - entry.timestamp) < this.maxAge) {
@ -170,14 +190,19 @@ class UnifiedProfileCache { @@ -170,14 +190,19 @@ class UnifiedProfileCache {
this.cache.delete(cleanId);
}
}
return undefined;
}
/**
* Set profile data in cache
*/
set(identifier: string, profile: NpubMetadata, pubkey?: string, relaySource?: string): void {
set(
identifier: string,
profile: NpubMetadata,
pubkey?: string,
relaySource?: string,
): void {
const cleanId = identifier.replace(/^nostr:/, "");
const entry: CacheEntry = {
profile,
@ -199,7 +224,7 @@ class UnifiedProfileCache { @@ -199,7 +224,7 @@ class UnifiedProfileCache {
has(identifier: string): boolean {
const cleanId = identifier.replace(/^nostr:/, "");
const entry = this.cache.get(cleanId);
if (entry) {
const now = Date.now();
if ((now - entry.timestamp) < this.maxAge) {
@ -209,7 +234,7 @@ class UnifiedProfileCache { @@ -209,7 +234,7 @@ class UnifiedProfileCache {
this.cache.delete(cleanId);
}
}
return false;
}
@ -219,7 +244,7 @@ class UnifiedProfileCache { @@ -219,7 +244,7 @@ class UnifiedProfileCache {
delete(identifier: string): boolean {
const cleanId = identifier.replace(/^nostr:/, "");
const entry = this.cache.get(cleanId);
if (entry) {
this.cache.delete(cleanId);
if (entry.pubkey && entry.pubkey !== cleanId) {
@ -228,7 +253,7 @@ class UnifiedProfileCache { @@ -228,7 +253,7 @@ class UnifiedProfileCache {
this.saveToStorage();
return true;
}
return false;
}
@ -264,18 +289,20 @@ class UnifiedProfileCache { @@ -264,18 +289,20 @@ class UnifiedProfileCache {
cleanup(): void {
const now = Date.now();
const expiredKeys: string[] = [];
for (const [key, entry] of this.cache.entries()) {
if ((now - entry.timestamp) >= this.maxAge) {
expiredKeys.push(key);
}
}
expiredKeys.forEach(key => this.cache.delete(key));
expiredKeys.forEach((key) => this.cache.delete(key));
if (expiredKeys.length > 0) {
this.saveToStorage();
console.log(`UnifiedProfileCache: Cleaned up ${expiredKeys.length} expired entries`);
console.log(
`UnifiedProfileCache: Cleaned up ${expiredKeys.length} expired entries`,
);
}
}
}
@ -294,7 +321,8 @@ if (typeof window !== "undefined") { @@ -294,7 +321,8 @@ if (typeof window !== "undefined") {
// but make it use the unified cache internally
export const npubCache = {
get: (key: string) => unifiedProfileCache.getCached(key),
set: (key: string, value: NpubMetadata) => unifiedProfileCache.set(key, value),
set: (key: string, value: NpubMetadata) =>
unifiedProfileCache.set(key, value),
has: (key: string) => unifiedProfileCache.has(key),
delete: (key: string) => unifiedProfileCache.delete(key),
clear: () => unifiedProfileCache.clear(),
@ -303,14 +331,19 @@ export const npubCache = { @@ -303,14 +331,19 @@ export const npubCache = {
};
// Legacy compatibility for old profileCache functions
export async function getDisplayName(pubkey: string, ndk: NDK): Promise<string> {
export async function getDisplayName(
pubkey: string,
ndk: NDK,
): Promise<string> {
const profile = await unifiedProfileCache.getProfile(pubkey, ndk);
return profile.displayName || profile.name || `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
return profile.displayName || profile.name ||
`${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
}
export function getDisplayNameSync(pubkey: string): string {
const profile = unifiedProfileCache.getCached(pubkey);
return profile?.displayName || profile?.name || `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
return profile?.displayName || profile?.name ||
`${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
}
export async function batchFetchProfiles(
@ -319,15 +352,15 @@ export async function batchFetchProfiles( @@ -319,15 +352,15 @@ export async function batchFetchProfiles(
onProgress?: (fetched: number, total: number) => void,
): Promise<NDKEvent[]> {
const allProfileEvents: NDKEvent[] = [];
if (onProgress) onProgress(0, pubkeys.length);
// Fetch profiles in parallel using the unified cache
const fetchPromises = pubkeys.map(async (pubkey, index) => {
try {
const profile = await unifiedProfileCache.getProfile(pubkey, ndk);
if (onProgress) onProgress(index + 1, pubkeys.length);
// Create a mock NDKEvent for compatibility
const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile);
@ -338,14 +371,14 @@ export async function batchFetchProfiles( @@ -338,14 +371,14 @@ export async function batchFetchProfiles(
return null;
}
});
const results = await Promise.allSettled(fetchPromises);
results.forEach(result => {
if (result.status === 'fulfilled' && result.value) {
results.forEach((result) => {
if (result.status === "fulfilled" && result.value) {
allProfileEvents.push(result.value);
}
});
return allProfileEvents;
}

53
src/lib/utils/profile_search.ts

@ -1,8 +1,17 @@ @@ -1,8 +1,17 @@
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { getNpubFromNip05, getUserMetadata, fetchEventWithFallback } from "./nostrUtils.ts";
import {
fetchEventWithFallback,
getNpubFromNip05,
getUserMetadata,
} from "./nostrUtils.ts";
import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
import { searchCache } from "./searchCache.ts";
import { communityRelays, searchRelays, secondaryRelays, anonymousRelays } from "../consts.ts";
import {
anonymousRelays,
communityRelays,
searchRelays,
secondaryRelays,
} from "../consts.ts";
import { get } from "svelte/store";
import type { NostrProfile, ProfileSearchResult } from "./search_types.ts";
import {
@ -78,7 +87,7 @@ export async function searchProfiles( @@ -78,7 +87,7 @@ export async function searchProfiles(
const npub = await getNpubFromNip05(normalizedNip05);
if (npub) {
const metadata = await getUserMetadata(npub, ndk);
// AI-NOTE: Fetch the original event timestamp to preserve created_at
let created_at: number | undefined = undefined;
try {
@ -94,9 +103,12 @@ export async function searchProfiles( @@ -94,9 +103,12 @@ export async function searchProfiles(
}
}
} catch (e) {
console.warn("profile_search: Failed to fetch original event timestamp:", e);
console.warn(
"profile_search: Failed to fetch original event timestamp:",
e,
);
}
const profile: NostrProfile & { created_at?: number } = {
...metadata,
pubkey: npub,
@ -207,7 +219,7 @@ async function searchNip05Domains( @@ -207,7 +219,7 @@ async function searchNip05Domains(
npub,
);
const metadata = await getUserMetadata(npub, ndk);
// AI-NOTE: Fetch the original event timestamp to preserve created_at
let created_at: number | undefined = undefined;
try {
@ -223,9 +235,12 @@ async function searchNip05Domains( @@ -223,9 +235,12 @@ async function searchNip05Domains(
}
}
} catch (e) {
console.warn("profile_search: Failed to fetch original event timestamp:", e);
console.warn(
"profile_search: Failed to fetch original event timestamp:",
e,
);
}
const profile: NostrProfile & { created_at?: number } = {
...metadata,
pubkey: npub,
@ -259,7 +274,7 @@ async function searchNip05Domains( @@ -259,7 +274,7 @@ async function searchNip05Domains(
if (npub) {
console.log("NIP-05 search: found npub for", nip05Address, ":", npub);
const metadata = await getUserMetadata(npub, ndk);
// AI-NOTE: Fetch the original event timestamp to preserve created_at
let created_at: number | undefined = undefined;
try {
@ -275,9 +290,12 @@ async function searchNip05Domains( @@ -275,9 +290,12 @@ async function searchNip05Domains(
}
}
} catch (e) {
console.warn("profile_search: Failed to fetch original event timestamp:", e);
console.warn(
"profile_search: Failed to fetch original event timestamp:",
e,
);
}
const profile: NostrProfile & { created_at?: number } = {
...metadata,
pubkey: npub,
@ -328,12 +346,14 @@ async function quickRelaySearch( @@ -328,12 +346,14 @@ async function quickRelaySearch(
// AI-NOTE: Use ALL available relays for comprehensive profile discovery
// This ensures we don't miss profiles due to stale cache or limited relay coverage
// Get all available relays from NDK pool (most comprehensive)
const poolRelays = Array.from(ndk.pool.relays.values()).map((r: any) => r.url) as string[];
const poolRelays = Array.from(ndk.pool.relays.values()).map((r: any) =>
r.url
) as string[];
const userInboxRelays = get(activeInboxRelays);
const userOutboxRelays = get(activeOutboxRelays);
// Combine ALL available relays for maximum coverage
const allRelayUrls = [
...poolRelays, // All NDK pool relays
@ -347,7 +367,10 @@ async function quickRelaySearch( @@ -347,7 +367,10 @@ async function quickRelaySearch(
// Deduplicate relay URLs
const uniqueRelayUrls = [...new Set(allRelayUrls)];
console.log("Using ALL available relays for profile search:", uniqueRelayUrls);
console.log(
"Using ALL available relays for profile search:",
uniqueRelayUrls,
);
console.log("Total relays for profile search:", uniqueRelayUrls.length);
// Create relay sets for parallel search

366
src/lib/utils/subscription_search.ts

@ -27,12 +27,12 @@ const normalizeUrl = (url: string): string => { @@ -27,12 +27,12 @@ const normalizeUrl = (url: string): string => {
// AI-NOTE: Define prioritized event kinds for subscription search
const PRIORITIZED_EVENT_KINDS = new Set([
1, // Text notes
1, // Text notes
1111, // Comments
9802, // Highlights
20, // Article
21, // Article
22, // Article
20, // Article
21, // Article
22, // Article
1222, // Long-form content
1244, // Long-form content
30023, // Long-form content
@ -47,7 +47,7 @@ const PRIORITIZED_EVENT_KINDS = new Set([ @@ -47,7 +47,7 @@ const PRIORITIZED_EVENT_KINDS = new Set([
* @param maxResults Maximum number of results to return
* @param ndk NDK instance for user list and community checks
* @returns Prioritized array of events
*
*
* Priority tiers:
* 1. Prioritized event kinds (1, 1111, 9802, 20, 21, 22, 1222, 1244, 30023, 30040, 30041) + target pubkey events (n: searches only)
* 2. Events from user's follows (if logged in)
@ -58,7 +58,7 @@ async function prioritizeSearchEvents( @@ -58,7 +58,7 @@ async function prioritizeSearchEvents(
events: NDKEvent[],
targetPubkey?: string,
maxResults: number = SEARCH_LIMITS.GENERAL_CONTENT,
ndk?: NDK
ndk?: NDK,
): Promise<NDKEvent[]> {
if (events.length === 0) {
return [];
@ -67,58 +67,75 @@ async function prioritizeSearchEvents( @@ -67,58 +67,75 @@ async function prioritizeSearchEvents(
// AI-NOTE: Get user lists and community status for prioritization
let userFollowPubkeys = new Set<string>();
let communityMemberPubkeys = new Set<string>();
// Only attempt user list and community checks if NDK is provided
if (ndk) {
try {
// Import user list functions dynamically to avoid circular dependencies
const { fetchCurrentUserLists, getPubkeysFromListKind } = await import("./user_lists.ts");
const { fetchCurrentUserLists, getPubkeysFromListKind } = await import(
"./user_lists.ts"
);
const { checkCommunity } = await import("./community_checker.ts");
// Get current user's follow lists (if logged in)
const userLists = await fetchCurrentUserLists(undefined, ndk);
userFollowPubkeys = getPubkeysFromListKind(userLists, 3); // Kind 3 = follow list
// Check community status for unique pubkeys in events (limit to prevent hanging)
const uniquePubkeys = new Set(events.map(e => e.pubkey).filter(Boolean));
const uniquePubkeys = new Set(
events.map((e) => e.pubkey).filter(Boolean),
);
const pubkeysToCheck = Array.from(uniquePubkeys).slice(0, 20); // Limit to first 20 pubkeys
console.log(`subscription_search: Checking community status for ${pubkeysToCheck.length} pubkeys out of ${uniquePubkeys.size} total`);
console.log(
`subscription_search: Checking community status for ${pubkeysToCheck.length} pubkeys out of ${uniquePubkeys.size} total`,
);
const communityChecks = await Promise.allSettled(
pubkeysToCheck.map(async (pubkey) => {
try {
const isCommunityMember = await Promise.race([
checkCommunity(pubkey),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Community check timeout')), 2000)
)
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("Community check timeout")),
2000,
)
),
]);
return { pubkey, isCommunityMember };
} catch (error) {
console.warn(`subscription_search: Community check failed for ${pubkey}:`, error);
console.warn(
`subscription_search: Community check failed for ${pubkey}:`,
error,
);
return { pubkey, isCommunityMember: false };
}
})
}),
);
// Build set of community member pubkeys
communityChecks.forEach(result => {
communityChecks.forEach((result) => {
if (result.status === "fulfilled" && result.value.isCommunityMember) {
communityMemberPubkeys.add(result.value.pubkey);
}
});
console.log("subscription_search: Prioritization data loaded:", {
userFollows: userFollowPubkeys.size,
communityMembers: communityMemberPubkeys.size,
totalEvents: events.length
totalEvents: events.length,
});
} catch (error) {
console.warn("subscription_search: Failed to load prioritization data:", error);
console.warn(
"subscription_search: Failed to load prioritization data:",
error,
);
}
} else {
console.log("subscription_search: No NDK provided, skipping user list and community checks");
console.log(
"subscription_search: No NDK provided, skipping user list and community checks",
);
}
// Separate events into priority tiers
@ -131,8 +148,10 @@ async function prioritizeSearchEvents( @@ -131,8 +148,10 @@ async function prioritizeSearchEvents(
const isFromTarget = targetPubkey && event.pubkey === targetPubkey;
const isPrioritizedKind = PRIORITIZED_EVENT_KINDS.has(event.kind || 0);
const isFromFollow = userFollowPubkeys.has(event.pubkey || "");
const isFromCommunityMember = communityMemberPubkeys.has(event.pubkey || "");
const isFromCommunityMember = communityMemberPubkeys.has(
event.pubkey || "",
);
// AI-NOTE: Prioritized kinds are always in tier 1
// Target pubkey priority only applies to n: searches (when targetPubkey is provided)
if (isPrioritizedKind || isFromTarget) {
@ -154,22 +173,22 @@ async function prioritizeSearchEvents( @@ -154,22 +173,22 @@ async function prioritizeSearchEvents(
// Combine tiers in priority order, respecting the limit
const result: NDKEvent[] = [];
// Add tier 1 events (highest priority)
result.push(...tier1);
// Add tier 2 events (follows) if we haven't reached the limit
const remainingAfterTier1 = maxResults - result.length;
if (remainingAfterTier1 > 0) {
result.push(...tier2.slice(0, remainingAfterTier1));
}
// Add tier 3 events (community members) if we haven't reached the limit
const remainingAfterTier2 = maxResults - result.length;
if (remainingAfterTier2 > 0) {
result.push(...tier3.slice(0, remainingAfterTier2));
}
// Add tier 4 events (others) if we haven't reached the limit
const remainingAfterTier3 = maxResults - result.length;
if (remainingAfterTier3 > 0) {
@ -181,7 +200,7 @@ async function prioritizeSearchEvents( @@ -181,7 +200,7 @@ async function prioritizeSearchEvents(
tier2: tier2.length, // User follows
tier3: tier3.length, // Community members
tier4: tier4.length, // Others
total: result.length
total: result.length,
});
return result;
@ -221,61 +240,74 @@ export async function searchBySubscription( @@ -221,61 +240,74 @@ export async function searchBySubscription(
const cachedResult = searchCache.get(searchType, normalizedSearchTerm);
if (cachedResult) {
console.log("subscription_search: Found cached result:", cachedResult);
// AI-NOTE: Ensure cached events have created_at property preserved
// This fixes the "Unknown date" issue when events are retrieved from cache
const eventsWithCreatedAt = cachedResult.events.map(event => {
if (event && typeof event === 'object' && !event.created_at) {
console.warn("subscription_search: Event missing created_at, setting to 0:", event.id);
const eventsWithCreatedAt = cachedResult.events.map((event) => {
if (event && typeof event === "object" && !event.created_at) {
console.warn(
"subscription_search: Event missing created_at, setting to 0:",
event.id,
);
(event as any).created_at = 0;
}
return event;
});
const secondOrderWithCreatedAt = cachedResult.secondOrder.map(event => {
if (event && typeof event === 'object' && !event.created_at) {
console.warn("subscription_search: Second order event missing created_at, setting to 0:", event.id);
const secondOrderWithCreatedAt = cachedResult.secondOrder.map((event) => {
if (event && typeof event === "object" && !event.created_at) {
console.warn(
"subscription_search: Second order event missing created_at, setting to 0:",
event.id,
);
(event as any).created_at = 0;
}
return event;
});
const tTagEventsWithCreatedAt = cachedResult.tTagEvents.map(event => {
if (event && typeof event === 'object' && !event.created_at) {
console.warn("subscription_search: T-tag event missing created_at, setting to 0:", event.id);
const tTagEventsWithCreatedAt = cachedResult.tTagEvents.map((event) => {
if (event && typeof event === "object" && !event.created_at) {
console.warn(
"subscription_search: T-tag event missing created_at, setting to 0:",
event.id,
);
(event as any).created_at = 0;
}
return event;
});
const resultWithCreatedAt = {
...cachedResult,
events: eventsWithCreatedAt,
secondOrder: secondOrderWithCreatedAt,
tTagEvents: tTagEventsWithCreatedAt
tTagEvents: tTagEventsWithCreatedAt,
};
// AI-NOTE: Return cached results immediately but trigger second-order search in background
// This ensures we get fast results while still updating second-order data
console.log("subscription_search: Returning cached result immediately, triggering background second-order search");
// Trigger second-order search in background for all search types
if (ndk) {
// Start second-order search in background for n and d searches only
if (searchType === "n" || searchType === "d") {
console.log("subscription_search: Triggering background second-order search for cached result");
performSecondOrderSearchInBackground(
searchType as "n" | "d",
eventsWithCreatedAt,
cachedResult.eventIds || new Set(),
cachedResult.addresses || new Set(),
ndk,
searchType === "n" ? eventsWithCreatedAt[0]?.pubkey : undefined,
callbacks
);
}
console.log(
"subscription_search: Returning cached result immediately, triggering background second-order search",
);
// Trigger second-order search in background for all search types
if (ndk) {
// Start second-order search in background for n and d searches only
if (searchType === "n" || searchType === "d") {
console.log(
"subscription_search: Triggering background second-order search for cached result",
);
performSecondOrderSearchInBackground(
searchType as "n" | "d",
eventsWithCreatedAt,
cachedResult.eventIds || new Set(),
cachedResult.addresses || new Set(),
ndk,
searchType === "n" ? eventsWithCreatedAt[0]?.pubkey : undefined,
callbacks,
);
}
}
return resultWithCreatedAt;
}
@ -316,7 +348,10 @@ export async function searchBySubscription( @@ -316,7 +348,10 @@ export async function searchBySubscription(
// AI-NOTE: Check for preloaded events first (for profile searches)
if (searchFilter.preloadedEvents && searchFilter.preloadedEvents.length > 0) {
console.log("subscription_search: Using preloaded events:", searchFilter.preloadedEvents.length);
console.log(
"subscription_search: Using preloaded events:",
searchFilter.preloadedEvents.length,
);
processPrimaryRelayResults(
new Set(searchFilter.preloadedEvents),
searchType,
@ -326,9 +361,11 @@ export async function searchBySubscription( @@ -326,9 +361,11 @@ export async function searchBySubscription(
abortSignal,
cleanup,
);
if (hasResults(searchState, searchType)) {
console.log("subscription_search: Found results from preloaded events, returning immediately");
console.log(
"subscription_search: Found results from preloaded events, returning immediately",
);
const immediateResult = createSearchResult(
searchState,
searchType,
@ -367,19 +404,25 @@ export async function searchBySubscription( @@ -367,19 +404,25 @@ export async function searchBySubscription(
"subscription_search: Searching primary relay with filter:",
searchFilter.filter,
);
// Add timeout to primary relay search
const primaryEventsPromise = ndk.fetchEvents(
searchFilter.filter,
{ closeOnEose: true },
primaryRelaySet,
);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Primary relay search timeout")), TIMEOUTS.SUBSCRIPTION_SEARCH);
setTimeout(
() => reject(new Error("Primary relay search timeout")),
TIMEOUTS.SUBSCRIPTION_SEARCH,
);
});
const primaryEvents = await Promise.race([primaryEventsPromise, timeoutPromise]) as any;
const primaryEvents = await Promise.race([
primaryEventsPromise,
timeoutPromise,
]) as any;
console.log(
"subscription_search: Primary relay returned",
@ -429,7 +472,7 @@ export async function searchBySubscription( @@ -429,7 +472,7 @@ export async function searchBySubscription(
console.log(
`subscription_search: Profile search completed in ${elapsed}ms`,
);
// Clear the main timeout since we're returning early
cleanup();
return immediateResult;
@ -471,12 +514,18 @@ export async function searchBySubscription( @@ -471,12 +514,18 @@ export async function searchBySubscription(
{ closeOnEose: true },
allRelaySet,
);
const fallbackTimeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Fallback search timeout")), TIMEOUTS.SUBSCRIPTION_SEARCH);
setTimeout(
() => reject(new Error("Fallback search timeout")),
TIMEOUTS.SUBSCRIPTION_SEARCH,
);
});
const fallbackEvents = await Promise.race([fallbackEventsPromise, fallbackTimeoutPromise]) as any;
const fallbackEvents = await Promise.race([
fallbackEventsPromise,
fallbackTimeoutPromise,
]) as any;
console.log(
"subscription_search: Fallback search returned",
@ -508,7 +557,7 @@ export async function searchBySubscription( @@ -508,7 +557,7 @@ export async function searchBySubscription(
console.log(
`subscription_search: Profile search completed in ${elapsed}ms (fallback)`,
);
// Clear the main timeout since we're returning early
cleanup();
return fallbackResult;
@ -518,10 +567,15 @@ export async function searchBySubscription( @@ -518,10 +567,15 @@ export async function searchBySubscription(
"subscription_search: Fallback search failed:",
fallbackError,
);
// If it's a timeout error, continue to return empty result
if (fallbackError instanceof Error && fallbackError.message.includes("timeout")) {
console.log("subscription_search: Fallback search timed out, returning empty result");
if (
fallbackError instanceof Error &&
fallbackError.message.includes("timeout")
) {
console.log(
"subscription_search: Fallback search timed out, returning empty result",
);
}
}
@ -538,7 +592,7 @@ export async function searchBySubscription( @@ -538,7 +592,7 @@ export async function searchBySubscription(
console.log(
`subscription_search: Profile search completed in ${elapsed}ms (not found)`,
);
// Clear the main timeout since we're returning early
cleanup();
return emptyResult;
@ -553,10 +607,12 @@ export async function searchBySubscription( @@ -553,10 +607,12 @@ export async function searchBySubscription(
`subscription_search: Error searching primary relay:`,
error,
);
// If it's a timeout error, continue to Phase 2 instead of failing
if (error instanceof Error && error.message.includes("timeout")) {
console.log("subscription_search: Primary relay search timed out, continuing to Phase 2");
console.log(
"subscription_search: Primary relay search timed out, continuing to Phase 2",
);
} else {
// For other errors, we might want to fail the search
throw error;
@ -669,12 +725,12 @@ async function createSearchFilter( @@ -669,12 +725,12 @@ async function createSearchFilter(
// This properly handles NIP-05 lookups and name searches
const { searchProfiles } = await import("./profile_search.ts");
const profileResult = await searchProfiles(normalizedSearchTerm, ndk);
// Convert profile results to events for compatibility
const events = profileResult.profiles.map((profile) => {
const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile);
// AI-NOTE: Convert npub to hex public key for compatibility with nprofileEncode
// The profile.pubkey is an npub (bech32-encoded), but nprofileEncode expects hex-encoded public key
let hexPubkey = profile.pubkey || "";
@ -685,26 +741,36 @@ async function createSearchFilter( @@ -685,26 +741,36 @@ async function createSearchFilter(
hexPubkey = decoded.data as string;
}
} catch (e) {
console.warn("subscription_search: Failed to decode npub:", profile.pubkey, e);
console.warn(
"subscription_search: Failed to decode npub:",
profile.pubkey,
e,
);
}
}
event.pubkey = hexPubkey;
event.kind = 0;
// AI-NOTE: Use the preserved created_at timestamp from the profile
// This ensures the profile cards show the actual creation date instead of "Unknown date"
if ((profile as any).created_at) {
event.created_at = (profile as any).created_at;
console.log("subscription_search: Using preserved timestamp:", event.created_at);
console.log(
"subscription_search: Using preserved timestamp:",
event.created_at,
);
} else {
// Fallback to current timestamp if no preserved timestamp
event.created_at = Math.floor(Date.now() / 1000);
console.log("subscription_search: Using fallback timestamp:", event.created_at);
console.log(
"subscription_search: Using fallback timestamp:",
event.created_at,
);
}
return event;
});
// Return a mock filter since we're using the profile search directly
const nFilter = {
filter: { kinds: [0], limit: 1 }, // Dummy filter
@ -712,7 +778,10 @@ async function createSearchFilter( @@ -712,7 +778,10 @@ async function createSearchFilter(
searchTerm: normalizedSearchTerm,
preloadedEvents: events, // AI-NOTE: Pass preloaded events
};
console.log("subscription_search: Created profile filter with preloaded events:", nFilter);
console.log(
"subscription_search: Created profile filter with preloaded events:",
nFilter,
);
return nFilter;
}
default: {
@ -721,8 +790,6 @@ async function createSearchFilter( @@ -721,8 +790,6 @@ async function createSearchFilter(
}
}
/**
* Create primary relay set for search operations
* AI-NOTE: Updated to use all available relays to prevent search failures
@ -816,7 +883,9 @@ function processPrimaryRelayResults( @@ -816,7 +883,9 @@ function processPrimaryRelayResults(
for (const event of events) {
// Check if we've reached the event limit
if (processedCount >= maxEvents) {
console.log(`subscription_search: Reached event limit of ${maxEvents} in primary relay processing`);
console.log(
`subscription_search: Reached event limit of ${maxEvents} in primary relay processing`,
);
break;
}
@ -1029,13 +1098,15 @@ function searchOtherRelaysInBackground( @@ -1029,13 +1098,15 @@ function searchOtherRelaysInBackground(
sub.on("event", (event: NDKEvent) => {
// Check if we've reached the event limit
if (eventCount >= maxEvents) {
console.log(`subscription_search: Reached event limit of ${maxEvents}, stopping event processing`);
console.log(
`subscription_search: Reached event limit of ${maxEvents}, stopping event processing`,
);
sub.stop();
return;
}
eventCount++;
try {
if (searchType === "n") {
processProfileEvent(
@ -1054,11 +1125,13 @@ function searchOtherRelaysInBackground( @@ -1054,11 +1125,13 @@ function searchOtherRelaysInBackground(
return new Promise<SearchResult>((resolve) => {
let resolved = false;
// Add timeout to prevent hanging
const timeoutId = setTimeout(async () => {
if (!resolved) {
console.log("subscription_search: Background search timeout, resolving with current results");
console.log(
"subscription_search: Background search timeout, resolving with current results",
);
resolved = true;
sub.stop();
const result = await processEoseResults(
@ -1073,7 +1146,7 @@ function searchOtherRelaysInBackground( @@ -1073,7 +1146,7 @@ function searchOtherRelaysInBackground(
resolve(result);
}
}, TIMEOUTS.SUBSCRIPTION_SEARCH);
sub.on("eose", async () => {
if (!resolved) {
resolved = true;
@ -1106,7 +1179,12 @@ async function processEoseResults( @@ -1106,7 +1179,12 @@ async function processEoseResults(
if (searchType === "n") {
return processProfileEoseResults(searchState, searchFilter, ndk, callbacks);
} else if (searchType === "d") {
return await processContentEoseResults(searchState, searchType, ndk, callbacks);
return await processContentEoseResults(
searchState,
searchType,
ndk,
callbacks,
);
} else if (searchType === "t") {
return await processTTagEoseResults(searchState, ndk);
}
@ -1242,7 +1320,7 @@ async function processContentEoseResults( @@ -1242,7 +1320,7 @@ async function processContentEoseResults(
dedupedEvents,
undefined, // No specific target pubkey for d-tag searches
SEARCH_LIMITS.GENERAL_CONTENT,
ndk
ndk,
);
// AI-NOTE: Attach profile data to first-order events for display
@ -1276,7 +1354,10 @@ async function processContentEoseResults( @@ -1276,7 +1354,10 @@ async function processContentEoseResults(
/**
* Process t-tag EOSE results
*/
async function processTTagEoseResults(searchState: any, ndk?: NDK): Promise<SearchResult> {
async function processTTagEoseResults(
searchState: any,
ndk?: NDK,
): Promise<SearchResult> {
if (searchState.tTagEvents.length === 0) {
return createEmptySearchResult("t", searchState.normalizedSearchTerm);
}
@ -1287,7 +1368,7 @@ async function processTTagEoseResults(searchState: any, ndk?: NDK): Promise<Sear @@ -1287,7 +1368,7 @@ async function processTTagEoseResults(searchState: any, ndk?: NDK): Promise<Sear
searchState.tTagEvents,
undefined, // No specific target pubkey for t-tag searches
SEARCH_LIMITS.GENERAL_CONTENT,
ndk
ndk,
);
// AI-NOTE: Attach profile data to t-tag events for display
@ -1458,10 +1539,12 @@ async function performSecondOrderSearchInBackground( @@ -1458,10 +1539,12 @@ async function performSecondOrderSearchInBackground(
// Race between fetch and timeout - only timeout the initial event fetching
await Promise.race([fetchPromise, fetchTimeoutPromise]);
// Now do the prioritization without timeout
console.log("subscription_search: Event fetching completed, starting prioritization...");
console.log(
"subscription_search: Event fetching completed, starting prioritization...",
);
// Deduplicate by event ID
const uniqueSecondOrder = new Map<string, NDKEvent>();
allSecondOrderEvents.forEach((event) => {
@ -1484,18 +1567,18 @@ async function performSecondOrderSearchInBackground( @@ -1484,18 +1567,18 @@ async function performSecondOrderSearchInBackground(
deduplicatedSecondOrder,
targetPubkey,
SEARCH_LIMITS.SECOND_ORDER_RESULTS,
ndk
ndk,
);
const prioritizationTimeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Prioritization timeout')), 15000); // 15 second timeout
setTimeout(() => reject(new Error("Prioritization timeout")), 15000); // 15 second timeout
});
let prioritizedSecondOrder: NDKEvent[];
try {
prioritizedSecondOrder = await Promise.race([
prioritizationPromise,
prioritizationTimeoutPromise
prioritizationTimeoutPromise,
]) as NDKEvent[];
console.log(
@ -1504,7 +1587,10 @@ async function performSecondOrderSearchInBackground( @@ -1504,7 +1587,10 @@ async function performSecondOrderSearchInBackground(
"prioritized results",
);
} catch (error) {
console.warn("subscription_search: Prioritization failed, using simple sorting:", error);
console.warn(
"subscription_search: Prioritization failed, using simple sorting:",
error,
);
// Fallback to simple sorting if prioritization fails
prioritizedSecondOrder = deduplicatedSecondOrder.sort((a, b) => {
// Prioritize events from target pubkey first (for n: searches)
@ -1514,17 +1600,17 @@ async function performSecondOrderSearchInBackground( @@ -1514,17 +1600,17 @@ async function performSecondOrderSearchInBackground(
if (aIsTarget && !bIsTarget) return -1;
if (!aIsTarget && bIsTarget) return 1;
}
// Prioritize by event kind (for t: searches and general prioritization)
const aIsPrioritized = PRIORITIZED_EVENT_KINDS.has(a.kind || 0);
const bIsPrioritized = PRIORITIZED_EVENT_KINDS.has(b.kind || 0);
if (aIsPrioritized && !bIsPrioritized) return -1;
if (!aIsPrioritized && bIsPrioritized) return 1;
// Then sort by creation time (newest first)
return (b.created_at || 0) - (a.created_at || 0);
}).slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS);
console.log(
"subscription_search: Using fallback sorting with",
prioritizedSecondOrder.length,
@ -1577,20 +1663,27 @@ async function performSecondOrderSearchInBackground( @@ -1577,20 +1663,27 @@ async function performSecondOrderSearchInBackground(
* @param ndk NDK instance for fetching profile data
* @returns Promise that resolves when profile data is attached
*/
async function attachProfileDataToEvents(events: NDKEvent[], ndk: NDK): Promise<void> {
async function attachProfileDataToEvents(
events: NDKEvent[],
ndk: NDK,
): Promise<void> {
if (events.length === 0) {
return;
}
console.log(`subscription_search: Attaching profile data to ${events.length} events`);
console.log(
`subscription_search: Attaching profile data to ${events.length} events`,
);
try {
// Import user list functions dynamically to avoid circular dependencies
const { fetchCurrentUserLists, isPubkeyInUserLists } = await import("./user_lists.ts");
const { fetchCurrentUserLists, isPubkeyInUserLists } = await import(
"./user_lists.ts"
);
// Get current user's lists for user list status
const userLists = await fetchCurrentUserLists(undefined, ndk);
// Get unique pubkeys from events
const uniquePubkeys = new Set<string>();
events.forEach((event) => {
@ -1599,39 +1692,46 @@ async function attachProfileDataToEvents(events: NDKEvent[], ndk: NDK): Promise< @@ -1599,39 +1692,46 @@ async function attachProfileDataToEvents(events: NDKEvent[], ndk: NDK): Promise<
}
});
console.log(`subscription_search: Found ${uniquePubkeys.size} unique pubkeys to fetch profiles for`);
console.log(
`subscription_search: Found ${uniquePubkeys.size} unique pubkeys to fetch profiles for`,
);
// Fetch profile data for each unique pubkey
const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => {
try {
// Import getUserMetadata dynamically to avoid circular dependencies
const { getUserMetadata } = await import("./nostrUtils.ts");
const npub = await import("./nostrUtils.ts").then(m => m.toNpub(pubkey));
const npub = await import("./nostrUtils.ts").then((m) =>
m.toNpub(pubkey)
);
if (npub) {
const profileData = await getUserMetadata(npub, ndk, true);
if (profileData) {
// Check if this pubkey is in user's lists
const isInLists = isPubkeyInUserLists(pubkey, userLists);
// Return profile data with user list status
return {
pubkey,
profileData: {
...profileData,
isInUserLists: isInLists
}
isInUserLists: isInLists,
},
};
}
}
} catch (error) {
console.warn(`subscription_search: Failed to fetch profile for ${pubkey}:`, error);
console.warn(
`subscription_search: Failed to fetch profile for ${pubkey}:`,
error,
);
}
return null;
});
const profileResults = await Promise.allSettled(profilePromises);
// Create a map of pubkey to profile data
const profileMap = new Map<string, any>();
profileResults.forEach((result) => {
@ -1640,7 +1740,9 @@ async function attachProfileDataToEvents(events: NDKEvent[], ndk: NDK): Promise< @@ -1640,7 +1740,9 @@ async function attachProfileDataToEvents(events: NDKEvent[], ndk: NDK): Promise<
}
});
console.log(`subscription_search: Successfully fetched ${profileMap.size} profiles`);
console.log(
`subscription_search: Successfully fetched ${profileMap.size} profiles`,
);
// Attach profile data to each event
events.forEach((event) => {

113
src/lib/utils/user_lists.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { getNdkContext, activeInboxRelays } from "../ndk.ts";
import { activeInboxRelays, getNdkContext } from "../ndk.ts";
import { get } from "svelte/store";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
@ -11,15 +11,15 @@ import { npubCache } from "./npubCache.ts"; @@ -11,15 +11,15 @@ import { npubCache } from "./npubCache.ts";
* @see https://github.com/nostr-protocol/nips/blob/master/51.md
*/
export const NIP51_LIST_KINDS = {
FOLLOWS: 3, // Follow list
MUTED: 10000, // Mute list
PINNED: 10001, // Pin list
RELAYS: 10002, // Relay list
PEOPLE: 30000, // Categorized people list
BOOKMARKS: 30001, // Categorized bookmark list
COMMUNITIES: 34550, // Community definition
STARTER_PACKS: 39089, // Starter packs
MEDIA_STARTER_PACKS: 39092, // Media starter packs
FOLLOWS: 3, // Follow list
MUTED: 10000, // Mute list
PINNED: 10001, // Pin list
RELAYS: 10002, // Relay list
PEOPLE: 30000, // Categorized people list
BOOKMARKS: 30001, // Categorized bookmark list
COMMUNITIES: 34550, // Community definition
STARTER_PACKS: 39089, // Starter packs
MEDIA_STARTER_PACKS: 39092, // Media starter packs
} as const;
/**
@ -52,7 +52,7 @@ export interface UserListEvent { @@ -52,7 +52,7 @@ export interface UserListEvent {
export async function fetchUserLists(
pubkey: string,
listKinds: number[] = [...PEOPLE_LIST_KINDS],
ndk?: NDK
ndk?: NDK,
): Promise<UserListEvent[]> {
const ndkInstance = ndk || getNdkContext();
if (!ndkInstance) {
@ -60,7 +60,10 @@ export async function fetchUserLists( @@ -60,7 +60,10 @@ export async function fetchUserLists(
return [];
}
console.log(`fetchUserLists: Fetching lists for ${pubkey}, kinds:`, listKinds);
console.log(
`fetchUserLists: Fetching lists for ${pubkey}, kinds:`,
listKinds,
);
try {
const events = await ndkInstance.fetchEvents({
@ -72,10 +75,10 @@ export async function fetchUserLists( @@ -72,10 +75,10 @@ export async function fetchUserLists(
for (const event of events) {
const pubkeys: string[] = [];
// Extract pubkeys from p-tags
event.tags.forEach(tag => {
if (tag[0] === 'p' && tag[1]) {
event.tags.forEach((tag) => {
if (tag[0] === "p" && tag[1]) {
pubkeys.push(tag[1]);
}
});
@ -83,7 +86,7 @@ export async function fetchUserLists( @@ -83,7 +86,7 @@ export async function fetchUserLists(
// Extract list metadata from content if available
let listName: string | undefined;
let listDescription: string | undefined;
if (event.content) {
try {
const content = JSON.parse(event.content);
@ -96,7 +99,7 @@ export async function fetchUserLists( @@ -96,7 +99,7 @@ export async function fetchUserLists(
// Get list name from d-tag if available (for addressable lists)
if (!listName && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.getMatchingTags('d')[0]?.[1];
const dTag = event.getMatchingTags("d")[0]?.[1];
if (dTag) {
listName = dTag;
}
@ -111,7 +114,11 @@ export async function fetchUserLists( @@ -111,7 +114,11 @@ export async function fetchUserLists(
});
}
console.log(`fetchUserLists: Found ${userLists.length} lists with ${userLists.reduce((sum, list) => sum + list.pubkeys.length, 0)} total pubkeys`);
console.log(
`fetchUserLists: Found ${userLists.length} lists with ${
userLists.reduce((sum, list) => sum + list.pubkeys.length, 0)
} total pubkeys`,
);
return userLists;
} catch (error) {
console.error("fetchUserLists: Error fetching user lists:", error);
@ -127,10 +134,10 @@ export async function fetchUserLists( @@ -127,10 +134,10 @@ export async function fetchUserLists(
*/
export async function fetchCurrentUserLists(
listKinds: number[] = [...PEOPLE_LIST_KINDS],
ndk?: NDK
ndk?: NDK,
): Promise<UserListEvent[]> {
const userState = get(userStore);
if (!userState.signedIn || !userState.pubkey) {
console.warn("fetchCurrentUserLists: No active user found in userStore");
return [];
@ -145,11 +152,13 @@ export async function fetchCurrentUserLists( @@ -145,11 +152,13 @@ export async function fetchCurrentUserLists(
* @param userLists - Array of UserListEvent objects
* @returns Set of unique pubkeys
*/
export function getPubkeysFromUserLists(userLists: UserListEvent[]): Set<string> {
export function getPubkeysFromUserLists(
userLists: UserListEvent[],
): Set<string> {
const pubkeys = new Set<string>();
userLists.forEach(list => {
list.pubkeys.forEach(pubkey => {
userLists.forEach((list) => {
list.pubkeys.forEach((pubkey) => {
pubkeys.add(pubkey);
});
});
@ -163,12 +172,15 @@ export function getPubkeysFromUserLists(userLists: UserListEvent[]): Set<string> @@ -163,12 +172,15 @@ export function getPubkeysFromUserLists(userLists: UserListEvent[]): Set<string>
* @param kind - The list kind to filter by
* @returns Set of unique pubkeys from the specified list kind
*/
export function getPubkeysFromListKind(userLists: UserListEvent[], kind: number): Set<string> {
export function getPubkeysFromListKind(
userLists: UserListEvent[],
kind: number,
): Set<string> {
const pubkeys = new Set<string>();
userLists.forEach(list => {
userLists.forEach((list) => {
if (list.kind === kind) {
list.pubkeys.forEach(pubkey => {
list.pubkeys.forEach((pubkey) => {
pubkeys.add(pubkey);
});
}
@ -183,11 +195,22 @@ export function getPubkeysFromListKind(userLists: UserListEvent[], kind: number) @@ -183,11 +195,22 @@ export function getPubkeysFromListKind(userLists: UserListEvent[], kind: number)
* @param userLists - Array of UserListEvent objects
* @returns True if the pubkey is in any list
*/
export function isPubkeyInUserLists(pubkey: string, userLists: UserListEvent[]): boolean {
const result = userLists.some(list => list.pubkeys.includes(pubkey));
console.log(`isPubkeyInUserLists: Checking ${pubkey} against ${userLists.length} lists, result: ${result}`);
export function isPubkeyInUserLists(
pubkey: string,
userLists: UserListEvent[],
): boolean {
const result = userLists.some((list) => list.pubkeys.includes(pubkey));
console.log(
`isPubkeyInUserLists: Checking ${pubkey} against ${userLists.length} lists, result: ${result}`,
);
if (result) {
console.log(`isPubkeyInUserLists: Found ${pubkey} in lists:`, userLists.filter(list => list.pubkeys.includes(pubkey)).map(list => ({ kind: list.kind, name: list.listName })));
console.log(
`isPubkeyInUserLists: Found ${pubkey} in lists:`,
userLists.filter((list) => list.pubkeys.includes(pubkey)).map((list) => ({
kind: list.kind,
name: list.listName,
})),
);
}
return result;
}
@ -198,10 +221,13 @@ export function isPubkeyInUserLists(pubkey: string, userLists: UserListEvent[]): @@ -198,10 +221,13 @@ export function isPubkeyInUserLists(pubkey: string, userLists: UserListEvent[]):
* @param userLists - Array of UserListEvent objects
* @returns Array of list kinds that contain the pubkey
*/
export function getListKindsForPubkey(pubkey: string, userLists: UserListEvent[]): number[] {
export function getListKindsForPubkey(
pubkey: string,
userLists: UserListEvent[],
): number[] {
return userLists
.filter(list => list.pubkeys.includes(pubkey))
.map(list => list.kind);
.filter((list) => list.pubkeys.includes(pubkey))
.map((list) => list.kind);
}
/**
@ -209,29 +235,32 @@ export function getListKindsForPubkey(pubkey: string, userLists: UserListEvent[] @@ -209,29 +235,32 @@ export function getListKindsForPubkey(pubkey: string, userLists: UserListEvent[]
* This ensures follows are always cached and prioritized
* @param pubkeys - Array of pubkeys to cache profiles for
*/
export async function updateProfileCacheForPubkeys(pubkeys: string[], ndk?: NDK): Promise<void> {
export async function updateProfileCacheForPubkeys(
pubkeys: string[],
ndk?: NDK,
): Promise<void> {
if (pubkeys.length === 0) return;
try {
console.log(`Updating profile cache for ${pubkeys.length} pubkeys`);
const ndkInstance = ndk || getNdkContext();
if (!ndkInstance) {
console.warn("updateProfileCacheForPubkeys: No NDK instance available");
return;
}
// Fetch profiles for all pubkeys in batches
const batchSize = 20;
for (let i = 0; i < pubkeys.length; i += batchSize) {
const batch = pubkeys.slice(i, i + batchSize);
try {
const events = await ndkInstance.fetchEvents({
kinds: [0],
authors: batch,
});
// Cache each profile
for (const event of events) {
if (event.content) {
@ -249,7 +278,7 @@ export async function updateProfileCacheForPubkeys(pubkeys: string[], ndk?: NDK) @@ -249,7 +278,7 @@ export async function updateProfileCacheForPubkeys(pubkeys: string[], ndk?: NDK)
console.warn("Failed to fetch batch of profiles:", error);
}
}
console.log("Profile cache update completed");
} catch (error) {
console.warn("Failed to update profile cache:", error);

26
src/routes/+layout.svelte

@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
<script lang="ts">
import "../app.css";
import Navigation from "$lib/components/Navigation.svelte";
import { onMount, setContext } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { cleanupNdk, getPersistedLogin } from "$lib/ndk";
import { userStore, loginMethodStorageKey } from "$lib/stores/userStore";
import { loginMethodStorageKey, userStore } from "$lib/stores/userStore";
import type { LayoutProps } from "./$types";
import { page } from "$app/state";
import { AFooter, ANavbar } from "$lib/a/index.js";
// Define children prop for Svelte 5
let { data, children }: LayoutProps = $props();
@ -15,7 +15,8 @@ @@ -15,7 +15,8 @@
// Get standard metadata for OpenGraph tags
let title = "Library of Alexandria";
let currentUrl = $page.url.href;
let currentUrl = page.url.href;
let currentPath = page.url.pathname;
// Get default image and summary for the Alexandria website
let image = "/screenshots/old_books.jpg";
@ -51,11 +52,9 @@ @@ -51,11 +52,9 @@
// If we have a persisted pubkey and login method, restore the session
if (persistedPubkey && loginMethod) {
console.log("Layout: Found persisted authentication, attempting to restore...");
const currentUserState = $userStore;
// Only restore if not already signed in
if (!currentUserState.signedIn) {
if (!$userStore.signedIn) {
console.log("Layout: User not currently signed in, restoring authentication...");
if (loginMethod === "extension") {
@ -181,7 +180,12 @@ @@ -181,7 +180,12 @@
<meta name="twitter:image" content={image} />
</svelte:head>
<div class={"leather mt-[120px] w-full mx-auto flex flex-col items-center"}>
<Navigation class="fixed top-0" />
{@render children()}
<div class="min-h-screen flex flex-col">
<ANavbar />
<div class="flex flex-1 flex-col w-full mt-[100px] self-center">
{@render children()}
</div>
<AFooter />
</div>

4
src/routes/+layout.ts

@ -1,8 +1,10 @@ @@ -1,8 +1,10 @@
import type { LayoutLoad } from "./$types";
import { initNdk } from "$lib/ndk";
export const ssr = false;
export const load: LayoutLoad = () => {
return {
ndk: initNdk(),
};
}
};

10
src/routes/+page.svelte

@ -38,14 +38,12 @@ @@ -38,14 +38,12 @@
function confirmClearSearch() {
searchQuery = "";
showClearSearchModal = false;
// Force the state update by reassigning
showOnlyMyPublications = false;
showOnlyMyPublications = true;
showOnlyMyPublications = pendingCheckboxState;
}
function cancelClearSearch() {
// Don't change showOnlyMyPublications - it should remain as it was
showClearSearchModal = false;
pendingCheckboxState = false;
}
// AI-NOTE: Removed automatic search clearing - now handled with confirmation dialog
@ -63,7 +61,7 @@ @@ -63,7 +61,7 @@
</div>
{#if eventCount.total > 0}
<div class="flex items-center justify-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<div class="flex flex-col sm:flex-row items-center justify-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Showing {eventCount.displayed} of {eventCount.total} events.</span>
<!-- AI-NOTE: Show filter checkbox only when user is logged in -->
@ -71,7 +69,7 @@ @@ -71,7 +69,7 @@
<div class="flex items-center gap-2">
<input
type="checkbox"
checked={showOnlyMyPublications}
bind:checked={showOnlyMyPublications}
onchange={handleCheckboxChange}
id="show-my-publications"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"

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

@ -10,8 +10,8 @@ @@ -10,8 +10,8 @@
<P class="note-leather mb-6"
>The page you are looking for does not exist or has been moved.</P
>
<div class="flex space-x-4">
<Button class="btn-leather !w-fit" onclick={() => goto("/")}
<div class="flex flex-row space-x-4">
<Button class="btn-leather !w-fit !mb-0" onclick={() => goto("/")}
>Return to Home</Button
>
<Button

15
src/routes/about/+page.svelte

@ -2,7 +2,6 @@ @@ -2,7 +2,6 @@
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { Heading, Img, P, A } from "flowbite-svelte";
import { goto } from "$app/navigation";
import RelayStatus from "$lib/components/RelayStatus.svelte";
import { getNdkContext } from "$lib/ndk";
// Get the git tag version from environment variables
@ -12,20 +11,19 @@ @@ -12,20 +11,19 @@
const ndk = getNdkContext();
</script>
<div class="w-full flex justify-center">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4">
<div class="flex justify-between items-center">
<div class="w-full max-w-3xl flex flex-col self-center mb-3 px-2">
<div class="flex flex-col justify-between items-center mb-4">
<Heading tag="h1" class="h-leather mb-2"
>About the Library of Alexandria</Heading
>
{#if isVersionKnown}
<span
class="text-sm bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded text-nowrap"
class="mt-2 text-sm bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded text-nowrap"
>Version: {appVersion}</span
>
{/if}
</div>
<Img src="./screenshots/old_books.jpg" alt="Alexandria icon" />
<Img src="./screenshots/old_books.jpg" alt="Alexandria icon" class="my-3"/>
<P class="mb-3">
Alexandria is a reader and writer for <A
@ -62,9 +60,4 @@ @@ -62,9 +60,4 @@
target="_blank">homepage</A
> and find out more about us, and the many projects we are working on.
</P>
<div class="border-t pt-6">
<RelayStatus />
</div>
</main>
</div>

7
src/routes/about/relay-stats/+page.svelte

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
<script lang="ts">
import RelayStatus from "$lib/components/RelayStatus.svelte";
</script>
<div class="w-full flex flex-col justify-center">
<RelayStatus />
</div>

405
src/routes/contact/+page.svelte

@ -1,14 +1,5 @@ @@ -1,14 +1,5 @@
<script lang="ts">
import {
Heading,
P,
A,
Button,
Label,
Textarea,
Input,
Modal,
} from "flowbite-svelte";
import { Heading, P, A } from "flowbite-svelte";
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { userStore } from "$lib/stores/userStore";
import { anonymousRelays } from "$lib/consts";
@ -20,6 +11,8 @@ @@ -20,6 +11,8 @@
import { nip19 } from "nostr-tools";
import { getMimeTags } from "$lib/utils/mime";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import AMarkupForm from "$lib/a/forms/AMarkupForm.svelte";
import { AAlert } from "$lib/a";
const ndk = getNdkContext();
@ -33,8 +26,6 @@ @@ -33,8 +26,6 @@
subject = "";
content = "";
submissionError = "";
isExpanded = false;
activeTab = "write";
}
let subject = $state("");
@ -46,9 +37,6 @@ @@ -46,9 +37,6 @@
let submittedEvent = $state<NDKEvent | null>(null);
let issueLink = $state("");
let successfulRelays = $state<string[]>([]);
let isExpanded = $state(false);
let activeTab = $state("write");
let showConfirmDialog = $state(false);
// Store form data when user needs to login
let savedFormData = {
@ -82,45 +70,28 @@ @@ -82,45 +70,28 @@
return url.replace(/\/+$/, "");
}
function toggleSize() {
isExpanded = !isExpanded;
}
async function handleSubmit(e: Event) {
// Prevent form submission
e.preventDefault();
/**
* Handle form submission from AMarkupForm
*/
async function handleFormSubmit(newSubject: string, newContent: string) {
submissionError = "";
subject = newSubject;
content = newContent;
if (!subject || !content) {
submissionError = "Please fill in all fields";
return;
}
// Check if user is logged in
if (!user.signedIn) {
// Save form data
savedFormData = {
subject,
content,
};
// Show login modal
savedFormData = { subject, content };
showLoginModal = true;
return;
}
// Show confirmation dialog
showConfirmDialog = true;
}
async function confirmSubmit() {
showConfirmDialog = false;
await submitIssue();
}
function cancelSubmit() {
showConfirmDialog = false;
}
/**
* Publish event to relays with retry logic
*/
@ -290,13 +261,10 @@ @@ -290,13 +261,10 @@
});
</script>
<div class="w-full flex justify-center">
<main
class="main-leather flex flex-col space-y-6 max-w-3xl w-full my-6 px-6 sm:px-4"
>
<div class="w-full max-w-3xl flex flex-col self-center mb-3 px-2">
<Heading tag="h1" class="h-leather mb-2">Contact GitCitadel</Heading>
<P class="mb-3">
<P class="my-3">
Make sure that you follow us on <A
href="https://github.com/ShadowySupercode/gitcitadel"
target="_blank">GitHub</A
@ -318,280 +286,121 @@ @@ -318,280 +286,121 @@
<Heading tag="h2" class="h-leather mt-4 mb-2">Submit an issue</Heading>
<P class="mb-3">
<P class="my-3">
If you are logged into the Alexandria web application (using the button at
the top-right of the window), then you can use the form, below, to submit
an issue, that will appear on our repo page.
</P>
<form class="space-y-4" onsubmit={handleSubmit} autocomplete="off">
<div>
<Label for="subject" class="mb-2">Subject</Label>
<Input
id="subject"
class="w-full bg-white dark:bg-gray-800"
placeholder="Issue subject"
bind:value={subject}
required
autofocus
/>
</div>
<div class="relative">
<Label for="content" class="mb-2">Description</Label>
<div
class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded
? 'h-[800px]'
: 'h-[200px]'} transition-all duration-200 sm:w-[95vw] md:w-full"
<AMarkupForm
bind:subject={subject}
bind:content={content}
isSubmitting={isSubmitting}
onSubmit={handleFormSubmit}
/>
{#if submissionSuccess && submittedEvent}
<div
class="p-6 mb-4 text-sm bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 rounded-lg relative"
role="alert"
>
<!-- Close button -->
<button
class="absolute top-2 right-2 text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
onclick={closeSuccessMessage}
aria-label="Close"
>
<div class="h-full flex flex-col">
<div
class="border-b border-gray-300 dark:border-gray-600 rounded-t-lg"
>
<ul
class="flex flex-wrap -mb-px text-sm font-medium text-center"
role="tablist"
>
<li class="mr-2" role="presentation">
<button
type="button"
class="inline-block p-4 rounded-t-lg {activeTab === 'write'
? 'border-b-2 border-primary-600 text-primary-600'
: 'hover:text-gray-600 hover:border-gray-300'}"
onclick={() => (activeTab = "write")}
role="tab"
>
Write
</button>
</li>
<li role="presentation">
<button
type="button"
class="inline-block p-4 rounded-t-lg {activeTab ===
'preview'
? 'border-b-2 border-primary-600 text-primary-600'
: 'hover:text-gray-600 hover:border-gray-300'}"
onclick={() => (activeTab = "preview")}
role="tab"
>
Preview
</button>
</li>
</ul>
</div>
<div class="flex-1 min-h-0 relative">
{#if activeTab === "write"}
<div class="absolute inset-0 overflow-hidden">
<Textarea
id="content"
class="w-full h-full resize-none bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border-s-4 border-primary-200 rounded-b-lg rounded-t-none shadow-none px-4 py-2 focus:border-primary-600 dark:focus:border-primary-400"
bind:value={content}
required
placeholder="Describe your issue in detail...
The following markup is supported:
# Headers (1-6 levels)
Header 1
======
*Bold* or **bold**
_Italic_ or __italic__ text
~Strikethrough~ or ~~strikethrough~~ text
> Blockquotes
Lists, including nested:
* Bullets/unordered lists
1. Numbered/ordered lists
[Links](url)
![Images](url)
`Inline code`
```language
Code blocks with syntax highlighting for over 100 languages
```
| Tables | With or without headers |
|--------|------|
| Multiple | Rows |
Footnotes[^1] and [^1]: footnote content
Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. With or without the nostr: prefix."
/>
</div>
{:else}
<div
class="absolute inset-0 p-4 max-w-none bg-white dark:bg-gray-800 prose-content markup-content"
>
{#key content}
{#await parseAdvancedmarkup(content)}
<p>Loading preview...</p>
{:then html}
{@html html ||
'<p class="text-gray-700 dark:text-gray-300">Nothing to preview</p>'}
{:catch error}
<p class="text-red-500">
Error rendering preview: {error.message}
</p>
{/await}
{/key}
</div>
{/if}
</div>
</div>
<Button
type="button"
size="xs"
class="absolute bottom-2 right-2 z-10 opacity-60 hover:opacity-100"
color="light"
onclick={toggleSize}
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
<div class="flex items-center mb-3">
<svg
class="w-5 h-5 mr-2 text-success-700 dark:text-success-300"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
></path>
</svg>
<span class="font-medium text-success-800 dark:text-success-200"
>Issue submitted successfully!</span
>
{isExpanded ? "⌃" : "⌄"}
</Button>
</div>
</div>
<div class="flex justify-end space-x-4">
<Button type="button" color="alternative" onclick={clearForm}>
Clear Form
</Button>
<Button type="submit" tabindex={0}>
{#if isSubmitting}
Submitting...
{:else}
Submit Issue
{/if}
</Button>
</div>
{#if submissionSuccess && submittedEvent}
<div
class="p-6 mb-4 text-sm bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 rounded-lg relative"
role="alert"
class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600"
>
<!-- Close button -->
<button
class="absolute top-2 right-2 text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
onclick={closeSuccessMessage}
aria-label="Close"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
<div class="flex items-center mb-3">
<svg
class="w-5 h-5 mr-2 text-success-700 dark:text-success-300"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
></path>
</svg>
<span class="font-medium text-success-800 dark:text-success-200"
>Issue submitted successfully!</span
<div class="mb-2">
<span class="font-semibold">Subject:</span>
<span
>{submittedEvent.tags.find((t) => t[0] === "subject")?.[1] ||
"No subject"}</span
>
</div>
<div
class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600"
>
<div class="mb-2">
<span class="font-semibold">Subject:</span>
<span
>{submittedEvent.tags.find((t) => t[0] === "subject")?.[1] ||
"No subject"}</span
>
</div>
<div>
<span class="font-semibold">Description:</span>
<div class="mt-1 note-leather max-h-[400px] overflow-y-auto">
{#await parseAdvancedmarkup(submittedEvent.content)}
<p>Loading...</p>
{:then html}
{@html html}
{:catch error}
<p class="text-red-500">
Error rendering markup: {error.message}
</p>
{/await}
</div>
</div>
</div>
<div class="mb-3">
<span class="font-semibold">View your issue:</span>
<div class="mt-1">
<A
href={issueLink}
target="_blank"
class="hover:underline text-primary-600 dark:text-primary-500 break-all"
>
{issueLink}
</A>
<div>
<span class="font-semibold">Description:</span>
<div class="mt-1 note-leather max-h-[400px] overflow-y-auto">
{#await parseAdvancedmarkup(submittedEvent.content)}
<p>Loading...</p>
{:then html}
{@html html}
{:catch error}
<p class="text-red-500">
Error rendering markup: {error.message}
</p>
{/await}
</div>
</div>
</div>
<!-- Display successful relays -->
<div class="text-sm">
<span class="font-semibold">Successfully published to relays:</span>
<ul class="list-disc list-inside mt-1">
{#each successfulRelays as relay}
<li class="text-success-700 dark:text-success-300">{relay}</li>
{/each}
</ul>
<div class="mb-3">
<span class="font-semibold">View your issue:</span>
<div class="mt-1">
<A
href={issueLink}
target="_blank"
class="hover:underline text-primary-600 dark:text-primary-500 break-all"
>
{issueLink}
</A>
</div>
</div>
{/if}
{#if submissionError}
<div
class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg"
role="alert"
>
{submissionError}
<!-- Display successful relays -->
<div class="text-sm">
<span class="font-semibold">Successfully published to relays:</span>
<ul class="list-disc list-inside mt-1">
{#each successfulRelays as relay}
<li class="text-success-700 dark:text-success-300">{relay}</li>
{/each}
</ul>
</div>
{/if}
</form>
</main>
</div>
</div>
{/if}
<!-- Confirmation Dialog -->
<Modal bind:open={showConfirmDialog} size="sm" autoclose={false} class="w-full">
<div class="text-center">
<h3 class="mb-5 text-lg font-normal text-gray-700 dark:text-gray-300">
Would you like to submit the issue?
</h3>
<div class="flex justify-center gap-4">
<Button color="alternative" onclick={cancelSubmit}>Cancel</Button>
<Button color="primary" onclick={confirmSubmit}>Submit</Button>
</div>
</div>
</Modal>
{#if submissionError}
<AAlert color="red">
{submissionError}
</AAlert>
{/if}
</div>
<!-- Login Modal -->
<LoginModal

92
src/routes/events/+page.svelte

@ -1,11 +1,10 @@ @@ -1,11 +1,10 @@
<script lang="ts">
import { Heading, P } from "flowbite-svelte";
import { Heading, P, List, Li } from "flowbite-svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import EventSearch from "$lib/components/EventSearch.svelte";
import EventDetails from "$lib/components/EventDetails.svelte";
import RelayActions from "$lib/components/RelayActions.svelte";
import CommentBox from "$lib/components/CommentBox.svelte";
import CommentViewer from "$lib/components/CommentViewer.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
@ -14,7 +13,6 @@ @@ -14,7 +13,6 @@
toNpub,
getUserMetadata,
} from "$lib/utils/nostrUtils";
import EventInput from "$lib/components/EventInput.svelte";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays, getNdkContext } from "$lib/ndk";
@ -33,6 +31,7 @@ @@ -33,6 +31,7 @@
import type { SearchType } from "$lib/models/search_type";
import { clearAllCaches } from "$lib/utils/cache_manager";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
import { AAlert } from "$lib/a";
// AI-NOTE: Add cache clearing function for testing second-order search
// This can be called from browser console: window.clearCache()
@ -506,27 +505,25 @@ @@ -506,27 +505,25 @@
<P class="mb-3">
Search and explore Nostr events across the network. Find events by:
</P>
<ul
class="mb-3 list-disc list-inside space-y-1 text-sm text-gray-700 dark:text-gray-300"
>
<li>
<List class="mb-3 list-disc">
<Li>
<strong>Event identifiers:</strong> nevent, note, naddr, npub, nprofile,
pubkey, or event ID
</li>
<li><strong>NIP-05 addresses:</strong> username@domain.com</li>
<li>
</Li>
<Li><strong>NIP-05 addresses:</strong> username@domain.com</Li>
<Li>
<strong>Profile names:</strong> Search by display name or username (use
"n:" prefix for exact matches)
</li>
<li>
</Li>
<Li>
<strong>D-tags:</strong> Find events with specific d-tags using "d:tag-name"
</li>
<li>
</Li>
<Li>
<strong>T-tags:</strong> Find events tagged with specific topics using
"t:topic"
</li>
</ul>
<P class="mb-3 text-sm text-gray-600 dark:text-gray-400">
</Li>
</List>
<P class="mb-3 text-sm text-muted">
The page shows primary search results, second-order references
(replies, quotes, mentions), and related tagged events. Click any
event to view details, comments, and relay information.
@ -545,11 +542,9 @@ @@ -545,11 +542,9 @@
/>
{#if secondOrderSearchMessage}
<div
class="mt-4 p-4 text-sm text-blue-700 bg-blue-100 dark:bg-blue-900 dark:text-blue-200 rounded-lg"
>
<AAlert color="blue">
{secondOrderSearchMessage}
</div>
</AAlert>
{/if}
{#if searchResults.length > 0}
@ -1318,36 +1313,6 @@ @@ -1318,36 +1313,6 @@
</div>
</div>
{/if}
{#if !event && searchResults.length === 0 && secondOrderResults.length === 0 && tTagResults.length === 0 && !searchValue && !searchInProgress}
<div class="mt-8 w-full">
<Heading tag="h2" class="h-leather mb-4"
>Publish Nostr Event</Heading
>
<P class="mb-4">
Create and publish new Nostr events to the network. This form
supports various event kinds including:
</P>
<ul
class="mb-6 list-disc list-inside space-y-1 text-sm text-gray-700 dark:text-gray-300"
>
<li>
<strong>Kind 30040:</strong> Publication indexes that organize AsciiDoc
content into structured publications
</li>
<li>
<strong>Kind 30041:</strong> Individual section content for publications
</li>
<li>
<strong>Other kinds:</strong> Standard Nostr events with custom tags
and content
</li>
</ul>
<div class="w-full flex justify-center">
<EventInput />
</div>
</div>
{/if}
</div>
</div>
@ -1392,26 +1357,17 @@ @@ -1392,26 +1357,17 @@
<div class="min-w-0 overflow-hidden">
<EventDetails {event} {profile} communityStatusMap={communityStatus} />
</div>
<div class="min-w-0 overflow-hidden">
<RelayActions {event} />
</div>
<div class="min-w-0 overflow-hidden">
<div class="flex flex-col space-y-6">
<CommentViewer {event} />
</div>
{#if user?.signedIn}
<div class="mt-8 min-w-0 overflow-hidden">
<Heading tag="h3" class="h-leather mb-4 break-words"
>Add Comment</Heading
>
{#if user?.signedIn}
<CommentBox {event} {userRelayPreference} />
</div>
{:else}
<div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg min-w-0">
<P>Please sign in to add comments.</P>
</div>
{/if}
{:else}
<AAlert color="blue">
Please sign in to add comments.
</AAlert>
{/if}
</div>
</div>
{/if}
</div>

67
src/routes/events/compose/+page.svelte

@ -0,0 +1,67 @@ @@ -0,0 +1,67 @@
<script lang="ts">
import { Heading, P, List, Li } from "flowbite-svelte";
import EventInput from "$components/EventInput.svelte";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk.ts";
import { userStore } from "$lib/stores/userStore.ts";
import { AAlert } from "$lib/a";
// AI-NOTE: 2025-01-24 - Reactive effect to log relay configuration when stores change - non-blocking approach
$effect.pre(() => {
const inboxRelays = $activeInboxRelays;
const outboxRelays = $activeOutboxRelays;
// Only log if we have relays (not empty arrays)
if (inboxRelays.length > 0 || outboxRelays.length > 0) {
// Defer logging to avoid blocking the reactive system
requestAnimationFrame(() => {
console.log('🔌 Compose Page - Relay Configuration Updated:');
console.log('📥 Inbox Relays:', inboxRelays);
console.log('📤 Outbox Relays:', outboxRelays);
console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`);
});
}
});
</script>
<div class="w-full flex justify-center">
<div class="flex flex-col w-full max-w-4xl my-6 px-4 mx-auto">
<div class="main-leather flex flex-col space-y-6">
<Heading tag="h1" class="h-leather mb-2">Compose Event</Heading>
<P class="my-3">
Use this page to compose and publish various types of events to the Nostr network.
You can create notes, articles, and other event types depending on your needs.
</P>
<P class="mb-4">
Create and publish new Nostr events to the network. This form
supports various event kinds including:
</P>
<List
class="mb-6 list-disc list-inside space-y-1"
>
<Li>
<strong>Kind 30040:</strong> Publication indexes that organize AsciiDoc
content into structured publications
</Li>
<Li>
<strong>Kind 30041:</strong> Individual section content for publications
</Li>
<Li>
<strong>Other kinds:</strong> Standard Nostr events with custom tags
and content
</Li>
</List>
{#if $userStore.signedIn}
<EventInput />
{:else}
<AAlert color="blue">
{#snippet title()}Sign In Required{/snippet}
Please sign in to compose and publish events to the Nostr network.
</AAlert>
{/if}
</div>
</div>
</div>

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

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
<script lang="ts">
import { Heading, Button, Alert } from "flowbite-svelte";
import { Heading, Button } from "flowbite-svelte";
import ZettelEditor from "$lib/components/ZettelEditor.svelte";
import { nip19 } from "nostr-tools";
import {
@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
type ProcessedPublishResults,
} from "$lib/services/publisher";
import { getNdkContext } from "$lib/ndk";
import { AAlert } from "$lib/a/index";
const ndk = getNdkContext();
@ -218,13 +219,10 @@ @@ -218,13 +219,10 @@
<title>Compose Note - Alexandria</title>
</svelte:head>
<!-- Main container with 75% width and centered -->
<div class="w-3/4 mx-auto">
<div class="flex flex-col space-y-4">
<!-- Main container with max 1024px width and centered -->
<div class="flex flex-col self-center items-center w-full max-w-[1024px] mx-auto px-2 space-y-4">
<Heading
tag="h1"
class="text-2xl font-bold text-gray-900 dark:text-gray-100"
>
tag="h1" class="h-leather mb-2">
Compose Notes
</Heading>
@ -240,7 +238,7 @@ @@ -240,7 +238,7 @@
<!-- Status Messages -->
{#if publishResults}
{#if publishResults.successCount === publishResults.total}
<Alert color="green" dismissable>
<AAlert color="green" dismissable>
<span class="font-medium">Success!</span>
{publishResults.successCount} events published.
{#if publishResults.successfulEvents.length > 0}
@ -261,9 +259,9 @@ @@ -261,9 +259,9 @@
</div>
</div>
{/if}
</Alert>
</AAlert>
{:else}
<Alert color="red" dismissable>
<AAlert color="red" dismissable>
<span class="font-medium">Some events failed to publish.</span>
{publishResults.successCount} of {publishResults.total} events published.
@ -310,8 +308,7 @@ @@ -310,8 +308,7 @@
</div>
</div>
{/if}
</Alert>
</AAlert>
{/if}
{/if}
</div>
</div>

113
src/routes/profile/+page.svelte

@ -0,0 +1,113 @@ @@ -0,0 +1,113 @@
<script lang="ts">
import { AAlert, AProfilePreview, AThemeToggleMini } from "$lib/a";
import CommentBox from "$lib/components/CommentBox.svelte";
import CommentViewer from "$lib/components/CommentViewer.svelte";
import { userStore } from "$lib/stores/userStore";
import { getUserMetadata } from "$lib/utils/nostrUtils";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getNdkContext } from "$lib/ndk.ts";
import { Heading, P } from "flowbite-svelte";
import ATechToggle from "$lib/a/reader/ATechToggle.svelte";
// State
let user = $state($userStore);
let loading = $state(false);
let error = $state<string | null>(null);
let profileEvent = $state<NDKEvent | null>(null);
let profile = $state<{
name?: string;
display_name?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
lud16?: string;
nip05?: string;
} | null>(null);
let lastFetchedPubkey: string | null = null;
let userRelayPreference = $state(false); // required by CommentBox
userStore.subscribe(v => user = v);
async function fetchProfileEvent(pubkey: string) {
if (!pubkey || pubkey === lastFetchedPubkey) return;
loading = true;
error = null;
try {
const ndk = getNdkContext();
if (!ndk) {
throw new Error('NDK not initialized');
}
// Fetch kind 0 event for this author
const evt = await ndk.fetchEvent({ kinds: [0], authors: [pubkey] });
profileEvent = evt || null;
if (evt?.content) {
try { profile = JSON.parse(evt.content); } catch { profile = null; }
} else {
profile = null;
}
// Fallback: ensure we have metadata via helper (will cache)
if (!profile && user.npub) {
const meta = await getUserMetadata(user.npub, ndk, true);
profile = {
name: meta.name,
display_name: meta.displayName,
about: meta.about,
picture: meta.picture,
banner: meta.banner,
website: meta.website,
lud16: meta.lud16,
nip05: meta.nip05,
};
}
lastFetchedPubkey = pubkey;
} catch (e: any) {
console.error('[profile/+page] Failed to fetch profile event', e);
error = e?.message || 'Failed to load profile';
} finally {
loading = false;
}
}
// Reactive: when user login changes fetch profile event
$effect(() => {
if (user?.pubkey) fetchProfileEvent(user.pubkey);
});
</script>
{#if !user || !user.signedIn}
<div class="w-full max-w-3xl mx-auto mt-10 px-4">
<AAlert color="blue">Please log in to view your profile.</AAlert>
</div>
{:else}
<div class="w-full flex justify-center">
<div class="flex flex-col w-full max-w-5xl my-6 px-4 mx-auto gap-6">
{#if profileEvent}
<AProfilePreview event={profileEvent} user={user} profile={profile} loading={loading} error={error} isOwn={!!user?.signedIn && (!profileEvent?.pubkey || profileEvent.pubkey === user.pubkey)} />
{/if}
<div class="mt-6">
<Heading tag="h3" class="h-leather mb-4">
Settings
</Heading>
<!-- Theme and tech settings -->
<ul>
<li>
<ATechToggle />
</li>
<li>
<AThemeToggleMini />
</li>
</ul>
</div>
{#if profileEvent}
<div class="main-leather flex flex-col space-y-6">
<CommentViewer event={profileEvent} />
<CommentBox event={profileEvent} {userRelayPreference} />
</div>
{:else if !loading}
<AAlert color="gray">No profile event (kind 0) found for this user.</AAlert>
{/if}
</div>
</div>
{/if}

39
src/routes/my-notes/+page.svelte → src/routes/profile/my-notes/+page.svelte

@ -1,14 +1,15 @@ @@ -1,14 +1,15 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { userStore } from "$lib/stores/userStore";
import { Button } from "flowbite-svelte";
import { userStore } from "$lib/stores/userStore.ts";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { getTitleTagForEvent } from "$lib/utils/event_input_utils";
import { getMatchingTags } from "$lib/utils/nostrUtils.ts";
import { getTitleTagForEvent } from "$lib/utils/event_input_utils.ts";
import asciidoctor from "asciidoctor";
import { postProcessAsciidoctorHtml } from "$lib/utils/markup/asciidoctorPostProcessor";
import { getNdkContext } from "$lib/ndk";
import { postProcessAsciidoctorHtml } from "$lib/utils/markup/asciidoctorPostProcessor.ts";
import { getNdkContext } from "$lib/ndk.ts";
const ndk = getNdkContext();
let events: NDKEvent[] = $state([]);
@ -172,33 +173,34 @@ @@ -172,33 +173,34 @@
// AI-NOTE: Check authentication status and redirect if not logged in
// Wait for authentication state to be properly initialized before checking
let authCheckTimeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
const user = $userStore;
// Clear any existing timeout
if (authCheckTimeout) {
clearTimeout(authCheckTimeout);
authCheckTimeout = null;
}
// If user is signed in, we're good
if (user.signedIn) {
checkingAuth = false;
return;
}
// If user is not signed in, wait a bit for auth restoration to complete
// This handles the case where the page loads before auth restoration finishes
authCheckTimeout = setTimeout(() => {
const currentUser = get(userStore);
if (!currentUser.signedIn) {
goto("/");
console.debug('[MyNotes] User not signed in after auth restoration, redirecting to home page');
goto('/');
} else {
checkingAuth = false;
}
}, 1500); // 1.5 second delay to allow auth restoration to complete
// Cleanup function
return () => {
if (authCheckTimeout) {
@ -221,6 +223,7 @@ @@ -221,6 +223,7 @@
>
<!-- Tag Filter Sidebar -->
<aside class="w-full lg:w-80 flex-shrink-0 self-start">
<Button size="sm" class="mb-3" onclick={() => goto('/new/compose')}>Create new</Button>
<h2 class="text-lg font-bold mb-4">Tag Type</h2>
<div class="flex flex-wrap gap-2 mb-6">
{#each tagTypes as type}
@ -271,9 +274,7 @@ @@ -271,9 +274,7 @@
</aside>
<!-- Notes Feed -->
<div
class="flex-1 w-full lg:max-w-5xl lg:ml-auto px-0 lg:px-4 min-w-0 overflow-hidden"
>
<div class="flex-1 w-full lg:max-w-5xl lg:ml-auto px-0 lg:px-4 min-w-0 overflow-hidden">
<h1 class="text-2xl font-bold mb-6">My Notes</h1>
{#if checkingAuth}
<div class="text-gray-500">Checking authentication...</div>
@ -286,13 +287,9 @@ @@ -286,13 +287,9 @@
{:else}
<ul class="space-y-4 w-full">
{#each filteredEvents as event}
<li
class="p-4 bg-white dark:bg-gray-800 rounded shadow w-full overflow-hidden"
>
<li class="p-4 bg-white dark:bg-gray-800 rounded shadow w-full overflow-hidden">
<div class="flex items-center justify-between mb-2 min-w-0">
<div class="font-semibold text-lg truncate flex-1 mr-2">
{getTitle(event)}
</div>
<div class="font-semibold text-lg truncate flex-1 mr-2">{getTitle(event)}</div>
<button
class="flex-shrink-0 px-2 py-1 text-xs rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600"
onclick={() => toggleTags(event.id)}

13
src/routes/profile/notifications/+page.svelte

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
<script lang="ts">
import Notifications from "$lib/components/Notifications.svelte";
import { AAlert } from "$lib/a";
import { userStore } from "$lib/stores/userStore";
</script>
<div class="flex flex-col w-full max-w-3xl mx-auto px-2 items-center gap-4">
{#if $userStore?.signedIn}
<Notifications />
{:else}
<AAlert color="blue">Please log in to view your notifications.</AAlert>
{/if}
</div>

1
src/routes/publication/[type]/[identifier]/+layout.server.ts

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = ({ url }: { url: URL }) => {
const currentUrl = `${url.origin}${url.pathname}`;

11
src/routes/publication/[type]/[identifier]/+page.svelte

@ -3,7 +3,6 @@ @@ -3,7 +3,6 @@
import type { PageProps } from "./$types";
import { onDestroy, onMount, setContext } from "svelte";
import Processor from "asciidoctor";
import ArticleNav from "$components/util/ArticleNav.svelte";
import { SveltePublicationTree } from "$lib/components/publications/svelte_publication_tree.svelte";
import { TableOfContents } from "$lib/components/publications/table_of_contents.svelte";
import { page } from "$app/state";
@ -180,13 +179,6 @@ @@ -180,13 +179,6 @@
{#if indexEvent && publicationTree && toc}
{@const debugInfo = `indexEvent: ${!!indexEvent}, publicationTree: ${!!publicationTree}, toc: ${!!toc}`}
{@const debugElement = console.debug('[Publication] Rendering publication with:', debugInfo)}
<ArticleNav
publicationType={data.publicationType}
rootId={indexEvent.id}
indexEvent={indexEvent}
/>
<main class="publication {data.publicationType}">
<Publication
rootAddress={indexEvent.tagAddress()}
publicationType={data.publicationType}
@ -194,7 +186,6 @@ @@ -194,7 +186,6 @@
publicationTree={publicationTree}
toc={toc}
/>
</main>
{:else if loading}
<main class="publication">
<div class="flex items-center justify-center min-h-screen">
@ -224,4 +215,4 @@ @@ -224,4 +215,4 @@
<p class="text-gray-600 dark:text-gray-400">Loading publication...</p>
</div>
</main>
{/if}
{/if}

3
src/routes/publication/[type]/[identifier]/+page.ts

@ -49,7 +49,8 @@ export const load: PageLoad = async ( @@ -49,7 +49,8 @@ export const load: PageLoad = async (
// AI-NOTE: Return null for indexEvent during SSR or when fetch fails
// The component will handle client-side loading and error states
const publicationType = indexEvent?.tags.find((tag) => tag[0] === "type")?.[1] ?? "";
const publicationType =
indexEvent?.tags.find((tag) => tag[0] === "type")?.[1] ?? "";
const result = {
publicationType,

10
src/routes/start/+page.svelte

@ -7,8 +7,7 @@ @@ -7,8 +7,7 @@
const isVersionKnown = appVersion !== "development";
</script>
<div class="w-full flex justify-center">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4">
<div class="w-full max-w-3xl flex flex-col self-center mb-3 px-2">
<Heading tag="h1" class="h-leather mb-2"
>Getting Started with Alexandria</Heading
>
@ -96,7 +95,7 @@ @@ -96,7 +95,7 @@
>
</P>
<div class="flex justify-center my-4">
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/JaneEyre.png"
alt="Jane Eyre, by Charlotte Brontë"
@ -132,7 +131,7 @@ @@ -132,7 +131,7 @@
>
</P>
<div class="flex justify-center my-4">
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/ResearchPaper.png"
alt="Research paper"
@ -152,7 +151,7 @@ @@ -152,7 +151,7 @@
>.
</P>
<div class="flex justify-center my-4">
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/Documentation.png"
alt="Documentation"
@ -178,5 +177,4 @@ @@ -178,5 +177,4 @@
to other wiki pages, creating a web of knowledge that can be navigated and
explored.
</P>
</main>
</div>

252
src/styles/a/cards.css

@ -0,0 +1,252 @@ @@ -0,0 +1,252 @@
@layer components {
/* ========================================
Base Card Styles
======================================== */
/* Main card leather theme */
.card-leather {
@apply shadow-none text-primary-1000 border-s-4 bg-highlight
border-primary-200 has-[:hover]:border-primary-700;
@apply dark:bg-primary-1000 dark:border-primary-800
dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
}
.card-leather h1,
.card-leather h2,
.card-leather h3,
.card-leather h4,
.card-leather h5,
.card-leather h6 {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100
dark:hover:text-primary-400;
}
.card-leather .font-thin {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100
dark:hover:text-primary-400;
}
/* Main card leather (used in profile previews) */
.main-leather {
@apply bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100;
}
/* ========================================
Responsive Card Styles
======================================== */
.responsive-card {
@apply w-full min-w-0 overflow-hidden;
}
.responsive-card-content {
@apply break-words overflow-hidden;
}
/* ========================================
Article Box Styles (Blog & Publication Cards)
======================================== */
.ArticleBox {
@apply shadow-none text-primary-1000 border-s-4 bg-highlight
border-primary-200 has-[:hover]:border-primary-700;
@apply dark:bg-primary-1000 dark:border-primary-800
dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
}
.ArticleBox h1,
.ArticleBox h2,
.ArticleBox h3,
.ArticleBox h4,
.ArticleBox h5,
.ArticleBox h6 {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100
dark:hover:text-primary-400;
}
.ArticleBox .font-thin {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100
dark:hover:text-primary-400;
}
/* Article box image transitions */
.ArticleBox.grid .ArticleBoxImage {
@apply max-h-0;
transition: max-height 0.5s ease;
}
.ArticleBox.grid.active .ArticleBoxImage {
@apply max-h-40;
}
/* ========================================
Event Preview Card Styles
======================================== */
/* Event preview card hover state */
.event-preview-card {
@apply hover:bg-highlight dark:bg-primary-900/70 bg-primary-50
dark:hover:bg-primary-800 border-primary-400 border-s-4 transition-colors
cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500
shadow-none;
}
/* Event metadata badges */
.event-kind-badge {
@apply text-[10px] px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700
text-gray-700 dark:text-gray-300;
}
.event-label {
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
}
/* Community badge */
.community-badge {
@apply inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded
bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300;
}
/* ========================================
Profile Card Styles
======================================== */
/* Profile verification badge (NIP-05) */
.profile-nip05-badge {
@apply px-2 py-0.5 !mb-0 rounded bg-green-100 dark:bg-green-900
text-green-700 dark:text-green-300 text-xs;
}
/* Community status indicator */
.community-status-indicator {
@apply flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full
flex items-center justify-center;
}
.community-status-icon {
@apply w-3 h-3 text-yellow-600 dark:text-yellow-400;
}
/* User list status indicator (heart) */
.user-list-indicator {
@apply flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex
items-center justify-center;
}
.user-list-icon {
@apply w-3 h-3 text-red-600 dark:text-red-400;
}
/* ========================================
Card Content Styles
======================================== */
/* Card content sections */
.card-header {
@apply flex items-start w-full p-4;
}
.card-body {
@apply px-4 pb-3 flex flex-col gap-2;
}
.card-footer {
@apply px-4 pt-2 pb-3 border-t border-primary-200 dark:border-primary-700
flex items-center gap-2 flex-wrap;
}
/* Card content text styles */
.card-summary {
@apply text-sm text-primary-900 dark:text-primary-200 line-clamp-2;
}
.card-content {
@apply text-sm text-gray-800 dark:text-gray-200 line-clamp-3 break-words
mb-4;
}
.card-about {
@apply text-sm text-gray-700 dark:text-gray-300 line-clamp-3;
}
/* Deferral link styling */
.deferral-link {
@apply underline text-primary-700 dark:text-primary-400
hover:text-primary-600 dark:hover:text-primary-400 break-all
cursor-pointer;
}
/* ========================================
Tags and Badges
======================================== */
.tags span {
@apply bg-primary-50 text-primary-800 text-sm font-medium me-2 px-2.5 py-0.5
rounded-sm dark:bg-primary-900 dark:text-primary-200;
}
/* ========================================
Card Image Styles
======================================== */
.card-image-container {
@apply w-full bg-primary-200 dark:bg-primary-800 relative;
}
.card-banner {
@apply w-full h-60 object-cover;
}
.card-avatar-container {
@apply absolute w-fit top-[-56px];
}
/* ========================================
Utility Classes for Cards
======================================== */
/* Prose styling within cards - extends prose class when applied */
.card-prose {
@apply max-w-none text-gray-900 dark:text-gray-100 break-words min-w-0;
overflow-wrap: anywhere;
}
/* Card metadata grid */
.card-metadata-grid {
@apply grid grid-cols-1 gap-y-2;
}
.card-metadata-label {
@apply font-semibold min-w-[120px] flex-shrink-0;
}
.card-metadata-value {
@apply min-w-0 break-words;
}
/* ========================================
Interactive Card States
======================================== */
/* Clickable card states */
.card-clickable {
@apply cursor-pointer transition-colors focus:outline-none focus:ring-2
focus:ring-primary-500;
}
.card-clickable:hover {
@apply bg-primary-100 dark:bg-primary-800;
}
/* ========================================
Skeleton Loader for Cards
======================================== */
.skeleton-leather div {
@apply bg-primary-100 dark:bg-primary-800;
}
.skeleton-leather {
@apply h-48;
}
}

5
src/styles/a/forms.css

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
@layer components {
/* ========================================
Base Form Styles
======================================== */
}

5
src/styles/a/primitives.css

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
@layer components {
.alert-leather {
@apply border border-s-4;
}
}

2
src/styles/base.css

@ -1,5 +1,3 @@ @@ -1,5 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {

10
src/styles/publications.css

@ -1,5 +1,9 @@ @@ -1,5 +1,9 @@
@layer components {
/* AsciiDoc content */
section {
@apply my-2;
}
.publication-leather p a {
@apply underline hover:text-primary-600 dark:hover:text-primary-400;
}
@ -121,6 +125,12 @@ @@ -121,6 +125,12 @@
}
/* admonition */
.publication-leather .admonitionblock,
.publication-leather .sidebarblock,
.publication-leather .tableblock {
@apply my-4;
}
.publication-leather .admonitionblock .title {
@apply font-semibold;
}

64
src/theme-tokens.css

@ -0,0 +1,64 @@ @@ -0,0 +1,64 @@
/* Default theme (your current palette) */
:root {
--brand-primary-0: #efe6dc;
--brand-primary-50: #decdb9;
--brand-primary-100: #d6c1a8;
--brand-primary-200: #c6a885;
--brand-primary-300: #b58f62;
--brand-primary-400: #ad8351;
--brand-primary-500: #c6a885;
--brand-primary-600: #795c39;
--brand-primary-700: #564a3e;
--brand-primary-800: #3c352c;
--brand-primary-900: #2a241c;
--brand-primary-950: #1d1812;
--brand-primary-1000: #15110d;
}
/* Example alternative theme: ocean */
:root[data-theme="ocean"] {
--brand-primary-0: #ecf8ff;
--brand-primary-50: #e6f3ff;
--brand-primary-100: #d9ecff;
--brand-primary-200: #b9ddff;
--brand-primary-300: #90cbff;
--brand-primary-400: #61b6fb;
--brand-primary-500: #0ea5e9; /* sky-500-ish */
--brand-primary-600: #0284c7;
--brand-primary-700: #0369a1;
--brand-primary-800: #075985;
--brand-primary-900: #0c4a6e;
--brand-primary-950: #082f49;
--brand-primary-1000: #062233;
}
/* (Optional) per-theme dark tweaks — applied when <html class="dark"> is set */
:root.dark[data-theme="ocean"] {
/* nudge the mid tones brighter for contrast */
--brand-primary-400: #7ccdfc;
--brand-primary-500: #38bdf8;
}
/* Example alternative theme: forrest */
:root[data-theme="forrest"] {
--brand-primary-0: #eaf7ea;
--brand-primary-50: #d6eed6;
--brand-primary-100: #bfe3bf;
--brand-primary-200: #9fd49f;
--brand-primary-300: #7fc57f;
--brand-primary-400: #5fa65f;
--brand-primary-500: #3f863f; /* forest green */
--brand-primary-600: #2e6b2e;
--brand-primary-700: #205120;
--brand-primary-800: #153a15;
--brand-primary-900: #0c230c;
--brand-primary-950: #071507;
--brand-primary-1000: #041004;
}
/* (Optional) per-theme dark tweaks — applied when <html class="dark"> is set */
:root.dark[data-theme="forrest"] {
/* nudge the mid tones brighter for contrast */
--brand-primary-400: #7fc97f;
--brand-primary-500: #4caf50;
}

123
tailwind.config.cjs

@ -1,123 +0,0 @@ @@ -1,123 +0,0 @@
import flowbite from "flowbite/plugin";
import plugin from "tailwindcss/plugin";
import typography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config}*/
const config = {
content: [
"./src/**/*.{html,js,svelte,ts}",
"./node_modules/flowbite/**/*.js",
"./node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}",
],
theme: {
extend: {
colors: {
highlight: "#f9f6f1",
primary: {
0: "#efe6dc",
50: "#decdb9",
100: "#d6c1a8",
200: "#c6a885",
300: "#b58f62",
400: "#ad8351",
500: "#c6a885",
600: "#795c39",
700: "#564a3e",
800: "#3c352c",
900: "#2a241c",
950: "#1d1812",
1000: "#15110d",
},
success: {
50: "#e3f2e7",
100: "#c7e6cf",
200: "#a2d4ae",
300: "#7dbf8e",
400: "#5ea571",
500: "#4e8e5f",
600: "#3e744c",
700: "#305b3b",
800: "#22412a",
900: "#15281b",
},
info: {
50: "#e7eff6",
100: "#c5d9ea",
200: "#9fbfdb",
300: "#7aa5cc",
400: "#5e90be",
500: "#4779a5",
600: "#365d80",
700: "#27445d",
800: "#192b3a",
900: "#0d161f",
},
warning: {
50: "#fef4e6",
100: "#fde4bf",
200: "#fcd18e",
300: "#fbbc5c",
400: "#f9aa33",
500: "#f7971b",
600: "#c97a14",
700: "#9a5c0e",
800: "#6c3e08",
900: "#3e2404",
},
danger: {
50: "#fbeaea",
100: "#f5cccc",
200: "#eba5a5",
300: "#e17e7e",
400: "#d96060",
500: "#c94848",
600: "#a53939",
700: "#7c2b2b",
800: "#521c1c",
900: "#2b0e0e",
},
},
listStyleType: {
"upper-alpha": "upper-alpha", // Uppercase letters
"lower-alpha": "lower-alpha", // Lowercase letters
},
flexGrow: {
1: "1",
2: "2",
3: "3",
},
hueRotate: {
20: "20deg",
},
},
},
plugins: [
flowbite(),
typography,
plugin(function ({ addUtilities, matchUtilities }) {
addUtilities({
".content-visibility-auto": {
"content-visibility": "auto",
},
".contain-size": {
contain: "size",
},
});
matchUtilities({
"contain-intrinsic-w-*": (value) => ({
width: value,
}),
"contain-intrinsic-h-*": (value) => ({
height: value,
}),
});
}),
],
darkMode: "class",
};
module.exports = config;

111
tests/unit/mathProcessing.test.ts

@ -3,29 +3,38 @@ import { parseAdvancedmarkup } from "../../src/lib/utils/markup/advancedMarkupPa @@ -3,29 +3,38 @@ import { parseAdvancedmarkup } from "../../src/lib/utils/markup/advancedMarkupPa
describe("Math Processing in Advanced Markup Parser", () => {
it("should process inline math inside code blocks", async () => {
const input = "Here is some inline math: `$x^2 + y^2 = z^2$` in a sentence.";
const input =
"Here is some inline math: `$x^2 + y^2 = z^2$` in a sentence.";
const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">\\(x^2 + y^2 = z^2\\)</span>');
expect(result).toContain(
'<span class="math-inline">\\(x^2 + y^2 = z^2\\)</span>',
);
expect(result).toContain("Here is some inline math:");
expect(result).toContain("in a sentence.");
});
it("should process display math inside code blocks", async () => {
const input = "Here is a display equation:\n\n`$$\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n$$`\n\nThis is after the equation.";
const input =
"Here is a display equation:\n\n`$$\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n$$`\n\nThis is after the equation.";
const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-display">\\[\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n\\]</span>');
expect(result).toContain(
'<span class="math-display">\\[\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n\\]</span>',
);
expect(result).toContain('<p class="my-4">Here is a display equation:</p>');
expect(result).toContain('<p class="my-4">This is after the equation.</p>');
});
it("should process both inline and display math in the same code block", async () => {
const input = "Mixed math: `$\\alpha$ and $$\\beta = \\frac{1}{2}$$` in one block.";
const input =
"Mixed math: `$\\alpha$ and $$\\beta = \\frac{1}{2}$$` in one block.";
const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">\\(\\alpha\\)</span>');
expect(result).toContain('<span class="math-display">\\[\\beta = \\frac{1}{2}\\]</span>');
expect(result).toContain(
'<span class="math-display">\\[\\beta = \\frac{1}{2}\\]</span>',
);
expect(result).toContain("Mixed math:");
expect(result).toContain("in one block.");
});
@ -33,34 +42,39 @@ describe("Math Processing in Advanced Markup Parser", () => { @@ -33,34 +42,39 @@ describe("Math Processing in Advanced Markup Parser", () => {
it("should NOT process math outside of code blocks", async () => {
const input = "This math $x^2 + y^2 = z^2$ should not be processed.";
const result = await parseAdvancedmarkup(input);
expect(result).toContain("$x^2 + y^2 = z^2$");
expect(result).not.toContain('<span class="math-inline">');
expect(result).not.toContain('<span class="math-display">');
});
it("should NOT process display math outside of code blocks", async () => {
const input = "This display math $$\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n$$ should not be processed.";
const input =
"This display math $$\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n$$ should not be processed.";
const result = await parseAdvancedmarkup(input);
expect(result).toContain("$$\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n$$");
expect(result).toContain(
"$$\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n$$",
);
expect(result).not.toContain('<span class="math-inline">');
expect(result).not.toContain('<span class="math-display">');
});
it("should handle code blocks without math normally", async () => {
const input = "Here is some code: `console.log('hello world')` that should not be processed.";
const input =
"Here is some code: `console.log('hello world')` that should not be processed.";
const result = await parseAdvancedmarkup(input);
expect(result).toContain("`console.log('hello world')`");
expect(result).not.toContain('<span class="math-inline">');
expect(result).not.toContain('<span class="math-display">');
});
it("should handle complex math expressions with nested structures", async () => {
const input = "Complex math: `$$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix} \\cdot \\begin{pmatrix} x \\\\ y \\end{pmatrix} = \\begin{pmatrix} ax + by \\\\ cx + dy \\end{pmatrix}$$`";
const input =
"Complex math: `$$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix} \\cdot \\begin{pmatrix} x \\\\ y \\end{pmatrix} = \\begin{pmatrix} ax + by \\\\ cx + dy \\end{pmatrix}$$`";
const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\begin{pmatrix}");
expect(result).toContain("\\end{pmatrix}");
@ -68,25 +82,34 @@ describe("Math Processing in Advanced Markup Parser", () => { @@ -68,25 +82,34 @@ describe("Math Processing in Advanced Markup Parser", () => {
});
it("should handle inline math with special characters", async () => {
const input = "Special chars: `$\\alpha, \\beta, \\gamma, \\delta$` and `$\\sum_{i=1}^{n} x_i$`";
const input =
"Special chars: `$\\alpha, \\beta, \\gamma, \\delta$` and `$\\sum_{i=1}^{n} x_i$`";
const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">\\(\\alpha, \\beta, \\gamma, \\delta\\)</span>');
expect(result).toContain('<span class="math-inline">\\(\\sum_{i=1}^{n} x_i\\)</span>');
expect(result).toContain(
'<span class="math-inline">\\(\\alpha, \\beta, \\gamma, \\delta\\)</span>',
);
expect(result).toContain(
'<span class="math-inline">\\(\\sum_{i=1}^{n} x_i\\)</span>',
);
});
it("should handle multiple math expressions in separate code blocks", async () => {
const input = "First: `$E = mc^2$` and second: `$$F = G\\frac{m_1 m_2}{r^2}$$`";
const input =
"First: `$E = mc^2$` and second: `$$F = G\\frac{m_1 m_2}{r^2}$$`";
const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">\\(E = mc^2\\)</span>');
expect(result).toContain('<span class="math-display">\\[F = G\\frac{m_1 m_2}{r^2}\\]</span>');
expect(result).toContain(
'<span class="math-display">\\[F = G\\frac{m_1 m_2}{r^2}\\]</span>',
);
});
it("should handle math expressions with line breaks in display mode", async () => {
const input = "Multi-line: `$$\n\\begin{align}\nx &= a + b \\\\\ny &= c + d\n\\end{align}\n$$`";
const input =
"Multi-line: `$$\n\\begin{align}\nx &= a + b \\\\\ny &= c + d\n\\end{align}\n$$`";
const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\begin{align}");
expect(result).toContain("\\end{align}");
@ -97,7 +120,7 @@ describe("Math Processing in Advanced Markup Parser", () => { @@ -97,7 +120,7 @@ describe("Math Processing in Advanced Markup Parser", () => {
it("should handle edge case with empty math expressions", async () => {
const input = "Empty math: `$$` and `$`";
const result = await parseAdvancedmarkup(input);
// Should not crash and should preserve the original content
expect(result).toContain("`$$`");
expect(result).toContain("`$`");
@ -115,16 +138,18 @@ And display math: \`$$\n\\int_0^1 x^2 dx = \\frac{1}{3}\n$$\` @@ -115,16 +138,18 @@ And display math: \`$$\n\\int_0^1 x^2 dx = \\frac{1}{3}\n$$\`
And more regular text.`;
const result = await parseAdvancedmarkup(input);
// Should preserve regular text
expect(result).toContain("This is a paragraph with regular text.");
expect(result).toContain("And more regular text.");
// Should preserve regular code blocks
expect(result).toContain("`console.log('hello')`");
// Should process math
expect(result).toContain('<span class="math-inline">\\(\\pi \\approx 3.14159\\)</span>');
expect(result).toContain(
'<span class="math-inline">\\(\\pi \\approx 3.14159\\)</span>',
);
expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\int_0^1 x^2 dx = \\frac{1}{3}");
});
@ -132,15 +157,16 @@ And more regular text.`; @@ -132,15 +157,16 @@ And more regular text.`;
it("should handle math expressions with dollar signs in the content", async () => {
const input = "Price math: `$\\text{Price} = \\$19.99$`";
const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">');
expect(result).toContain("\\text{Price} = \\$19.99");
});
it("should handle display math with dollar signs in the content", async () => {
const input = "Price display: `$$\n\\text{Total} = \\$19.99 + \\$5.99 = \\$25.98\n$$`";
const input =
"Price display: `$$\n\\text{Total} = \\$19.99 + \\$5.99 = \\$25.98\n$$`";
const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\text{Total} = \\$19.99 + \\$5.99 = \\$25.98");
});
@ -149,34 +175,37 @@ And more regular text.`; @@ -149,34 +175,37 @@ And more regular text.`;
// Simulate content from JSON where backslashes are escaped
const jsonContent = "Math from JSON: `$\\\\alpha + \\\\beta = \\\\gamma$`";
const result = await parseAdvancedmarkup(jsonContent);
expect(result).toContain('<span class="math-inline">');
expect(result).toContain("\\\\alpha + \\\\beta = \\\\gamma");
});
it("should handle JSON content with escaped display math", async () => {
// Simulate content from JSON where backslashes are escaped
const jsonContent = "Display math from JSON: `$$\\\\int_0^1 x^2 dx = \\\\frac{1}{3}$$`";
const jsonContent =
"Display math from JSON: `$$\\\\int_0^1 x^2 dx = \\\\frac{1}{3}$$`";
const result = await parseAdvancedmarkup(jsonContent);
expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\\\int_0^1 x^2 dx = \\\\frac{1}{3}");
});
it("should handle JSON content with escaped dollar signs", async () => {
// Simulate content from JSON where dollar signs are escaped
const jsonContent = "Price math from JSON: `$\\\\text{Price} = \\\\\\$19.99$`";
const jsonContent =
"Price math from JSON: `$\\\\text{Price} = \\\\\\$19.99$`";
const result = await parseAdvancedmarkup(jsonContent);
expect(result).toContain('<span class="math-inline">');
expect(result).toContain("\\\\text{Price} = \\\\\\$19.99");
});
it("should handle complex JSON content with multiple escaped characters", async () => {
// Simulate complex content from JSON
const jsonContent = "Complex JSON math: `$$\\\\begin{pmatrix} a & b \\\\\\\\ c & d \\\\end{pmatrix} \\\\cdot \\\\begin{pmatrix} x \\\\\\\\ y \\\\end{pmatrix}$$`";
const jsonContent =
"Complex JSON math: `$$\\\\begin{pmatrix} a & b \\\\\\\\ c & d \\\\end{pmatrix} \\\\cdot \\\\begin{pmatrix} x \\\\\\\\ y \\\\end{pmatrix}$$`";
const result = await parseAdvancedmarkup(jsonContent);
expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\\\begin{pmatrix}");
expect(result).toContain("\\\\end{pmatrix}");

8
tests/unit/tagExpansion.test.ts

@ -335,7 +335,9 @@ describe("Tag Expansion Tests", () => { @@ -335,7 +335,9 @@ describe("Tag Expansion Tests", () => {
);
// Should not include events without tags
expect(result.publications.map((p: any) => p.id)).not.toContain("no-tags");
expect(result.publications.map((p: any) => p.id)).not.toContain(
"no-tags",
);
});
});
@ -512,7 +514,9 @@ describe("Tag Expansion Tests", () => { @@ -512,7 +514,9 @@ describe("Tag Expansion Tests", () => {
// Should handle d-tags with colons correctly
expect(result.publications).toHaveLength(3);
expect(result.contentEvents.map((c: any) => c.id)).toContain("colon-content");
expect(result.contentEvents.map((c: any) => c.id)).toContain(
"colon-content",
);
});
});
});

2
vite.config.ts

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import { execSync } from "child_process";
import { execSync } from "node:child_process";
import process from "node:process";
// Function to get the latest git tag

Loading…
Cancel
Save