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

7
postcss.config.js

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

537
src/app.css

@ -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/base.css";
@import "./styles/scrollbar.css"; @import "./styles/scrollbar.css";
@import "./styles/publications.css"; @import "./styles/publications.css";
@import "./styles/visualize.css"; @import "./styles/visualize.css";
@import "./styles/asciidoc.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 */ /* Custom styles */
@layer base { @layer base {
.leather { /* disable chrome cancel button */
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100; 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 { .btn-leather.text-xs {
@ -36,28 +251,6 @@
@apply border border-primary-700; @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 { main {
@apply max-w-full flex; @apply max-w-full flex;
} }
@ -73,14 +266,13 @@
main.main-leather, main.main-leather,
article.article-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, div.note-leather,
p.note-leather, p.note-leather,
section.note-leather { section.note-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 @apply text-gray-900 dark:text-gray-100 p-2 rounded;
p-2 rounded;
} }
.edit div.note-leather:hover:not(:has(.note-leather:hover)), .edit div.note-leather:hover:not(:has(.note-leather:hover)),
@ -89,50 +281,8 @@
@apply hover:bg-primary-100 dark:hover:bg-primary-800; @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 { 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; dark:border-primary-600;
} }
@ -142,14 +292,16 @@
div.modal-leather > div > h4, div.modal-leather > div > h4,
div.modal-leather > div > h5, div.modal-leather > div > h5,
div.modal-leather > div > h6 { div.modal-leather > div > h6 {
@apply text-gray-900 hover:text-gray-900 dark:text-gray-100 @apply text-gray-900 dark:text-gray-100;
dark:hover:text-gray-100;
} }
div.modal-leather button { div.modal-leather > div > h1 a,
@apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950 div.modal-leather > div > h2 a,
dark:hover:bg-primary-950 text-gray-900 hover:text-primary-600 div.modal-leather > div > h3 a,
dark:text-gray-100 dark:hover:text-primary-400; 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 */ /* Navbar */
@ -166,26 +318,33 @@
dark:hover:fill-primary-400; 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 h1,
nav.navbar-leather h2, nav.navbar-leather h2,
nav.navbar-leather h3, nav.navbar-leather h3,
nav.navbar-leather h4, nav.navbar-leather h4,
nav.navbar-leather h5, nav.navbar-leather h5,
nav.navbar-leather h6 { nav.navbar-leather h6 {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 @apply text-gray-900 dark:text-gray-100;
dark:hover:text-primary-400;
}
div.skeleton-leather div {
@apply bg-primary-100 dark:bg-primary-800;
} }
div.skeleton-leather { nav.navbar-leather h1 a,
@apply h-48; 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 { 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), div.textarea-leather > div:nth-child(1),
@ -194,7 +353,7 @@
} }
div.textarea-leather > div:nth-child(2) { 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, div.textarea-leather,
@ -240,33 +399,7 @@
/* Utilities can be applied via the @apply directive. */ /* Utilities can be applied via the @apply directive. */
@layer utilities { @layer utilities {
.h-leather { /* Removed redundant .h-leather and .h1-leather through .h6-leather - use base layer definitions instead */
@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;
}
/* Line clamp utilities for text truncation */ /* Line clamp utilities for text truncation */
.line-clamp-1 { .line-clamp-1 {
@ -290,33 +423,23 @@
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
} }
.decoration-none {
text-decoration: none !important;
}
/* Lists */ /* Lists */
.ol-leather li a, .ol-leather li a,
.ul-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; dark:hover:text-primary-400;
} }
/* Links - consistent hover colors */
.link { .link {
@apply underline cursor-pointer hover:text-primary-600 @apply underline cursor-pointer hover:text-primary-600
dark:hover:text-primary-400; 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 { .npub-badge {
@apply inline-flex space-x-1 items-center text-primary-600 @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 dark:text-primary-500 hover:underline me-2 px-2 py-0.5 rounded-sm border
@ -326,9 +449,17 @@
@apply fill-primary-600 dark:fill-primary-500; @apply fill-primary-600 dark:fill-primary-500;
} }
} }
[data-tech="off"] .tech-detail {
@apply !hidden;
}
} }
@layer components { @layer components {
nav a {
text-decoration-line: none !important;
}
canvas.qr-code { canvas.qr-code {
@apply block mx-auto my-4; @apply block mx-auto my-4;
} }
@ -349,7 +480,7 @@
/* Tooltip */ /* Tooltip */
.tooltip-leather { .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 text-gray-900 dark:text-gray-100 border border-gray-200
dark:border-gray-700 transition-colors duration-200; dark:border-gray-700 transition-colors duration-200;
max-width: 400px; max-width: 400px;
@ -360,112 +491,78 @@
@apply dark:text-white; @apply dark:text-white;
} }
/* Rendered publication content */
.publication-leather { .publication-leather {
@apply flex flex-col space-y-4; @apply flex flex-col space-y-4;
scroll-margin-top: 150px; scroll-margin-top: 150px;
scroll-behavior: smooth; scroll-behavior: smooth;
}
h1, /* Publication headings inherit from base layer - removed duplicate definitions */
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;
}
.olist { .olist {
@apply flex flex-col space-y-4; @apply flex flex-col space-y-4;
ol { ol {
@apply ol-leather list-decimal px-6 flex flex-col space-y-2; @apply list-decimal px-6 flex flex-col space-y-2;
li { li {
.paragraph { .paragraph {
@apply py-2; @apply py-2;
}
} }
} }
} }
}
.ulist { .ulist {
@apply flex flex-col space-y-4; @apply flex flex-col space-y-4;
ul { ul {
@apply ul-leather list-disc px-6 flex flex-col space-y-2; @apply list-disc px-6 flex flex-col space-y-2;
li { li {
.paragraph { .paragraph {
@apply py-2; @apply py-2;
}
} }
} }
} }
}
a { /* All links - consistent hover behavior */
@apply link; a {
} @apply underline cursor-pointer hover:text-primary-600
dark:hover:text-primary-400;
}
.imageblock { .imageblock {
@apply flex flex-col items-center; @apply flex flex-col items-center;
.title { .title {
@apply text-sm text-center; @apply text-sm text-center;
}
} }
}
.stemblock { .stemblock {
@apply bg-gray-200 dark:bg-gray-800 p-4 rounded-lg; @apply bg-gray-200 dark:bg-gray-800 p-4 rounded-lg;
} }
.literalblock { .literalblock {
pre { pre {
@apply text-wrap; @apply text-wrap;
}
} }
}
table { table {
@apply w-full overflow-x-auto; @apply w-full overflow-x-auto;
caption { caption {
@apply text-sm; @apply text-sm;
} }
thead, thead,
tbody { tbody {
th, th,
td { td {
@apply border border-gray-200 dark:border-gray-700; @apply border border-gray-200 dark:border-gray-700;
}
} }
} }
} }
@ -473,7 +570,7 @@
/* Footnotes */ /* Footnotes */
.footnote-ref { .footnote-ref {
text-decoration: none; text-decoration: none;
color: var(--color-primary); color: var(--color-primary-500);
} }
.footnotes { .footnotes {
@ -495,6 +592,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.footnotes li { .footnotes li {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@ -502,12 +600,12 @@
.footnote-backref { .footnote-backref {
text-decoration: none; text-decoration: none;
margin-left: 0.5rem; margin-left: 0.5rem;
color: var(--color-primary); color: var(--color-primary-500);
} }
.note-leather .footnote-ref, .note-leather .footnote-ref,
.note-leather .footnote-backref { .note-leather .footnote-backref {
color: var(--color-leather-primary); color: var(--color-primary-500);
} }
/* Scrollable content */ /* Scrollable content */
@ -575,15 +673,15 @@
input[type="tel"], input[type="tel"],
input[type="url"], input[type="url"],
textarea { textarea {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 @apply bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100
border-s-4 border-primary-200 rounded shadow-none px-4 py-2; border-s-4 border-primary-200 rounded shadow-none;
@apply focus:border-primary-600 dark:focus:border-primary-400; @apply focus:border-primary-600 dark:focus:border-primary-400;
} }
/* Table of Contents highlighting */ /* Table of Contents highlighting */
.toc-highlight { .toc-highlight {
@apply bg-primary-200 dark:bg-primary-700 border-l-4 border-primary-600 @apply bg-primary-300 dark:bg-primary-700 border-s-4 border-primary-600
dark:border-primary-400 font-medium; rounded dark:border-primary-400 font-medium;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
} }
@ -618,3 +716,10 @@
text-indent: 0 !important; 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 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-tech="off">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png?v=2" /> <link rel="icon" href="%sveltekit.assets%/favicon.png?v=2" />
<meta name="viewport" content="width=device-width" /> <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 --> <!-- MathJax for math rendering -->
<script> <script>
window.MathJax = { 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 @@
# 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 @@
<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 @@
<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 @@
<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 @@
<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 @@
<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 @@
<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 @@
// 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 @@
<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 @@
<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 @@
#!/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 @@
<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 @@
<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 @@
<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 @@
<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 @@
<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 @@
<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 @@
<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 @@
<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 @@
<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 @@
<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 @@
<script lang="ts"> <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 { UserOutline } from "flowbite-svelte-icons";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { toNpub } from "$lib/utils/nostrUtils"; import { toNpub } from "$lib/utils/nostrUtils";
import { searchProfiles } from "$lib/utils/search_utility"; 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 { userStore } from "$lib/stores/userStore";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { import {
@ -17,7 +21,8 @@
import { tick } from "svelte"; import { tick } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk"; 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<{ const props = $props<{
event: NDKEvent; event: NDKEvent;
@ -155,7 +160,7 @@
async function handleSubmit( async function handleSubmit(
useOtherRelays = false, useOtherRelays = false,
useSecondaryRelays = false, useSecondaryRelays = false
) { ) {
isSubmitting = true; isSubmitting = true;
error = null; error = null;
@ -372,16 +377,21 @@
} }
</script> </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="w-full space-y-4">
<div class="flex flex-wrap gap-2">
{#each markupButtons as button} <ACommentForm
<Button size="xs" onclick={button.action}>{button.label}</Button> bind:content={content}
{/each} {isSubmitting}
<Button size="xs" color="alternative" onclick={removeFormatting} onSubmit={() => handleSubmit()}
>Remove Formatting</Button extensions={commentExtensions}
> />
<Button size="xs" color="alternative" onclick={clearForm}>Clear</Button>
</div>
<!-- Mention Modal --> <!-- Mention Modal -->
<Modal <Modal
@ -436,81 +446,85 @@
> >
<ul class="space-y-1 p-2"> <ul class="space-y-1 p-2">
{#each mentionResults as profile} {#each mentionResults as profile}
<button <li>
type="button" <div
class="w-full text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 p-2 rounded flex items-center gap-3" role="button"
onclick={() => selectMention(profile)} 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"
{#if profile.isInUserLists} onclick={() => selectMention(profile)}
<div onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectMention(profile); } }}
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" {#if profile.isInUserLists}
> <div
<svg class="flex-shrink-0 w-6 h-6 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
class="w-4 h-4 text-red-600 dark:text-red-400" title="In your lists"
fill="currentColor"
viewBox="0 0 24 24"
> >
<path <svg
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" class="w-4 h-4 text-red-600 dark:text-red-400"
/> fill="currentColor"
</svg> viewBox="0 0 24 24"
</div> >
{:else if profile.pubkey && communityStatus[profile.pubkey]} <path
<div 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"
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>
> </div>
<svg {:else if profile.pubkey && communityStatus[profile.pubkey]}
class="w-4 h-4 text-yellow-600 dark:text-yellow-400" <div
fill="currentColor" class="flex-shrink-0 w-6 h-6 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
viewBox="0 0 24 24" 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 <svg
class="inline w-4 h-4 text-primary-500" class="w-4 h-4 text-yellow-600 dark:text-yellow-400"
fill="none" fill="currentColor"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/></svg
> >
{profile.nip05} <path
</span> 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}
<span class="text-xs text-gray-400 font-mono truncate" {#if profile.picture}
>{shortenNpub(profile.pubkey)}</span <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> </div>
</button> </li>
{/each} {/each}
</ul> </ul>
</div> </div>
@ -558,22 +572,6 @@
</div> </div>
</Modal> </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} {#if error}
<Alert color="red" dismissable> <Alert color="red" dismissable>
{error} {error}
@ -605,43 +603,6 @@
</Alert> </Alert>
{/if} {/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} {#if !$userStore.pubkey}
<Alert color="yellow" class="mt-4"> <Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your Please sign in to post comments. Your comments will be signed with your

2
src/lib/components/CommentViewer.svelte

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

361
src/lib/components/EventDetails.svelte

@ -6,7 +6,7 @@
import { activeInboxRelays } from "$lib/ndk"; import { activeInboxRelays } from "$lib/ndk";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } 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 { goto } from "$app/navigation";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils"; import { getUserMetadata } from "$lib/utils/nostrUtils";
@ -22,6 +22,9 @@
import { getNdkContext } from "$lib/ndk"; import { getNdkContext } from "$lib/ndk";
import type { UserProfile } from "$lib/models/user_profile"; import type { UserProfile } from "$lib/models/user_profile";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte"; 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 { const {
event, event,
@ -218,6 +221,32 @@
return { text: `${tag[0]}:${tag[1]}` }; 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(() => { $effect(() => {
if (!event?.pubkey) { if (!event?.pubkey) {
authorDisplayName = undefined; authorDisplayName = undefined;
@ -236,12 +265,10 @@
// --- Identifier helpers --- // --- Identifier helpers ---
function getIdentifiers( function getIdentifiers(
event: NDKEvent, event: NDKEvent,
profile: any, _profile: any,
): { label: string; value: string; link?: string }[] { ): { label: string; value: string; link?: string }[] {
const ids: { label: string; value: string; link?: string }[] = []; const ids: { label: string; value: string; link?: string }[] = [];
if (event.kind === 0) { if (event.kind === 0) {
// NIP-05
const nip05 = profile?.nip05 || getMatchingTags(event, "nip05")[0]?.[1];
// npub // npub
const npub = toNpub(event.pubkey); const npub = toNpub(event.pubkey);
if (npub) if (npub)
@ -278,6 +305,10 @@
return ids; return ids;
} }
function navigateToIdentifier(link: string) {
goto(link);
}
onMount(() => { onMount(() => {
function handleInternalLinkClick(event: MouseEvent) { function handleInternalLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
@ -299,43 +330,38 @@
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100 break-words"> <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100 break-words">
{@render basicMarkup(getEventTitle(event), ndk)} {@render basicMarkup(getEventTitle(event), ndk)}
</h2> </h2>
{/if}
<!-- Notifications (for profile events) --> <div class="flex items-center space-x-2 min-w-0">
{#if event.kind === 0} {#if toNpub(event.pubkey)}
<Notifications {event} />
{/if}
<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" <span class="text-gray-600 dark:text-gray-400 min-w-0"
>Author: {@render userBadge( >Author: {@render userBadge(
toNpub(event.pubkey) as string, toNpub(event.pubkey) || '',
profile?.display_name || undefined, profile?.display_name || undefined,
ndk, ndk,
)}</span )}</span
> >
{:else} {:else}
<span class="text-gray-600 dark:text-gray-400 min-w-0 break-words" <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} {/if}
</div> </div>
<div class="flex items-center space-x-2 min-w-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="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span>
<span class="font-mono flex-shrink-0">{event.kind}</span> <span class="font-mono flex-shrink-0">{event.kind}</span>
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0" <span class="text-gray-700 dark:text-gray-300 flex-shrink-0"
>({getEventTypeDisplay(event)})</span >({getEventTypeDisplay(event)})</span
> >
</div> </div>
<div class="flex flex-col space-y-1 min-w-0"> <div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300">Summary:</span> <span class="text-gray-700 dark:text-gray-300">Summary:</span>
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0"> <div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0">
{@render basicMarkup(getEventSummary(event), ndk)} {@render basicMarkup(getEventSummary(event), ndk)}
</div>
</div> </div>
</div> {/if}
<!-- Containing Publications --> <!-- Containing Publications -->
<ContainingIndexes {event} /> <ContainingIndexes {event} />
@ -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="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"> <div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300 font-semibold">Content:</span> <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"> <div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}>
{#if isRepost} {#if isRepost}
<!-- Repost content handling --> <!-- Repost content handling -->
{#if repostKinds.includes(event.kind)} {#if repostKinds.includes(event.kind)}
<!-- Kind 6 and 16 reposts - stringified JSON content --> <!-- Kind 6 and 16 reposts - stringified JSON content -->
<div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2"> <div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> <div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{event.kind === 6 ? 'Reposted content:' : 'Generic reposted content:'} {event.kind === 6 ? 'Reposted content:' : 'Generic reposted content:'}
</div> </div>
{@render repostContent(event.content)} {@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> </div>
{@render quotedContent(event, [], ndk)} {:else if event.kind === 1 && event.getMatchingTags("q").length > 0}
{#if content} <!-- Quote repost - kind 1 with q tag -->
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"> <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"> <div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Added comment: Quote repost:
</div>
{#if repostKinds.includes(kind)}
{@html content}
{:else}
{@render basicMarkup(content, ndk)}
{/if}
</div> </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} {/if}
</div> </div>
{/if} {#if shouldTruncate}
{:else} <button
<!-- Regular content --> class="mt-2 text-primary-700 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-200"
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}> onclick={() => (showFullContent = true)}>Show more</button
{#if repostKinds.includes(kind)} >
{@html content}
{:else}
{@render basicMarkup(content, ndk)}
{/if} {/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}
{/if}
</div> </div>
</div> </div>
</div> </div>
@ -402,126 +428,83 @@
<!-- If event is profile --> <!-- If event is profile -->
{#if event.kind === 0} {#if event.kind === 0}
<ProfileHeader <AProfilePreview event={event} profile={profile} communityStatusMap={communityStatusMap} />
{event}
{profile}
{communityStatusMap}
/>
{/if} {/if}
<!-- Raw Event JSON --> <ATechBlock>
<details {#snippet content()}
class="relative w-full max-w-2xl md:max-w-full bg-primary-50 dark:bg-primary-900 rounded p-4 overflow-hidden" <Heading tag="h3" class="h-leather my-6">
> Technical details
<summary </Heading>
class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2"
> <Accordion flush class="w-full">
Show details <AccordionItem open={false} >
</summary> {#snippet header()}Identifiers{/snippet}
{#if event}
<!-- Identifiers Section --> <div class="flex flex-col gap-2">
<div class="mb-4 max-w-full overflow-hidden"> {#each getIdentifiers(event, profile) as identifier}
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Identifiers:</h4> <div class="grid grid-cols-[max-content_minmax(0,1fr)_max-content] items-start gap-2 min-w-0">
<div class="flex flex-col gap-2 min-w-0"> <span class="min-w-24 text-gray-600 dark:text-gray-400">{identifier.label}:</span>
{#each getIdentifiers(event, profile) as identifier} <div class="min-w-0">
<div class="flex items-center gap-2 min-w-0"> {#if identifier.link}
<span class="text-gray-600 dark:text-gray-400 flex-shrink-0">{identifier.label}:</span> <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"
<div class="flex-1 min-w-0 flex items-center gap-2"> onclick={() => navigateToIdentifier(identifier.link)}>
{#if identifier.link} {identifier.value}
<a </button>
href={identifier.link} {:else}
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" <span class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all">{identifier.value}</span>
title={identifier.value} {/if}
> </div>
{identifier.value.slice(0, 20)}...{identifier.value.slice(-8)} <div class="justify-self-end">
</a> <CopyToClipboard displayText="" copyText={identifier.value} />
{:else} </div>
<span class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all" title={identifier.value}> </div>
{identifier.value.slice(0, 20)}...{identifier.value.slice(-8)} {/each}
</span> </div>
{/if} {/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 <CopyToClipboard
displayText="" displayText=""
copyText={identifier.value} copyText={JSON.stringify(event.rawEvent(), null, 2)}
/> />
</div> </div>
</div> {#if event}
{/each} <pre class="p-4 wrap-break-word bg-highlight dark:bg-primary-900">
</div> <code class="text-wrap">{JSON.stringify(event.rawEvent(), null, 2)}</code>
</div> </pre>
{/if}
<!-- Event Tags Section --> </AccordionItem>
{#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}
<!-- Raw Event JSON Section --> <AccordionItem open={true}>
<div class="mb-4 max-w-full overflow-hidden"> {#snippet header()}Relay Info{/snippet}
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Raw Event JSON:</h4> <RelayActions {event} />
<div class="relative min-w-0"> </AccordionItem>
<div class="absolute top-0 right-0 z-10"> </Accordion>
<CopyToClipboard {/snippet}
displayText="" </ATechBlock>
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>
</div> </div>

4
src/lib/components/EventRenderLevelLimit.svelte

@ -42,14 +42,14 @@
id="levels-to-render" id="levels-to-render"
min="1" min="1"
max="50" 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} bind:value={inputValue}
oninput={handleInput} oninput={handleInput}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
/> />
<button <button
onclick={handleUpdate} 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 Update
</button> </button>

15
src/lib/components/EventSearch.svelte

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

246
src/lib/components/Notifications.svelte

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

33
src/lib/components/RelayStatus.svelte

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

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

@ -156,11 +156,11 @@
</div> </div>
{#if communityStatus === true} {#if communityStatus === true}
<div <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" title="Has posted to the community"
> >
<svg <svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400" class="community-status-icon"
fill="currentColor" fill="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
@ -174,11 +174,11 @@
{/if} {/if}
{#if isInUserLists === true} {#if isInUserLists === true}
<div <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.)" title="In your lists (follows, etc.)"
> >
<svg <svg
class="w-3 h-3 text-red-600 dark:text-red-400" class="user-list-icon"
fill="currentColor" fill="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
@ -194,24 +194,24 @@
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<div class="mt-2 flex flex-col gap-4"> <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} {#if profile.name}
<div class="flex gap-2 min-w-0"> <div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">Name:</dt> <dt class="card-metadata-label">Name:</dt>
<dd class="min-w-0 break-words">{profile.name}</dd> <dd class="card-metadata-value">{profile.name}</dd>
</div> </div>
{/if} {/if}
{#if profile.displayName} {#if profile.displayName}
<div class="flex gap-2 min-w-0"> <div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">Display Name:</dt> <dt class="card-metadata-label">Display Name:</dt>
<dd class="min-w-0 break-words">{profile.displayName}</dd> <dd class="card-metadata-value">{profile.displayName}</dd>
</div> </div>
{/if} {/if}
{#if profile.about} {#if profile.about}
<div class="flex gap-2 min-w-0"> <div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">About:</dt> <dt class="card-metadata-label">About:</dt>
<dd class="min-w-0 break-words"> <dd class="card-metadata-value">
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0"> <div class="prose dark:prose-invert card-prose">
{@render basicMarkup(profile.about, ndk)} {@render basicMarkup(profile.about, ndk)}
</div> </div>
</dd> </dd>
@ -219,8 +219,8 @@
{/if} {/if}
{#if profile.website} {#if profile.website}
<div class="flex gap-2 min-w-0"> <div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">Website:</dt> <dt class="card-metadata-label">Website:</dt>
<dd class="min-w-0 break-all"> <dd class="card-metadata-value">
<a <a
href={profile.website} href={profile.website}
class="underline text-primary-700 dark:text-primary-200" class="underline text-primary-700 dark:text-primary-200"
@ -231,8 +231,8 @@
{/if} {/if}
{#if profile.lud16} {#if profile.lud16}
<div class="flex items-center gap-2 mt-4 min-w-0"> <div class="flex items-center gap-2 mt-4 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">Lightning:</dt> <dt class="card-metadata-label">Lightning:</dt>
<dd class="min-w-0 break-all"> <dd class="card-metadata-value">
<Button <Button
class="btn-leather" class="btn-leather"
color="primary" color="primary"
@ -244,14 +244,14 @@
{/if} {/if}
{#if profile.nip05} {#if profile.nip05}
<div class="flex gap-2 min-w-0"> <div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">NIP-05:</dt> <dt class="card-metadata-label">NIP-05:</dt>
<dd class="min-w-0 break-all">{profile.nip05}</dd> <dd class="card-metadata-value">{profile.nip05}</dd>
</div> </div>
{/if} {/if}
{#each identifiers as id} {#each identifiers as id}
<div class="flex gap-2 min-w-0"> <div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">{id.label}:</dt> <dt class="card-metadata-label">{id.label}:</dt>
<dd class="min-w-0 break-all"> <dd class="card-metadata-value">
{#if id.link} {#if id.link}
<button <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" 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";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { removeMetadataFromContent } from "$lib/utils/asciidoc_metadata"; import { removeMetadataFromContent } from "$lib/utils/asciidoc_metadata";
import { build30040EventSet } from "$lib/utils/event_input_utils"; 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 * Converts TagData array to NDK-compatible format
*/ */
function convertTagsToNDKFormat(tags: TagData[]): string[][] { function convertTagsToNDKFormat(tags: TagData[]): string[][] {
return tags return tags
.filter(tag => tag.key.trim() !== "") .filter((tag) => tag.key.trim() !== "")
.map(tag => [tag.key, ...tag.values]); .map((tag) => [tag.key, ...tag.values]);
} }
/** /**
* Publishes an event to relays * 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) { if (!ndk) {
return { success: false, error: "NDK context not available" }; return { success: false, error: "NDK context not available" };
} }
const userState = get(userStore); const userState = get(userStore);
const pubkey = userState.pubkey; const pubkey = userState.pubkey;
if (!pubkey) { if (!pubkey) {
return { success: false, error: "User not logged in." }; return { success: false, error: "User not logged in." };
} }
const pubkeyString = String(pubkey); const pubkeyString = String(pubkey);
if (!/^[a-fA-F0-9]{64}$/.test(pubkeyString)) { 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 }; const baseEvent = { pubkey: pubkeyString, created_at: eventData.createdAt };
@ -56,48 +68,56 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
if (Number(eventData.kind) === 30040) { if (Number(eventData.kind) === 30040) {
console.log("=== 30040 EVENT CREATION START ==="); console.log("=== 30040 EVENT CREATION START ===");
console.log("Creating 30040 event set with content:", eventData.content); console.log("Creating 30040 event set with content:", eventData.content);
try { try {
// Get the current d and title values from the UI // Get the current d and title values from the UI
const dTagValue = tags.find(tag => tag.key === "d")?.values[0] || ""; const dTagValue = tags.find((tag) => tag.key === "d")?.values[0] || "";
const titleTagValue = tags.find(tag => tag.key === "title")?.values[0] || ""; const titleTagValue = tags.find((tag) =>
tag.key === "title"
)?.values[0] || "";
// Convert multi-value tags to the format expected by build30040EventSet // Convert multi-value tags to the format expected by build30040EventSet
// Filter out d and title tags since we'll add them manually // Filter out d and title tags since we'll add them manually
const compatibleTags: [string, string][] = tags const compatibleTags: [string, string][] = tags
.filter(tag => tag.key.trim() !== "" && tag.key !== "d" && tag.key !== "title") .filter((tag) =>
.map(tag => [tag.key, tag.values[0] || ""] as [string, string]); tag.key.trim() !== "" && tag.key !== "d" && tag.key !== "title"
)
.map((tag) => [tag.key, tag.values[0] || ""] as [string, string]);
const { indexEvent, sectionEvents } = build30040EventSet( const { indexEvent, sectionEvents } = build30040EventSet(
eventData.content, eventData.content,
compatibleTags, compatibleTags,
baseEvent, baseEvent,
ndk, ndk,
); );
// Override the d and title tags with the UI values if they exist // 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) { if (dTagValue) {
finalTags.push(["d", dTagValue]); finalTags.push(["d", dTagValue]);
} }
if (titleTagValue) { if (titleTagValue) {
finalTags.push(["title", titleTagValue]); finalTags.push(["title", titleTagValue]);
} }
// Update the index event with the correct tags // Update the index event with the correct tags
indexEvent.tags = finalTags; indexEvent.tags = finalTags;
console.log("Index event:", indexEvent); console.log("Index event:", indexEvent);
console.log("Section events:", sectionEvents); console.log("Section events:", sectionEvents);
// Publish all 30041 section events first, then the 30040 index event // Publish all 30041 section events first, then the 30040 index event
events = [...sectionEvents, indexEvent]; events = [...sectionEvents, indexEvent];
console.log("Total events to publish:", events.length); console.log("Total events to publish:", events.length);
console.log("=== 30040 EVENT CREATION END ==="); console.log("=== 30040 EVENT CREATION END ===");
} catch (error) { } catch (error) {
console.error("Error in build30040EventSet:", error); console.error("Error in build30040EventSet:", error);
return { return {
success: false, success: false,
error: `Failed to build 30040 event set: ${error instanceof Error ? error.message : "Unknown error"}` error: `Failed to build 30040 event set: ${
error instanceof Error ? error.message : "Unknown error"
}`,
}; };
} }
} else { } else {
@ -109,7 +129,7 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
if (eventData.kind === 30040 || eventData.kind === 30041) { if (eventData.kind === 30040 || eventData.kind === 30041) {
finalContent = removeMetadataFromContent(eventData.content); finalContent = removeMetadataFromContent(eventData.content);
} }
// Prefix Nostr addresses before publishing // Prefix Nostr addresses before publishing
const prefixedContent = prefixNostrAddresses(finalContent); const prefixedContent = prefixNostrAddresses(finalContent);
@ -150,7 +170,7 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
tags: event.tags.map((tag) => tag.map(String)), tags: event.tags.map((tag) => tag.map(String)),
content: String(event.content), content: String(event.content),
}; };
if ( if (
typeof window !== "undefined" && typeof window !== "undefined" &&
window.nostr && window.nostr &&
@ -178,12 +198,15 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
...get(activeOutboxRelays), ...get(activeOutboxRelays),
...get(activeInboxRelays), ...get(activeInboxRelays),
]; ];
console.log("publishEvent: Publishing to relays:", relays); console.log("publishEvent: Publishing to relays:", relays);
console.log("publishEvent: Anonymous relays:", anonymousRelays); 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)); console.log("publishEvent: Active inbox relays:", get(activeInboxRelays));
let published = false; let published = false;
for (const relayUrl of relays) { for (const relayUrl of relays) {
@ -234,18 +257,20 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
} }
} catch (signError) { } catch (signError) {
console.error("Error signing/publishing event:", signError); console.error("Error signing/publishing event:", signError);
return { return {
success: false, success: false,
error: `Failed to sign event: ${signError instanceof Error ? signError.message : "Unknown error"}` error: `Failed to sign event: ${
signError instanceof Error ? signError.message : "Unknown error"
}`,
}; };
} }
} }
if (atLeastOne) { if (atLeastOne) {
return { return {
success: true, success: true,
eventId: lastEventId || undefined, eventId: lastEventId || undefined,
relays: relaysPublished relays: relaysPublished,
}; };
} else { } else {
return { success: false, error: "Failed to publish to any relay." }; return { success: false, error: "Failed to publish to any relay." };
@ -255,16 +280,22 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
/** /**
* Loads an event by its hex ID * 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) { if (!ndk) {
throw new Error("NDK context not available"); throw new Error("NDK context not available");
} }
console.log("loadEvent: Starting search for event ID:", eventId); 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 inbox relays:", get(activeInboxRelays));
console.log("loadEvent: Active outbox relays:", get(activeOutboxRelays)); console.log("loadEvent: Active outbox relays:", get(activeOutboxRelays));
const foundEvent = await fetchEventWithFallback(ndk, eventId, 10000); const foundEvent = await fetchEventWithFallback(ndk, eventId, 10000);
if (foundEvent) { if (foundEvent) {
@ -279,7 +310,7 @@ export async function loadEvent(ndk: any, eventId: string): Promise<LoadEventRes
// Convert NDK tags format to our format // Convert NDK tags format to our format
const tags: TagData[] = foundEvent.tags.map((tag: string[]) => ({ const tags: TagData[] = foundEvent.tags.map((tag: string[]) => ({
key: tag[0] || "", key: tag[0] || "",
values: tag.slice(1) values: tag.slice(1),
})); }));
return { eventData, tags }; return { eventData, tags };

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

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

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

@ -6,22 +6,25 @@ import { get } from "svelte/store";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import type { EventData, TagData, ValidationResult } from "./types"; import type { EventData, TagData, ValidationResult } from "./types";
import { import {
validateNotAsciidoc,
validateAsciiDoc,
validate30040EventSet, validate30040EventSet,
validateAsciiDoc,
validateNotAsciidoc,
} from "$lib/utils/event_input_utils"; } from "$lib/utils/event_input_utils";
/** /**
* Validates an event and its tags * 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 userState = get(userStore);
const pubkey = userState.pubkey; const pubkey = userState.pubkey;
if (!pubkey) { if (!pubkey) {
return { valid: false, reason: "Not logged in." }; return { valid: false, reason: "Not logged in." };
} }
// Content validation - 30040 events don't require content // Content validation - 30040 events don't require content
if (eventData.kind !== 30040 && !eventData.content.trim()) { if (eventData.kind !== 30040 && !eventData.content.trim()) {
return { valid: false, reason: "Content required." }; return { valid: false, reason: "Content required." };
@ -32,25 +35,27 @@ export function validateEvent(eventData: EventData, tags: TagData[]): Validation
const v = validateNotAsciidoc(eventData.content); const v = validateNotAsciidoc(eventData.content);
if (!v.valid) return v; if (!v.valid) return v;
} }
if (eventData.kind === 30040) { if (eventData.kind === 30040) {
// Check for required tags // Check for required tags
const versionTag = tags.find(t => t.key === "version"); const versionTag = tags.find((t) => t.key === "version");
const dTag = tags.find(t => t.key === "d"); const dTag = tags.find((t) => t.key === "d");
const titleTag = tags.find(t => t.key === "title"); const titleTag = tags.find((t) => t.key === "title");
if (!versionTag || !versionTag.values[0] || versionTag.values[0].trim() === "") { if (
!versionTag || !versionTag.values[0] || versionTag.values[0].trim() === ""
) {
return { valid: false, reason: "30040 events require a 'version' tag." }; return { valid: false, reason: "30040 events require a 'version' tag." };
} }
if (!dTag || !dTag.values[0] || dTag.values[0].trim() === "") { if (!dTag || !dTag.values[0] || dTag.values[0].trim() === "") {
return { valid: false, reason: "30040 events require a 'd' tag." }; return { valid: false, reason: "30040 events require a 'd' tag." };
} }
if (!titleTag || !titleTag.values[0] || titleTag.values[0].trim() === "") { if (!titleTag || !titleTag.values[0] || titleTag.values[0].trim() === "") {
return { valid: false, reason: "30040 events require a 'title' tag." }; return { valid: false, reason: "30040 events require a 'title' tag." };
} }
// Validate content format if present // Validate content format if present
if (eventData.content.trim()) { if (eventData.content.trim()) {
const v = validate30040EventSet(eventData.content); const v = validate30040EventSet(eventData.content);
@ -58,7 +63,7 @@ export function validateEvent(eventData: EventData, tags: TagData[]): Validation
if (v.warning) return { valid: true, warning: v.warning }; if (v.warning) return { valid: true, warning: v.warning };
} }
} }
if (eventData.kind === 30041 || eventData.kind === 30818) { if (eventData.kind === 30041 || eventData.kind === 30818) {
const v = validateAsciiDoc(eventData.content); const v = validateAsciiDoc(eventData.content);
if (!v.valid) return v; if (!v.valid) return v;
@ -86,5 +91,5 @@ export function isValidTagKey(key: string): boolean {
* Validates that a tag has at least one value * Validates that a tag has at least one value
*/ */
export function isValidTag(tag: TagData): boolean { 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 @@
SidebarGroup, SidebarGroup,
SidebarWrapper, SidebarWrapper,
Heading, Heading,
CloseButton, CloseButton, uiHelpers
} from "flowbite-svelte"; } from "flowbite-svelte";
import { getContext, onDestroy, onMount } from "svelte"; import { getContext, onDestroy, onMount } from "svelte";
import { import {
@ -23,6 +23,7 @@
import type { SveltePublicationTree } from "./svelte_publication_tree.svelte"; import type { SveltePublicationTree } from "./svelte_publication_tree.svelte";
import TableOfContents from "./TableOfContents.svelte"; import TableOfContents from "./TableOfContents.svelte";
import type { TableOfContents as TocType } from "./table_of_contents.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<{ let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{
rootAddress: string; rootAddress: string;
@ -143,6 +144,10 @@
let currentBlogEvent: null | NDKEvent = $state(null); let currentBlogEvent: null | NDKEvent = $state(null);
const isLeaf = $derived(indexEvent.kind === 30041); const isLeaf = $derived(indexEvent.kind === 30041);
const tocSidebarUi = uiHelpers();
const closeTocSidebar = tocSidebarUi.close;
const isTocOpen = $state($publicationColumnVisibility.toc);
function isInnerActive() { function isInnerActive() {
return currentBlog !== null && $publicationColumnVisibility.inner; return currentBlog !== null && $publicationColumnVisibility.inner;
} }
@ -247,172 +252,178 @@
// #endregion // #endregion
</script> </script>
<!-- Table of contents --> <!-- Add gap & items-start so sticky sidebars size correctly -->
{#if publicationType !== "blog" || !isLeaf} <div class="relative grid gap-4 items-start grid-cols-[1fr_3fr_1fr] grid-rows-[auto_1fr]">
{#if $publicationColumnVisibility.toc} <!-- Full-width ArticleNav row -->
<Sidebar <ArticleNav
activeUrl={`#${activeAddress ?? ""}`} publicationType={publicationType}
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" rootId={indexEvent.id}
activeClass="flex items-center p-2 bg-primary-50 dark:bg-primary-800 p-2 rounded-lg" indexEvent={indexEvent}
nonActiveClass="flex items-center p-2 hover:bg-primary-50 dark:hover:bg-primary-800 p-2 rounded-lg" />
> <!-- Three-column row -->
<CloseButton <div class="contents">
onclick={closeToc} <!-- Table of contents -->
class="btn-leather absolute top-4 right-4 hover:bg-primary-50 dark:hover:bg-primary-800" <div class="mt-[70px] relative {$publicationColumnVisibility.toc ? 'w-64' : 'w-auto'}">
/> {#if publicationType !== "blog" && !isLeaf}
<TableOfContents {#if $publicationColumnVisibility.toc}
{rootAddress} <Sidebar
{toc} class="z-10 ml-2 fixed top-[162px] max-h-[calc(100vh-165px)] overflow-y-auto dark:bg-primary-900 bg-primary-50 rounded"
depth={2} activeUrl={`#${activeAddress ?? ""}`}
onSectionFocused={(address: string) => classes={{
publicationTree.setBookmark(address)} div: 'dark:bg-primary-900 bg-primary-50',
onLoadMore={() => { active: 'bg-primary-100 dark:bg-primary-800 p-2 rounded-lg',
if (!isLoading && !isDone && publicationTree) { nonactive: 'bg-primary-50 dark:bg-primary-900',
loadMore(4);
}
}} }}
/> >
</Sidebar> <SidebarWrapper>
{/if} <CloseButton color="secondary" class="m-2 dark:text-primary-100" onclick={closeToc} ></CloseButton>
{/if} <TableOfContents
{rootAddress}
<!-- Default publications --> {toc}
{#if $publicationColumnVisibility.main} depth={2}
<div class="flex flex-col p-4 space-y-4 overflow-auto max-w-2xl flex-grow-2"> onSectionFocused={(address: string) => publicationTree.setBookmark(address)}
<div onLoadMore={() => {
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border" if (!isLoading && !isDone && publicationTree) {
> loadMore(4);
<Details event={indexEvent} /> }
</div> }}
<!-- Publication sections/cards --> />
{#each leaves as leaf, i}
{#if leaf == null} </SidebarWrapper>
<Alert class="flex space-x-2"> </Sidebar>
<ExclamationCircleOutline class="w-5 h-5" /> {/if}
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} {/if}
</div> </div>
</div> <div class="mt-[70px]">
{/if} <!-- Default publications -->
{#if $publicationColumnVisibility.main}
<!-- Blog list --> <!-- Remove overflow-auto so page scroll drives it -->
{#if $publicationColumnVisibility.blog} <div class="flex flex-col p-4 space-y-4 max-w-3xl flex-grow-2 mx-auto">
<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 <div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border" 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>
</div>
{/if}
<PublicationSection <!-- Blog list -->
{rootAddress} {#if $publicationColumnVisibility.blog}
{leaves} <!-- Remove overflow-auto -->
address={leaf.tagAddress()} <div
{publicationTree} class={`flex flex-col p-4 space-y-4 max-w-xl flex-grow-1 ${isInnerActive() ? "discreet" : ""}`}
{toc} >
ref={(el) => setLastElementRef(el, i)} <div
/> class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
<Card class="ArticleBox !hidden card-leather min-w-full mt-4">
<Interactions rootId={currentBlog} />
</Card>
{/if}
{/each}
</div>
{/key}
{/if}
{#if $publicationColumnVisibility.discussion}
<Sidebar class="sidebar-leather right-0 md:!pl-8">
<SidebarWrapper>
<SidebarGroup class="sidebar-group-leather">
<div class="flex justify-between items-baseline">
<Heading tag="h1" class="h-leather !text-lg">Discussion</Heading>
<Button
class="btn-leather hidden sm:flex z-30 !p-1 bg-primary-50 dark:bg-gray-800"
outline
onclick={closeDiscussion}
> >
<CloseOutline /> <Details event={indexEvent} />
</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>
<!-- List blog excerpts -->
{#each leaves as leaf, i}
{#if leaf}
<BlogHeader
rootId={leaf.tagAddress()}
event={leaf}
onBlogUpdate={loadBlog}
active={!isInnerActive()}
/>
{/if}
{/each}
</div> </div>
</SidebarGroup> {/if}
</SidebarWrapper>
</Sidebar> {#if isInnerActive()}
{/if} {#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 @@
> >
{#if loading && eventsInView.length === 0} {#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id} {#each getSkeletonIds() as id}
<Skeleton divClass="skeleton-leather w-full" size="lg" /> <Skeleton classes={{wrapper: "skeleton-leather w-full"}} size="lg" />
{/each} {/each}
{:else if eventsInView.length > 0} {:else if eventsInView.length > 0}
{#each eventsInView as event} {#each eventsInView as event}

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

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

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

@ -158,20 +158,21 @@
{@const expanded = toc.expandedMap.get(address) ?? false} {@const expanded = toc.expandedMap.get(address) ?? false}
{@const isLeaf = toc.leaves.has(address)} {@const isLeaf = toc.leaves.has(address)}
{@const isVisible = isEntryVisible(address)} {@const isVisible = isEntryVisible(address)}
{@const isLastEntry = index === entries.length - 1}
{#if isLeaf} {#if isLeaf}
<SidebarItem <SidebarItem
label={entry.title} label={entry.title}
href={`#${address}`} href={`#${address}`}
spanClass="px-2 text-ellipsis" spanClass="px-2 text-ellipsis"
class={`${isVisible ? "toc-highlight" : ""} ${isLastEntry ? "pb-4" : ""}`} class={`${isVisible ? "toc-highlight" : ""} `}
onclick={() => handleSectionClick(address)} onclick={() => handleSectionClick(address)}
/> >
<!-- Empty for now - could add icons or labels here in the future -->
</SidebarItem>
{:else} {:else}
{@const childDepth = depth + 1} {@const childDepth = depth + 1}
<SidebarDropdownWrapper <SidebarDropdownWrapper
label={entry.title} 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)} bind:isOpen={() => expanded, (open) => setEntryExpanded(address, open)}
> >
<Self rootAddress={address} depth={childDepth} {toc} {onSectionFocused} {onLoadMore} /> <Self rootAddress={address} depth={childDepth} {toc} {onSectionFocused} {onLoadMore} />

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

@ -6,7 +6,7 @@
GlobeOutline, GlobeOutline,
ChartOutline, ChartOutline,
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import { Button } from "flowbite-svelte"; import { Button, P } from "flowbite-svelte";
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
@ -152,7 +152,7 @@
</script> </script>
<nav <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-0'
: '-translate-y-full'}" : '-translate-y-full'}"
> >
@ -191,14 +191,14 @@
{/if} {/if}
</div> </div>
<div class="flex flex-col flex-grow text justify-center items-center"> <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> <b class="text-nowrap">{title}</b>
</p> </P>
<p> <P>
<span class="whitespace-nowrap" <span class="whitespace-nowrap"
>by {@render userBadge(pubkey, author, ndk)}</span >by {@render userBadge(pubkey, author, ndk)}</span
> >
</p> </P>
</div> </div>
<div class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8"> <div class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8">
{#if $publicationColumnVisibility.inner} {#if $publicationColumnVisibility.inner}

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

@ -134,7 +134,7 @@
<Button <Button
type="button" type="button"
id="dots-{event.id}" 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" color="none"
data-popover-target="popover-actions" data-popover-target="popover-actions"
> >
@ -203,7 +203,7 @@
</div> </div>
{/if} {/if}
<div class="flex flex-col col space-y-5 justify-center align-middle"> <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"> <h2 class="text-base font-bold">
by by
{#if originalAuthor} {#if originalAuthor}

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

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Button } from "flowbite-svelte";
import { import {
ClipboardCheckOutline, ClipboardCheckOutline,
ClipboardCleanOutline, ClipboardCleanOutline,
@ -42,15 +43,12 @@
} }
</script> </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} {#if copied}
<ClipboardCheckOutline class="inline mr-2" /> Copied! <ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else} {:else}
{#if icon === ClipboardCleanOutline} {@const TheIcon = icon}
<ClipboardCleanOutline class="inline mr-2" /> <TheIcon class="inline { displayText !== '' ? 'mr-2' : ''}" />
{:else if icon === ClipboardCheckOutline}
<ClipboardCheckOutline class="inline mr-2" />
{/if}
{displayText} {displayText}
{/if} {/if}
</button> </button>

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

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

80
src/lib/data_structures/publication_tree.ts

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

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

@ -258,7 +258,7 @@
id="tag-type-select" id="tag-type-select"
bind:value={selectedTagType} bind:value={selectedTagType}
onchange={onTagSettingsChange} 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="t">Hashtags</option>
<option value="author">Authors</option> <option value="author">Authors</option>

9
src/lib/nostr/event.ts

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

34
src/lib/stores/techStore.ts

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

3
src/lib/styles/cva.ts

@ -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 @@
import { unifiedProfileCache } from './npubCache'; import { unifiedProfileCache } from "./npubCache";
import { searchCache } from './searchCache'; import { searchCache } from "./searchCache";
import { indexEventCache } from './indexEventCache'; import { indexEventCache } from "./indexEventCache";
import { clearRelaySetCache } from '../ndk'; import { clearRelaySetCache } from "../ndk";
/** /**
* Clears all application caches * Clears all application caches
* *
* Clears: * Clears:
* - unifiedProfileCache (profile metadata) * - unifiedProfileCache (profile metadata)
* - searchCache (search results) * - searchCache (search results)
@ -13,18 +13,18 @@ import { clearRelaySetCache } from '../ndk';
* - relaySetCache (relay configuration) * - relaySetCache (relay configuration)
*/ */
export function clearAllCaches(): void { export function clearAllCaches(): void {
console.log('[CacheManager] Clearing all application caches...'); console.log("[CacheManager] Clearing all application caches...");
// Clear in-memory caches // Clear in-memory caches
unifiedProfileCache.clear(); unifiedProfileCache.clear();
searchCache.clear(); searchCache.clear();
indexEventCache.clear(); indexEventCache.clear();
clearRelaySetCache(); clearRelaySetCache();
// Clear localStorage caches // Clear localStorage caches
clearLocalStorageCaches(); clearLocalStorageCaches();
console.log('[CacheManager] All caches cleared successfully'); console.log("[CacheManager] All caches cleared successfully");
} }
/** /**
@ -32,41 +32,43 @@ export function clearAllCaches(): void {
* This is useful when profile pictures or metadata are stale * This is useful when profile pictures or metadata are stale
*/ */
export function clearProfileCaches(): void { export function clearProfileCaches(): void {
console.log('[CacheManager] Clearing profile-specific caches...'); console.log("[CacheManager] Clearing profile-specific caches...");
// Clear unified profile cache // Clear unified profile cache
unifiedProfileCache.clear(); unifiedProfileCache.clear();
// Clear profile-related search results // Clear profile-related search results
// Note: searchCache doesn't have a way to clear specific types, so we clear all // 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 // This is acceptable since profile searches are the most common
searchCache.clear(); searchCache.clear();
console.log('[CacheManager] Profile caches cleared successfully'); console.log("[CacheManager] Profile caches cleared successfully");
} }
/** /**
* Clears localStorage caches * Clears localStorage caches
*/ */
function clearLocalStorageCaches(): void { function clearLocalStorageCaches(): void {
if (typeof window === 'undefined') return; if (typeof window === "undefined") return;
const keysToRemove: string[] = []; const keysToRemove: string[] = [];
// Find all localStorage keys that start with 'alexandria' // Find all localStorage keys that start with 'alexandria'
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
if (key && key.startsWith('alexandria')) { if (key && key.startsWith("alexandria")) {
keysToRemove.push(key); keysToRemove.push(key);
} }
} }
// Remove the keys // Remove the keys
keysToRemove.forEach(key => { keysToRemove.forEach((key) => {
localStorage.removeItem(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 {
// Event Construction // Event Construction
// ========================= // =========================
/** /**
* Builds a set of events for a 30040 publication: one 30040 index event and one 30041 event per section. * 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). * Each 30041 gets a d-tag (normalized section header) and a title tag (raw section header).
@ -263,31 +262,33 @@ export function build30040EventSet(
console.log("Index event:", { documentTitle, indexDTag }); console.log("Index event:", { documentTitle, indexDTag });
// Create section events with their metadata // Create section events with their metadata
const sectionEvents: NDKEvent[] = parsed.sections.map((section: any, i: number) => { const sectionEvents: NDKEvent[] = parsed.sections.map(
const sectionDTag = `${indexDTag}-${normalizeDTagValue(section.title)}`; (section: any, i: number) => {
console.log(`Creating section ${i}:`, { const sectionDTag = `${indexDTag}-${normalizeDTagValue(section.title)}`;
title: section.title, console.log(`Creating section ${i}:`, {
dTag: sectionDTag, title: section.title,
content: section.content, dTag: sectionDTag,
metadata: section.metadata, content: section.content,
}); metadata: section.metadata,
});
// Convert section metadata to tags
const sectionMetadataTags = metadataToTags(section.metadata); // Convert section metadata to tags
const sectionMetadataTags = metadataToTags(section.metadata);
return new NDKEventClass(ndk, {
kind: 30041, return new NDKEventClass(ndk, {
content: section.content, kind: 30041,
tags: [ content: section.content,
...tags, tags: [
...sectionMetadataTags, ...tags,
["d", sectionDTag], ...sectionMetadataTags,
["title", section.title], ["d", sectionDTag],
], ["title", section.title],
pubkey: baseEvent.pubkey, ],
created_at: baseEvent.created_at, pubkey: baseEvent.pubkey,
}); created_at: baseEvent.created_at,
}); });
},
);
// Create proper a tags with format: kind:pubkey:d-tag // Create proper a tags with format: kind:pubkey:d-tag
const aTags = sectionEvents.map((event) => { const aTags = sectionEvents.map((event) => {

5
src/lib/utils/event_search.ts

@ -10,7 +10,10 @@ import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
/** /**
* Search for a single event by ID or filter * 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) { if (!ndk) {
console.warn("[Search] No NDK instance available"); console.warn("[Search] No NDK instance available");
return null; return null;

2
src/lib/utils/image_utils.ts

@ -21,4 +21,4 @@ export function generateDarkPastelColor(seed: string): string {
return `#${r.toString(16).padStart(2, "0")}${ return `#${r.toString(16).padStart(2, "0")}${
g.toString(16).padStart(2, "0") g.toString(16).padStart(2, "0")
}${b.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 {
// Check if the code content contains math expressions // Check if the code content contains math expressions
const hasInlineMath = /\$((?:[^$\\]|\\.)*?)\$/.test(codeContent); const hasInlineMath = /\$((?:[^$\\]|\\.)*?)\$/.test(codeContent);
const hasDisplayMath = /\$\$[\s\S]*?\$\$/.test(codeContent); const hasDisplayMath = /\$\$[\s\S]*?\$\$/.test(codeContent);
if (!hasInlineMath && !hasDisplayMath) { if (!hasInlineMath && !hasDisplayMath) {
// No math found, return the original inline code // No math found, return the original inline code
return match; return match;
} }
// Process display math ($$...$$) first to avoid conflicts with inline math // Process display math ($$...$$) first to avoid conflicts with inline math
let processedContent = codeContent.replace(/\$\$([\s\S]*?)\$\$/g, (mathMatch: string, mathContent: string) => { let processedContent = codeContent.replace(
// Skip empty math expressions /\$\$([\s\S]*?)\$\$/g,
if (!mathContent.trim()) { (mathMatch: string, mathContent: string) => {
return mathMatch; // Skip empty math expressions
} if (!mathContent.trim()) {
return `<span class="math-display">\\[${mathContent}\\]</span>`; 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 // Process inline math ($...$) after display math
if (!mathContent.trim()) { // Use a more sophisticated regex that handles escaped dollar signs
return mathMatch; processedContent = processedContent.replace(
} /\$((?:[^$\\]|\\.)*?)\$/g,
return `<span class="math-inline">\\(${mathContent}\\)</span>`; (mathMatch: string, mathContent: string) => {
}); // Skip empty math expressions
if (!mathContent.trim()) {
return mathMatch;
}
return `<span class="math-inline">\\(${mathContent}\\)</span>`;
},
);
return `\`${processedContent}\``; return `\`${processedContent}\``;
}); });
} }

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

@ -1,14 +1,12 @@
import NDK from "@nostr-dev-kit/ndk"; import NDK from "@nostr-dev-kit/ndk";
import { import {
processBasicFormatting, processBasicFormatting,
processBlockquotes, processBlockquotes,
processEmojiShortcodes, processEmojiShortcodes,
processNostrIdentifiersInText, processNostrIdentifiersInText,
processWikilinks, processWikilinks,
} from "./markupUtils.ts"; } from "./markupUtils.ts";
export function preProcessBasicMarkup(text: string): string { export function preProcessBasicMarkup(text: string): string {
try { try {
// Process basic text formatting first // Process basic text formatting first
@ -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 { try {
// Process Nostr identifiers last // Process Nostr identifiers last
let processedText = await processNostrIdentifiersInText(text, ndk); let processedText = await processNostrIdentifiersInText(text, ndk);
@ -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 ""; if (!text) return "";
try { try {

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

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

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

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

22
src/lib/utils/nostrUtils.ts

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

99
src/lib/utils/npubCache.ts

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

53
src/lib/utils/profile_search.ts

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

366
src/lib/utils/subscription_search.ts

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

113
src/lib/utils/user_lists.ts

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

26
src/routes/+layout.svelte

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

4
src/routes/+layout.ts

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

10
src/routes/+page.svelte

@ -38,14 +38,12 @@
function confirmClearSearch() { function confirmClearSearch() {
searchQuery = ""; searchQuery = "";
showClearSearchModal = false; showClearSearchModal = false;
// Force the state update by reassigning showOnlyMyPublications = pendingCheckboxState;
showOnlyMyPublications = false;
showOnlyMyPublications = true;
} }
function cancelClearSearch() { function cancelClearSearch() {
// Don't change showOnlyMyPublications - it should remain as it was
showClearSearchModal = false; showClearSearchModal = false;
pendingCheckboxState = false;
} }
// AI-NOTE: Removed automatic search clearing - now handled with confirmation dialog // AI-NOTE: Removed automatic search clearing - now handled with confirmation dialog
@ -63,7 +61,7 @@
</div> </div>
{#if eventCount.total > 0} {#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> <span>Showing {eventCount.displayed} of {eventCount.total} events.</span>
<!-- AI-NOTE: Show filter checkbox only when user is logged in --> <!-- AI-NOTE: Show filter checkbox only when user is logged in -->
@ -71,7 +69,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={showOnlyMyPublications} bind:checked={showOnlyMyPublications}
onchange={handleCheckboxChange} onchange={handleCheckboxChange}
id="show-my-publications" 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" 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 @@
<P class="note-leather mb-6" <P class="note-leather mb-6"
>The page you are looking for does not exist or has been moved.</P >The page you are looking for does not exist or has been moved.</P
> >
<div class="flex space-x-4"> <div class="flex flex-row space-x-4">
<Button class="btn-leather !w-fit" onclick={() => goto("/")} <Button class="btn-leather !w-fit !mb-0" onclick={() => goto("/")}
>Return to Home</Button >Return to Home</Button
> >
<Button <Button

15
src/routes/about/+page.svelte

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

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

@ -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 @@
<script lang="ts"> <script lang="ts">
import { import { Heading, P, A } from "flowbite-svelte";
Heading,
P,
A,
Button,
Label,
Textarea,
Input,
Modal,
} from "flowbite-svelte";
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { anonymousRelays } from "$lib/consts"; import { anonymousRelays } from "$lib/consts";
@ -20,6 +11,8 @@
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { getMimeTags } from "$lib/utils/mime"; import { getMimeTags } from "$lib/utils/mime";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import AMarkupForm from "$lib/a/forms/AMarkupForm.svelte";
import { AAlert } from "$lib/a";
const ndk = getNdkContext(); const ndk = getNdkContext();
@ -33,8 +26,6 @@
subject = ""; subject = "";
content = ""; content = "";
submissionError = ""; submissionError = "";
isExpanded = false;
activeTab = "write";
} }
let subject = $state(""); let subject = $state("");
@ -46,9 +37,6 @@
let submittedEvent = $state<NDKEvent | null>(null); let submittedEvent = $state<NDKEvent | null>(null);
let issueLink = $state(""); let issueLink = $state("");
let successfulRelays = $state<string[]>([]); let successfulRelays = $state<string[]>([]);
let isExpanded = $state(false);
let activeTab = $state("write");
let showConfirmDialog = $state(false);
// Store form data when user needs to login // Store form data when user needs to login
let savedFormData = { let savedFormData = {
@ -82,45 +70,28 @@
return url.replace(/\/+$/, ""); return url.replace(/\/+$/, "");
} }
function toggleSize() { /**
isExpanded = !isExpanded; * Handle form submission from AMarkupForm
} */
async function handleFormSubmit(newSubject: string, newContent: string) {
async function handleSubmit(e: Event) { submissionError = "";
// Prevent form submission subject = newSubject;
e.preventDefault(); content = newContent;
if (!subject || !content) { if (!subject || !content) {
submissionError = "Please fill in all fields"; submissionError = "Please fill in all fields";
return; return;
} }
// Check if user is logged in
if (!user.signedIn) { if (!user.signedIn) {
// Save form data savedFormData = { subject, content };
savedFormData = {
subject,
content,
};
// Show login modal
showLoginModal = true; showLoginModal = true;
return; return;
} }
// Show confirmation dialog
showConfirmDialog = true;
}
async function confirmSubmit() {
showConfirmDialog = false;
await submitIssue(); await submitIssue();
} }
function cancelSubmit() {
showConfirmDialog = false;
}
/** /**
* Publish event to relays with retry logic * Publish event to relays with retry logic
*/ */
@ -290,13 +261,10 @@
}); });
</script> </script>
<div class="w-full flex justify-center"> <div class="w-full max-w-3xl flex flex-col self-center mb-3 px-2">
<main
class="main-leather flex flex-col space-y-6 max-w-3xl w-full my-6 px-6 sm:px-4"
>
<Heading tag="h1" class="h-leather mb-2">Contact GitCitadel</Heading> <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 Make sure that you follow us on <A
href="https://github.com/ShadowySupercode/gitcitadel" href="https://github.com/ShadowySupercode/gitcitadel"
target="_blank">GitHub</A target="_blank">GitHub</A
@ -318,280 +286,121 @@
<Heading tag="h2" class="h-leather mt-4 mb-2">Submit an issue</Heading> <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 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 the top-right of the window), then you can use the form, below, to submit
an issue, that will appear on our repo page. an issue, that will appear on our repo page.
</P> </P>
<form class="space-y-4" onsubmit={handleSubmit} autocomplete="off"> <AMarkupForm
<div> bind:subject={subject}
<Label for="subject" class="mb-2">Subject</Label> bind:content={content}
<Input isSubmitting={isSubmitting}
id="subject" onSubmit={handleFormSubmit}
class="w-full bg-white dark:bg-gray-800" />
placeholder="Issue subject"
bind:value={subject} {#if submissionSuccess && submittedEvent}
required <div
autofocus 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"
</div> >
<!-- Close button -->
<div class="relative"> <button
<Label for="content" class="mb-2">Description</Label> class="absolute top-2 right-2 text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
<div onclick={closeSuccessMessage}
class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded aria-label="Close"
? 'h-[800px]'
: 'h-[200px]'} transition-all duration-200 sm:w-[95vw] md:w-full"
> >
<div class="h-full flex flex-col"> <svg
<div class="w-5 h-5"
class="border-b border-gray-300 dark:border-gray-600 rounded-t-lg" fill="none"
> stroke="currentColor"
<ul viewBox="0 0 24 24"
class="flex flex-wrap -mb-px text-sm font-medium text-center" xmlns="http://www.w3.org/2000/svg"
role="tablist" >
> <path
<li class="mr-2" role="presentation"> stroke-linecap="round"
<button stroke-linejoin="round"
type="button" stroke-width="2"
class="inline-block p-4 rounded-t-lg {activeTab === 'write' d="M6 18L18 6M6 6l12 12"
? 'border-b-2 border-primary-600 text-primary-600' ></path>
: 'hover:text-gray-600 hover:border-gray-300'}" </svg>
onclick={() => (activeTab = "write")} </button>
role="tab"
> <div class="flex items-center mb-3">
Write <svg
</button> class="w-5 h-5 mr-2 text-success-700 dark:text-success-300"
</li> fill="currentColor"
<li role="presentation"> viewBox="0 0 20 20"
<button xmlns="http://www.w3.org/2000/svg"
type="button" >
class="inline-block p-4 rounded-t-lg {activeTab === <path
'preview' fill-rule="evenodd"
? 'border-b-2 border-primary-600 text-primary-600' 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"
: 'hover:text-gray-600 hover:border-gray-300'}" clip-rule="evenodd"
onclick={() => (activeTab = "preview")} ></path>
role="tab" </svg>
> <span class="font-medium text-success-800 dark:text-success-200"
Preview >Issue submitted successfully!</span
</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}
> >
{isExpanded ? "⌃" : "⌄"}
</Button>
</div> </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 <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" class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600"
role="alert"
> >
<!-- Close button --> <div class="mb-2">
<button <span class="font-semibold">Subject:</span>
class="absolute top-2 right-2 text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100" <span
onclick={closeSuccessMessage} >{submittedEvent.tags.find((t) => t[0] === "subject")?.[1] ||
aria-label="Close" "No subject"}</span
>
<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> </div>
<div>
<div <span class="font-semibold">Description:</span>
class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600" <div class="mt-1 note-leather max-h-[400px] overflow-y-auto">
> {#await parseAdvancedmarkup(submittedEvent.content)}
<div class="mb-2"> <p>Loading...</p>
<span class="font-semibold">Subject:</span> {:then html}
<span {@html html}
>{submittedEvent.tags.find((t) => t[0] === "subject")?.[1] || {:catch error}
"No subject"}</span <p class="text-red-500">
> Error rendering markup: {error.message}
</div> </p>
<div> {/await}
<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> </div>
</div> </div>
</div>
<!-- Display successful relays --> <div class="mb-3">
<div class="text-sm"> <span class="font-semibold">View your issue:</span>
<span class="font-semibold">Successfully published to relays:</span> <div class="mt-1">
<ul class="list-disc list-inside mt-1"> <A
{#each successfulRelays as relay} href={issueLink}
<li class="text-success-700 dark:text-success-300">{relay}</li> target="_blank"
{/each} class="hover:underline text-primary-600 dark:text-primary-500 break-all"
</ul> >
{issueLink}
</A>
</div> </div>
</div> </div>
{/if}
{#if submissionError} <!-- Display successful relays -->
<div <div class="text-sm">
class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" <span class="font-semibold">Successfully published to relays:</span>
role="alert" <ul class="list-disc list-inside mt-1">
> {#each successfulRelays as relay}
{submissionError} <li class="text-success-700 dark:text-success-300">{relay}</li>
{/each}
</ul>
</div> </div>
{/if} </div>
</form> {/if}
</main>
</div>
<!-- Confirmation Dialog --> {#if submissionError}
<Modal bind:open={showConfirmDialog} size="sm" autoclose={false} class="w-full"> <AAlert color="red">
<div class="text-center"> {submissionError}
<h3 class="mb-5 text-lg font-normal text-gray-700 dark:text-gray-300"> </AAlert>
Would you like to submit the issue? {/if}
</h3> </div>
<div class="flex justify-center gap-4">
<Button color="alternative" onclick={cancelSubmit}>Cancel</Button>
<Button color="primary" onclick={confirmSubmit}>Submit</Button>
</div>
</div>
</Modal>
<!-- Login Modal --> <!-- Login Modal -->
<LoginModal <LoginModal

92
src/routes/events/+page.svelte

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

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

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

113
src/routes/profile/+page.svelte

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

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

@ -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 @@
import type { LayoutServerLoad } from "./$types"; import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = ({ url }: { url: URL }) => { export const load: LayoutServerLoad = ({ url }: { url: URL }) => {
const currentUrl = `${url.origin}${url.pathname}`; const currentUrl = `${url.origin}${url.pathname}`;

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

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

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

@ -49,7 +49,8 @@ export const load: PageLoad = async (
// AI-NOTE: Return null for indexEvent during SSR or when fetch fails // AI-NOTE: Return null for indexEvent during SSR or when fetch fails
// The component will handle client-side loading and error states // 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 = { const result = {
publicationType, publicationType,

10
src/routes/start/+page.svelte

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

252
src/styles/a/cards.css

@ -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 @@
@layer components {
/* ========================================
Base Form Styles
======================================== */
}

5
src/styles/a/primitives.css

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

2
src/styles/base.css

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

10
src/styles/publications.css

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

64
src/theme-tokens.css

@ -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 @@
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
describe("Math Processing in Advanced Markup Parser", () => { describe("Math Processing in Advanced Markup Parser", () => {
it("should process inline math inside code blocks", async () => { 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); 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("Here is some inline math:");
expect(result).toContain("in a sentence."); expect(result).toContain("in a sentence.");
}); });
it("should process display math inside code blocks", async () => { 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); 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">Here is a display equation:</p>');
expect(result).toContain('<p class="my-4">This is after the 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 () => { 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); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">\\(\\alpha\\)</span>'); 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("Mixed math:");
expect(result).toContain("in one block."); expect(result).toContain("in one block.");
}); });
@ -33,34 +42,39 @@ describe("Math Processing in Advanced Markup Parser", () => {
it("should NOT process math outside of code blocks", async () => { 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 input = "This math $x^2 + y^2 = z^2$ should not be processed.";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain("$x^2 + y^2 = z^2$"); expect(result).toContain("$x^2 + y^2 = z^2$");
expect(result).not.toContain('<span class="math-inline">'); expect(result).not.toContain('<span class="math-inline">');
expect(result).not.toContain('<span class="math-display">'); expect(result).not.toContain('<span class="math-display">');
}); });
it("should NOT process display math outside of code blocks", async () => { 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); 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-inline">');
expect(result).not.toContain('<span class="math-display">'); expect(result).not.toContain('<span class="math-display">');
}); });
it("should handle code blocks without math normally", async () => { 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); const result = await parseAdvancedmarkup(input);
expect(result).toContain("`console.log('hello world')`"); expect(result).toContain("`console.log('hello world')`");
expect(result).not.toContain('<span class="math-inline">'); expect(result).not.toContain('<span class="math-inline">');
expect(result).not.toContain('<span class="math-display">'); expect(result).not.toContain('<span class="math-display">');
}); });
it("should handle complex math expressions with nested structures", async () => { 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); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-display">'); expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\begin{pmatrix}"); expect(result).toContain("\\begin{pmatrix}");
expect(result).toContain("\\end{pmatrix}"); expect(result).toContain("\\end{pmatrix}");
@ -68,25 +82,34 @@ describe("Math Processing in Advanced Markup Parser", () => {
}); });
it("should handle inline math with special characters", async () => { 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); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">\\(\\alpha, \\beta, \\gamma, \\delta\\)</span>'); expect(result).toContain(
expect(result).toContain('<span class="math-inline">\\(\\sum_{i=1}^{n} x_i\\)</span>'); '<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 () => { 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); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">\\(E = mc^2\\)</span>'); 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 () => { 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); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-display">'); expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\begin{align}"); expect(result).toContain("\\begin{align}");
expect(result).toContain("\\end{align}"); expect(result).toContain("\\end{align}");
@ -97,7 +120,7 @@ describe("Math Processing in Advanced Markup Parser", () => {
it("should handle edge case with empty math expressions", async () => { it("should handle edge case with empty math expressions", async () => {
const input = "Empty math: `$$` and `$`"; const input = "Empty math: `$$` and `$`";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
// Should not crash and should preserve the original content // Should not crash and should preserve the original content
expect(result).toContain("`$$`"); expect(result).toContain("`$$`");
expect(result).toContain("`$`"); expect(result).toContain("`$`");
@ -115,16 +138,18 @@ And display math: \`$$\n\\int_0^1 x^2 dx = \\frac{1}{3}\n$$\`
And more regular text.`; And more regular text.`;
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
// Should preserve regular text // Should preserve regular text
expect(result).toContain("This is a paragraph with regular text."); expect(result).toContain("This is a paragraph with regular text.");
expect(result).toContain("And more regular text."); expect(result).toContain("And more regular text.");
// Should preserve regular code blocks // Should preserve regular code blocks
expect(result).toContain("`console.log('hello')`"); expect(result).toContain("`console.log('hello')`");
// Should process math // 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('<span class="math-display">');
expect(result).toContain("\\int_0^1 x^2 dx = \\frac{1}{3}"); expect(result).toContain("\\int_0^1 x^2 dx = \\frac{1}{3}");
}); });
@ -132,15 +157,16 @@ And more regular text.`;
it("should handle math expressions with dollar signs in the content", async () => { it("should handle math expressions with dollar signs in the content", async () => {
const input = "Price math: `$\\text{Price} = \\$19.99$`"; const input = "Price math: `$\\text{Price} = \\$19.99$`";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">'); expect(result).toContain('<span class="math-inline">');
expect(result).toContain("\\text{Price} = \\$19.99"); expect(result).toContain("\\text{Price} = \\$19.99");
}); });
it("should handle display math with dollar signs in the content", async () => { 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); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-display">'); expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\text{Total} = \\$19.99 + \\$5.99 = \\$25.98"); expect(result).toContain("\\text{Total} = \\$19.99 + \\$5.99 = \\$25.98");
}); });
@ -149,34 +175,37 @@ And more regular text.`;
// Simulate content from JSON where backslashes are escaped // Simulate content from JSON where backslashes are escaped
const jsonContent = "Math from JSON: `$\\\\alpha + \\\\beta = \\\\gamma$`"; const jsonContent = "Math from JSON: `$\\\\alpha + \\\\beta = \\\\gamma$`";
const result = await parseAdvancedmarkup(jsonContent); const result = await parseAdvancedmarkup(jsonContent);
expect(result).toContain('<span class="math-inline">'); expect(result).toContain('<span class="math-inline">');
expect(result).toContain("\\\\alpha + \\\\beta = \\\\gamma"); expect(result).toContain("\\\\alpha + \\\\beta = \\\\gamma");
}); });
it("should handle JSON content with escaped display math", async () => { it("should handle JSON content with escaped display math", async () => {
// Simulate content from JSON where backslashes are escaped // 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); const result = await parseAdvancedmarkup(jsonContent);
expect(result).toContain('<span class="math-display">'); expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\\\int_0^1 x^2 dx = \\\\frac{1}{3}"); expect(result).toContain("\\\\int_0^1 x^2 dx = \\\\frac{1}{3}");
}); });
it("should handle JSON content with escaped dollar signs", async () => { it("should handle JSON content with escaped dollar signs", async () => {
// Simulate content from JSON where dollar signs are escaped // 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); const result = await parseAdvancedmarkup(jsonContent);
expect(result).toContain('<span class="math-inline">'); expect(result).toContain('<span class="math-inline">');
expect(result).toContain("\\\\text{Price} = \\\\\\$19.99"); expect(result).toContain("\\\\text{Price} = \\\\\\$19.99");
}); });
it("should handle complex JSON content with multiple escaped characters", async () => { it("should handle complex JSON content with multiple escaped characters", async () => {
// Simulate complex content from JSON // 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); const result = await parseAdvancedmarkup(jsonContent);
expect(result).toContain('<span class="math-display">'); expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\\\begin{pmatrix}"); expect(result).toContain("\\\\begin{pmatrix}");
expect(result).toContain("\\\\end{pmatrix}"); expect(result).toContain("\\\\end{pmatrix}");

8
tests/unit/tagExpansion.test.ts

@ -335,7 +335,9 @@ describe("Tag Expansion Tests", () => {
); );
// Should not include events without tags // 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", () => {
// Should handle d-tags with colons correctly // Should handle d-tags with colons correctly
expect(result.publications).toHaveLength(3); 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 @@
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { execSync } from "child_process"; import { execSync } from "node:child_process";
import process from "node:process"; import process from "node:process";
// Function to get the latest git tag // Function to get the latest git tag

Loading…
Cancel
Save