Browse Source

Merges pull request #83

fix: resolve CodeMirror duplicate instance error
master
silberengel 2 months ago
parent
commit
4363883dd2
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 3
      TECHNIQUE-create-test-highlights.md
  2. 2
      check-publication-structure.js
  3. 10
      create-test-comments.js
  4. 10
      create-test-highlights.js
  5. 3806
      deno.lock
  6. 458
      package-lock.json
  7. 1
      package.json
  8. 26
      src/lib/components/CommentViewer.svelte
  9. 2
      src/lib/components/ZettelEditor.svelte
  10. 81
      src/lib/components/cards/BlogHeader.svelte
  11. 67
      src/lib/components/publications/CommentLayer.svelte
  12. 649
      src/lib/components/publications/HighlightLayer.svelte
  13. 108
      src/lib/components/publications/Publication.svelte
  14. 8
      src/lib/components/publications/PublicationFeed.svelte
  15. 132
      src/lib/components/publications/PublicationSection.svelte
  16. 173
      src/lib/components/publications/SectionComments.svelte
  17. 14
      src/lib/components/util/CardActions.svelte
  18. 2
      src/lib/components/util/Profile.svelte
  19. 4
      src/lib/consts.ts
  20. 144
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  21. 1
      src/lib/utils/markup/asciidoctorExtensions.ts
  22. 15
      src/lib/utils/mockCommentData.ts
  23. 8
      src/lib/utils/mockHighlightData.ts
  24. 9
      src/lib/utils/network_detection.ts
  25. 23
      src/lib/utils/nostrUtils.ts
  26. 13
      src/lib/utils/searchCache.ts
  27. 3
      src/lib/utils/search_constants.ts
  28. 21
      src/lib/utils/subscription_search.ts
  29. 5
      src/lib/utils/websocket_utils.ts
  30. 4
      src/routes/about/+page.svelte
  31. 10
      src/routes/contact/+page.svelte
  32. 91
      src/routes/start/+page.svelte
  33. BIN
      static/screenshots/JaneEyre.png
  34. BIN
      static/screenshots/LandingPage.png
  35. BIN
      static/screenshots/ToC_blog.png
  36. BIN
      static/screenshots/ToC_normal.png
  37. BIN
      static/screenshots/Visualization.png
  38. BIN
      static/screenshots/YourRelays.png

3
TECHNIQUE-create-test-highlights.md

@ -47,7 +47,7 @@ const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`;
console.log("\nRoot Address:", rootAddress); console.log("\nRoot Address:", rootAddress);
// Fetch the index event to see what sections it references // Fetch the index event to see what sections it references
const relay = "wss://relay.nostr.band"; const relay = "wss://thecitadel.nostr1.com";
async function fetchPublication() { async function fetchPublication() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -172,7 +172,6 @@ const sections = [
// Relays to publish to (matching HighlightLayer's relay list) // Relays to publish to (matching HighlightLayer's relay list)
const relays = [ const relays = [
"wss://relay.damus.io", "wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nostr.wine", "wss://nostr.wine",
]; ];

2
check-publication-structure.js

@ -13,7 +13,7 @@ const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`;
console.log("\nRoot Address:", rootAddress); console.log("\nRoot Address:", rootAddress);
// Fetch the index event to see what sections it references // Fetch the index event to see what sections it references
const relay = "wss://relay.nostr.band"; const relay = "wss://thecitadel.nostr1.com";
async function fetchPublication() { async function fetchPublication() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

10
create-test-comments.js

@ -1,5 +1,7 @@
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools"; import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
import WebSocket from "ws"; import WebSocket from "ws";
import { activeInboxRelays } from "./src/lib/ndk.ts";
import { secondaryRelays } from "./src/lib/consts.ts";
// Test user keys (generate fresh ones) // Test user keys (generate fresh ones)
const testUserKey = generateSecretKey(); const testUserKey = generateSecretKey();
@ -25,12 +27,8 @@ const sections = [
`30041:${publicationPubkey}:the-persistent-escape-of-knowledge`, `30041:${publicationPubkey}:the-persistent-escape-of-knowledge`,
]; ];
// Relays to publish to (matching CommentLayer's relay list) // Relays to publish to - should match src/lib/consts.ts relay constants
const relays = [ const relays = [...secondaryRelays, ...activeInboxRelays];
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nostr.wine",
];
// Test comments to create // Test comments to create
const testComments = [ const testComments = [

10
create-test-highlights.js

@ -1,5 +1,7 @@
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools"; import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
import WebSocket from "ws"; import WebSocket from "ws";
import { activeInboxRelays } from "./src/lib/ndk.ts";
import { secondaryRelays } from "./src/lib/consts.ts";
// Test user keys (generate fresh ones) // Test user keys (generate fresh ones)
const testUserKey = generateSecretKey(); const testUserKey = generateSecretKey();
@ -25,12 +27,8 @@ const sections = [
`30041:${publicationPubkey}:the-persistent-escape-of-knowledge`, `30041:${publicationPubkey}:the-persistent-escape-of-knowledge`,
]; ];
// Relays to publish to (matching HighlightLayer's relay list) // Relays to publish to - should match src/lib/consts.ts relay constants
const relays = [ const relays = [...secondaryRelays, ...activeInboxRelays];
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nostr.wine",
];
// Test highlights to create // Test highlights to create
// AI-NOTE: Kind 9802 highlight events contain the actual highlighted text in .content // AI-NOTE: Kind 9802 highlight events contain the actual highlighted text in .content

3806
deno.lock

File diff suppressed because it is too large Load Diff

458
package-lock.json generated

@ -8,7 +8,6 @@
"name": "alexandria", "name": "alexandria",
"version": "0.0.2", "version": "0.0.2",
"dependencies": { "dependencies": {
"@codemirror/basic-setup": "^0.20.0",
"@codemirror/lang-markdown": "^6.3.4", "@codemirror/lang-markdown": "^6.3.4",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
@ -175,97 +174,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@codemirror/autocomplete": {
"version": "0.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-0.20.3.tgz",
"integrity": "sha512-lYB+NPGP+LEzAudkWhLfMxhTrxtLILGl938w+RcFrGdrIc54A+UgmCoz+McE3IYRFp4xyQcL4uFJwo+93YdgHw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^0.20.0",
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/autocomplete/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/autocomplete/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/basic-setup": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/basic-setup/-/basic-setup-0.20.0.tgz",
"integrity": "sha512-W/ERKMLErWkrVLyP5I8Yh8PXl4r+WFNkdYVSzkXYPQv2RMPSkWpr2BgggiSJ8AHF/q3GuApncDD8I4BZz65fyg==",
"deprecated": "In version 6.0, this package has been renamed to just 'codemirror'",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^0.20.0",
"@codemirror/commands": "^0.20.0",
"@codemirror/language": "^0.20.0",
"@codemirror/lint": "^0.20.0",
"@codemirror/search": "^0.20.0",
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0"
}
},
"node_modules/@codemirror/basic-setup/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/basic-setup/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/commands": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-0.20.0.tgz",
"integrity": "sha512-v9L5NNVA+A9R6zaFvaTbxs30kc69F6BkOoiEbeFw4m4I0exmDEKBILN6mK+GksJtvTzGBxvhAPlVFTdQW8GB7Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^0.20.0",
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/commands/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/commands/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/lang-css": { "node_modules/@codemirror/lang-css": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
@ -537,93 +445,6 @@
"@lezer/common": "^1.0.0" "@lezer/common": "^1.0.0"
} }
}, },
"node_modules/@codemirror/language": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-0.20.2.tgz",
"integrity": "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0",
"@lezer/highlight": "^0.16.0",
"@lezer/lr": "^0.16.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/language/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/language/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/lint": {
"version": "0.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-0.20.3.tgz",
"integrity": "sha512-06xUScbbspZ8mKoODQCEx6hz1bjaq9m8W8DxdycWARMiiX1wMtfCh/MoHpaL7ws/KUMwlsFFfp2qhm32oaCvVA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.2",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/lint/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/lint/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/search": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-0.20.1.tgz",
"integrity": "sha512-ROe6gRboQU5E4z6GAkNa2kxhXqsGNbeLEisbvzbOeB7nuDYXUZ70vGIgmqPu0tB+1M3F9yWk6W8k2vrFpJaD4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/search/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/state": { "node_modules/@codemirror/state": {
"version": "6.5.2", "version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
@ -1415,12 +1236,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lezer/common": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-0.16.1.tgz",
"integrity": "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA==",
"license": "MIT"
},
"node_modules/@lezer/css": { "node_modules/@lezer/css": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
@ -1456,15 +1271,6 @@
"@lezer/common": "^1.0.0" "@lezer/common": "^1.0.0"
} }
}, },
"node_modules/@lezer/highlight": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-0.16.0.tgz",
"integrity": "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^0.16.0"
}
},
"node_modules/@lezer/html": { "node_modules/@lezer/html": {
"version": "1.3.10", "version": "1.3.10",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz", "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
@ -1535,15 +1341,6 @@
"@lezer/common": "^1.0.0" "@lezer/common": "^1.0.0"
} }
}, },
"node_modules/@lezer/lr": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-0.16.3.tgz",
"integrity": "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^0.16.0"
}
},
"node_modules/@lezer/markdown": { "node_modules/@lezer/markdown": {
"version": "1.4.3", "version": "1.4.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.3.tgz", "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.3.tgz",
@ -1665,13 +1462,12 @@
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.55.0", "version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.55.0" "playwright": "1.57.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -2268,18 +2064,17 @@
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.43.1", "version": "2.49.5",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.43.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.5.tgz",
"integrity": "sha512-H8eXW5TSziSvt9d5IJ5pPyWGhXQLdmq+17H9j7aofA/TsfSvG8ZIpTjObphFRNagfIyoFGyoB3lOzdsGHKiKpw==", "integrity": "sha512-dCYqelr2RVnWUuxc+Dk/dB/SjV/8JBndp1UovCyCZdIQezd8TRwFLNZctYkzgHxRJtaNvseCSRsuuHPeUgIN/A==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.5",
"@types/cookie": "^0.6.0", "@types/cookie": "^0.6.0",
"acorn": "^8.14.1", "acorn": "^8.14.1",
"cookie": "^0.6.0", "cookie": "^0.6.0",
"devalue": "^5.3.2", "devalue": "^5.6.2",
"esm-env": "^1.2.2", "esm-env": "^1.2.2",
"kleur": "^4.1.5", "kleur": "^4.1.5",
"magic-string": "^0.30.5", "magic-string": "^0.30.5",
@ -2298,11 +2093,15 @@
"@opentelemetry/api": "^1.0.0", "@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
"svelte": "^4.0.0 || ^5.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@opentelemetry/api": { "@opentelemetry/api": {
"optional": true "optional": true
},
"typescript": {
"optional": true
} }
} }
}, },
@ -3258,35 +3057,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/apexcharts": { "node_modules/apexcharts": {
"version": "5.3.5", "version": "5.3.5",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.3.5.tgz", "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.3.5.tgz",
@ -3450,20 +3220,6 @@
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@ -3476,20 +3232,6 @@
"concat-map": "0.0.1" "concat-map": "0.0.1"
} }
}, },
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.26.2", "version": "4.26.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
@ -3667,46 +3409,6 @@
"node": ">= 16" "node": ">= 16"
} }
}, },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/class-variance-authority": { "node_modules/class-variance-authority": {
"version": "0.7.1", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@ -4419,11 +4121,10 @@
} }
}, },
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.3.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
"integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==", "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
"dev": true, "dev": true
"license": "MIT"
}, },
"node_modules/dexie": { "node_modules/dexie": {
"version": "4.2.0", "version": "4.2.0",
@ -4978,20 +4679,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@ -5162,6 +4849,7 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -5481,20 +5169,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-core-module": { "node_modules/is-core-module": {
"version": "2.16.1", "version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@ -5536,7 +5210,7 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"engines": { "engines": {
@ -5556,7 +5230,7 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@ -5573,17 +5247,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-promise": { "node_modules/is-promise": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
@ -5666,11 +5329,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@ -6220,17 +5882,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/normalize-range": { "node_modules/normalize-range": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
@ -6525,13 +6176,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.55.0", "version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.55.0" "playwright-core": "1.57.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -6544,11 +6194,10 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.55.0", "version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
}, },
@ -7070,34 +6719,6 @@
"pify": "^2.3.0" "pify": "^2.3.0"
} }
}, },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -7661,20 +7282,6 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/token-stream": { "node_modules/token-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz",
@ -7825,11 +7432,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.6", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",

1
package.json

@ -17,7 +17,6 @@
"tokens": "node src/lib/theme/build-tokens.mjs" "tokens": "node src/lib/theme/build-tokens.mjs"
}, },
"dependencies": { "dependencies": {
"@codemirror/basic-setup": "^0.20.0",
"@codemirror/lang-markdown": "^6.3.4", "@codemirror/lang-markdown": "^6.3.4",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",

26
src/lib/components/CommentViewer.svelte

@ -523,6 +523,32 @@
} }
} }
/**
* Public method to refresh comments (e.g., after creating a new one)
*/
export function refresh() {
console.log(`[CommentViewer] Refreshing comments for event:`, event?.id);
// Clean up previous subscription
if (activeSub) {
activeSub.stop();
activeSub = null;
}
// Reset state
comments = [];
profiles = new Map();
nestedReplyIds = new Set();
isFetchingNestedReplies = false;
retryCount = 0;
isFetching = false;
// Refetch comments
if (event?.id && !isFetching) {
fetchComments();
}
}
// Cleanup on unmount // Cleanup on unmount
onMount(() => { onMount(() => {
return () => { return () => {

2
src/lib/components/ZettelEditor.svelte

@ -8,7 +8,7 @@
import { EditorState, StateField, StateEffect } from "@codemirror/state"; import { EditorState, StateField, StateEffect } from "@codemirror/state";
import { markdown } from "@codemirror/lang-markdown"; import { markdown } from "@codemirror/lang-markdown";
import { EditorView, Decoration, type DecorationSet } from "@codemirror/view"; import { EditorView, Decoration, type DecorationSet } from "@codemirror/view";
import { basicSetup } from "@codemirror/basic-setup"; import { basicSetup } from "codemirror";
import { RangeSet } from "@codemirror/state"; import { RangeSet } from "@codemirror/state";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { import {

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

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { scale } from "svelte/transition"; import { scale } from "svelte/transition";
import { Card } from "flowbite-svelte"; import { Card, Button, Popover } from "flowbite-svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import Interactions from "$components/util/Interactions.svelte"; import Interactions from "$components/util/Interactions.svelte";
import { quintOut } from "svelte/easing"; import { quintOut } from "svelte/easing";
@ -11,17 +11,32 @@
import { generateDarkPastelColor } from "$lib/utils/image_utils"; import { generateDarkPastelColor } from "$lib/utils/image_utils";
import { getNdkContext } from "$lib/ndk"; import { getNdkContext } from "$lib/ndk";
import { deleteEvent } from "$lib/services/deletion"; import { deleteEvent } from "$lib/services/deletion";
import {
EyeOutline,
EyeSlashOutline,
DotsVerticalOutline,
} from "flowbite-svelte-icons";
const { const {
rootId, rootId,
event, event,
onBlogUpdate, onBlogUpdate,
active = true, active = true,
showActionsMenu = false,
commentsVisible = false,
highlightsVisible = false,
onToggleComments,
onToggleHighlights,
} = $props<{ } = $props<{
rootId: string; rootId: string;
event: NDKEvent; event: NDKEvent;
onBlogUpdate?: any; onBlogUpdate?: any;
active: boolean; active: boolean;
showActionsMenu?: boolean;
commentsVisible?: boolean;
highlightsVisible?: boolean;
onToggleComments?: () => void;
onToggleHighlights?: () => void;
}>(); }>();
const ndk = getNdkContext(); const ndk = getNdkContext();
@ -84,6 +99,8 @@
function showBlog() { function showBlog() {
onBlogUpdate?.(rootId); onBlogUpdate?.(rootId);
} }
let actionsMenuOpen = $state(false);
</script> </script>
{#if title != null} {#if title != null}
@ -117,6 +134,68 @@
{@render userBadge(authorPubkey, author, ndk)} {@render userBadge(authorPubkey, author, ndk)}
<span class="text-gray-700 dark:text-gray-300">{publishedAt()}</span> <span class="text-gray-700 dark:text-gray-300">{publishedAt()}</span>
</div> </div>
{#if showActionsMenu}
<div class="flex items-center">
<Button
type="button"
class="btn-leather !p-1 bg-primary-50 dark:bg-gray-800"
outline
onmouseenter={() => (actionsMenuOpen = true)}
>
<DotsVerticalOutline class="w-5 h-5" />
</Button>
{#if actionsMenuOpen}
<Popover
id="popover-blog-actions"
placement="bottom-end"
trigger="click"
class="popover-leather w-fit z-10"
onmouseleave={() => (actionsMenuOpen = false)}
>
<div class="flex flex-row justify-between space-x-4">
<div class="flex flex-col text-nowrap">
<ul class="space-y-2">
<li>
<button
class="btn-leather w-full text-left"
onclick={() => {
onToggleComments?.();
actionsMenuOpen = false;
}}
>
{#if commentsVisible}
<EyeSlashOutline class="inline mr-2" />
Hide Comments
{:else}
<EyeOutline class="inline mr-2" />
Show Comments
{/if}
</button>
</li>
<li>
<button
class="btn-leather w-full text-left"
onclick={() => {
onToggleHighlights?.();
actionsMenuOpen = false;
}}
>
{#if highlightsVisible}
<EyeSlashOutline class="inline mr-2" />
Hide Highlights
{:else}
<EyeOutline class="inline mr-2" />
Show Highlights
{/if}
</button>
</li>
</ul>
</div>
</div>
</Popover>
{/if}
</div>
{/if}
</div> </div>
<div <div

67
src/lib/components/publications/CommentLayer.svelte

@ -72,19 +72,24 @@
try { try {
// Build filter for kind 1111 comment events // Build filter for kind 1111 comment events
// IMPORTANT: Use only #a tags because filters are AND, not OR // NIP-22: Uppercase tags (A, E, I, K, P) point to root scope (section/publication)
// If we include both #e and #a, relays will only return comments that have BOTH // Lowercase tags (a, e, i, k, p) point to parent item (comment being replied to)
// IMPORTANT: Use uppercase #A filter to match NIP-22 root scope tags
// If we include both #e and #A, relays will only return comments that have BOTH
const filter: any = { const filter: any = {
kinds: [1111], kinds: [1111],
limit: 500, limit: 500,
}; };
// Prefer #a (addressable events) since they're more specific and persistent // NIP-22: Use uppercase #A filter to match root scope (section addresses)
// This will fetch both direct comments and replies (replies also have uppercase A tag)
if (allAddresses.length > 0) { if (allAddresses.length > 0) {
filter["#a"] = allAddresses; filter["#A"] = allAddresses;
console.debug(`[CommentLayer] Fetching comments for addresses (NIP-22 #A filter):`, allAddresses);
} else if (allEventIds.length > 0) { } else if (allEventIds.length > 0) {
// Fallback to #e if no addresses available // Fallback to #e if no addresses available
filter["#e"] = allEventIds; filter["#e"] = allEventIds;
console.debug(`[CommentLayer] Fetching comments for event IDs:`, allEventIds);
} }
// Build explicit relay set (same pattern as HighlightLayer) // Build explicit relay set (same pattern as HighlightLayer)
@ -168,6 +173,16 @@
// Convert to NDKEvent // Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent); const ndkEvent = new NDKEventClass(ndk, rawEvent);
// AI-NOTE: Debug logging to track comment reception
const aTags = ndkEvent.tags.filter((t: string[]) => t[0] === "a");
console.debug(`[CommentLayer] Received comment event:`, {
id: rawEvent.id?.substring(0, 8),
kind: rawEvent.kind,
aTags: aTags.map((t: string[]) => t[1]),
content: rawEvent.content?.substring(0, 50),
});
comments = [...comments, ndkEvent]; comments = [...comments, ndkEvent];
} }
} else if (message[0] === "EOSE" && message[1] === subscriptionId) { } else if (message[0] === "EOSE" && message[1] === subscriptionId) {
@ -202,6 +217,16 @@
// Wait for all relays to respond or timeout // Wait for all relays to respond or timeout
await Promise.allSettled(fetchPromises); await Promise.allSettled(fetchPromises);
// AI-NOTE: Debug logging to track comment fetching
console.debug(`[CommentLayer] Fetched ${comments.length} comments for addresses:`, allAddresses);
if (comments.length > 0) {
console.debug(`[CommentLayer] Comment addresses:`, comments.map(c => {
// NIP-22: Look for uppercase A tag (root scope)
const rootATag = c.tags.find((t: string[]) => t[0] === "A");
return rootATag ? rootATag[1] : "no-A-tag";
}));
}
// Ensure loading is cleared even if checkAllResponses didn't fire // Ensure loading is cleared even if checkAllResponses didn't fire
loading = false; loading = false;
@ -214,25 +239,44 @@
// Track the last fetched event count to know when to refetch // Track the last fetched event count to know when to refetch
let lastFetchedCount = $state(0); let lastFetchedCount = $state(0);
let fetchTimeout: ReturnType<typeof setTimeout> | null = null; let fetchTimeout: ReturnType<typeof setTimeout> | null = null;
let lastAddressesString = $state("");
// Watch for changes to event data - debounce and fetch when data stabilizes // Watch for changes to event data - debounce and fetch when data stabilizes
$effect(() => { $effect(() => {
const currentCount = eventIds.length + eventAddresses.length; const currentCount = eventIds.length + eventAddresses.length;
const hasEventData = currentCount > 0; const hasEventData = currentCount > 0;
// AI-NOTE: Debug logging to track effect execution
console.debug(`[CommentLayer] Effect running:`, {
eventIdsCount: eventIds.length,
eventAddressesCount: eventAddresses.length,
hasEventData,
addresses: eventAddresses,
});
// AI-NOTE: Also track the actual addresses string to detect when addresses change
// even if the count stays the same (e.g., when commentsVisible toggles)
const currentAddressesString = JSON.stringify(eventAddresses.sort());
// Only fetch if: // Only fetch if:
// 1. We have event data // 1. We have event data
// 2. The count has changed since last fetch // 2. (The count has changed OR the addresses have changed) since last fetch
// 3. We're not already loading // 3. We're not already loading
if (hasEventData && currentCount !== lastFetchedCount && !loading) { const addressesChanged = currentAddressesString !== lastAddressesString;
const countChanged = currentCount !== lastFetchedCount;
if (hasEventData && (countChanged || addressesChanged) && !loading) {
// Clear any existing timeout // Clear any existing timeout
if (fetchTimeout) { if (fetchTimeout) {
clearTimeout(fetchTimeout); clearTimeout(fetchTimeout);
} }
console.debug(`[CommentLayer] Effect triggered: count=${currentCount}, addresses changed=${addressesChanged}, addresses:`, eventAddresses);
// Debounce: wait 500ms for more events to arrive before fetching // Debounce: wait 500ms for more events to arrive before fetching
fetchTimeout = setTimeout(() => { fetchTimeout = setTimeout(() => {
lastFetchedCount = currentCount; lastFetchedCount = currentCount;
lastAddressesString = currentAddressesString;
fetchComments(); fetchComments();
}, 500); }, 500);
} }
@ -249,11 +293,22 @@
* Public method to refresh comments (e.g., after creating a new one) * Public method to refresh comments (e.g., after creating a new one)
*/ */
export function refresh() { export function refresh() {
console.debug(`[CommentLayer] refresh() called, current comments: ${comments.length}`);
// Clear existing comments // Clear existing comments
comments = []; comments = [];
// Reset fetch count to force re-fetch // Reset fetch count to force re-fetch
lastFetchedCount = 0; lastFetchedCount = 0;
// Collect current addresses to log what we're fetching
const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(Boolean);
const allAddresses = [...(eventAddress ? [eventAddress] : []), ...eventAddresses].filter(Boolean);
console.debug(`[CommentLayer] Refreshing comments for:`, {
eventIds: allEventIds,
addresses: allAddresses,
});
fetchComments(); fetchComments();
} }
</script> </script>

649
src/lib/components/publications/HighlightLayer.svelte

@ -32,6 +32,9 @@
eventAddresses = [], eventAddresses = [],
visible = $bindable(false), visible = $bindable(false),
useMockHighlights = false, useMockHighlights = false,
currentViewAddress,
rootAddress,
publicationType,
}: { }: {
eventId?: string; eventId?: string;
eventAddress?: string; eventAddress?: string;
@ -39,6 +42,9 @@
eventAddresses?: string[]; eventAddresses?: string[];
visible?: boolean; visible?: boolean;
useMockHighlights?: boolean; useMockHighlights?: boolean;
currentViewAddress?: string;
rootAddress?: string;
publicationType?: string;
} = $props(); } = $props();
const ndk = getNdkContext(); const ndk = getNdkContext();
@ -52,20 +58,63 @@
let copyFeedback = $state<string | null>(null); let copyFeedback = $state<string | null>(null);
// Derived state for color mapping // Derived state for color mapping
// AI-NOTE: Increased opacity from 0.3 to 0.5 for better visibility
let colorMap = $derived.by(() => { let colorMap = $derived.by(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
highlights.forEach((highlight) => { highlights.forEach((highlight) => {
if (!map.has(highlight.pubkey)) { if (!map.has(highlight.pubkey)) {
const hue = pubkeyToHue(highlight.pubkey); const hue = pubkeyToHue(highlight.pubkey);
map.set(highlight.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`); map.set(highlight.pubkey, `hsla(${hue}, 70%, 60%, 0.5)`);
} }
}); });
return map; return map;
}); });
// Derived state for grouped highlights // Filter highlights to only show those for the current view
// AI-NOTE:
// - If viewing a blog index (30040), don't show any highlights - highlights scoped to the blog
// contain text from individual articles (30041) which aren't loaded on the index
// - If viewing a blog entry (30041), show highlights scoped to that entry OR to the parent blog (30040)
// - If viewing a publication index, show all highlights scoped to the publication
let filteredHighlights = $derived.by(() => {
if (!currentViewAddress) {
// No current view specified - viewing publication/blog index
// For blogs, don't show highlights on the index - the text is in the articles, not the index
if (publicationType === "blog") {
return [];
}
// For regular publications, show all highlights
return highlights;
}
// Check if currentViewAddress is a blog entry (30041) or section
const isBlogEntry = currentViewAddress.startsWith('30041:');
// Filter highlights to only those scoped to the current view
// OR to the root publication if viewing a blog entry
return highlights.filter((highlight) => {
const aTag = highlight.tags.find((tag) => tag[0] === "a");
if (!aTag) return false;
const highlightAddress = aTag[1];
// Match if highlight is scoped to current view
if (highlightAddress === currentViewAddress) {
return true;
}
// If viewing a blog entry (30041), also show highlights scoped to the parent publication (30040)
if (isBlogEntry && rootAddress && highlightAddress === rootAddress) {
return true;
}
return false;
});
});
// Derived state for grouped highlights (using filtered highlights)
let groupedHighlights = $derived.by(() => { let groupedHighlights = $derived.by(() => {
return groupHighlightsByAuthor(highlights); return groupHighlightsByAuthor(filteredHighlights);
}); });
/** /**
@ -281,36 +330,314 @@
const sectionElement = document.getElementById(targetAddress); const sectionElement = document.getElementById(targetAddress);
if (sectionElement) { if (sectionElement) {
searchRoot = sectionElement; searchRoot = sectionElement;
console.debug(`[HighlightLayer] Found section element for ${targetAddress}, using it as search root`);
} else {
console.warn(`[HighlightLayer] Section element not found for ${targetAddress}, falling back to containerRef`);
} }
} }
return highlightByOffset(searchRoot, offsetStart, offsetEnd, color); const result = highlightByOffset(searchRoot, offsetStart, offsetEnd, color);
if (!result) {
console.warn(`[HighlightLayer] highlightByOffset returned false for offsets ${offsetStart}-${offsetEnd} in ${targetAddress || 'container'}`);
} }
return result;
}
// Track retry attempts for text highlighting
const textHighlightRetries = new Map<string, number>();
const MAX_TEXT_HIGHLIGHT_RETRIES = 10;
const TEXT_HIGHLIGHT_RETRY_DELAYS = [100, 200, 300, 500, 750, 1000, 1500, 2000, 3000, 5000]; // ms
/** /**
* Find text in the DOM and highlight it (fallback method) * Find text in the DOM and highlight it (fallback method)
* @param text - The text to highlight * @param text - The text to highlight
* @param color - The color to use for highlighting * @param color - The color to use for highlighting
* @param targetAddress - Optional address to limit search to specific section * @param targetAddress - Optional address to limit search to specific section
* @param retryCount - Internal parameter for retry attempts
*/ */
function findAndHighlightText( function findAndHighlightText(
text: string, text: string,
color: string, color: string,
targetAddress?: string, targetAddress?: string,
retryCount: number = 0,
): void { ): void {
if (!containerRef || !text || text.trim().length === 0) { console.log(`[HighlightLayer] findAndHighlightText called: text="${text}", color="${color}", targetAddress="${targetAddress}", retryCount=${retryCount}`);
if (!text || text.trim().length === 0) {
console.warn(`[HighlightLayer] Empty text provided, returning`);
return; return;
} }
// If we have a target address, search only in that section // If we have a target address, search only in that section
let searchRoot: HTMLElement | Document = containerRef; // AI-NOTE: When viewing a section directly (leaf), the section element might be outside containerRef
// So we search the entire document for the section element
let searchRoot: HTMLElement | Document | null = null;
let sectionElement: HTMLElement | null = null;
if (targetAddress) { if (targetAddress) {
const sectionElement = document.getElementById(targetAddress); // Check if this is a publication address (30040) or section address (30041)
const isPublicationAddress = targetAddress.startsWith('30040:');
if (isPublicationAddress) {
// For publication-scoped highlights, search in all sections of that publication
// Find all section elements that belong to this publication
const allSections = document.querySelectorAll('section[id^="30041:"], section[id^="30040:"]');
const matchingSections: HTMLElement[] = [];
// Extract publication identifier from target address (pubkey:d-tag)
const pubParts = targetAddress.split(':');
if (pubParts.length >= 3) {
const pubKey = pubParts[1];
const pubDtag = pubParts[2];
// Find sections that belong to this publication
for (const section of allSections) {
const sectionId = section.id;
const sectionParts = sectionId.split(':');
if (sectionParts.length >= 3 && sectionParts[1] === pubKey) {
// This section belongs to the same publication
matchingSections.push(section as HTMLElement);
}
}
}
if (matchingSections.length > 0) {
// Create a container to search across all matching sections
// We'll search each section individually
console.debug(`[HighlightLayer] Found ${matchingSections.length} sections for publication ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
// Search in each matching section
for (const section of matchingSections) {
const contentSection = section.querySelector('section.publication-leather');
const searchTarget = (contentSection || section.querySelector('.section-content') || section) as HTMLElement;
if (searchTarget) {
// Use TreeWalker to find text in this section
const walker = document.createTreeWalker(
searchTarget,
NodeFilter.SHOW_TEXT,
null,
);
const textNodes: Node[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
// Search for the text in this section's text nodes
const normalizedSearchText = text.trim().replace(/\s+/g, ' ').toLowerCase();
for (const textNode of textNodes) {
const nodeText = textNode.textContent || "";
if (!nodeText || nodeText.trim().length === 0) continue;
const normalizedNodeText = nodeText.replace(/\s+/g, ' ').toLowerCase();
if (normalizedNodeText.includes(normalizedSearchText)) {
// Found match - highlight it
let index = normalizedNodeText.indexOf(normalizedSearchText);
if (index !== -1) {
const searchPattern = text.trim();
let actualIndex = nodeText.toLowerCase().indexOf(searchPattern.toLowerCase());
if (actualIndex === -1) {
// Try flexible whitespace matching
const escapedText = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const flexiblePattern = escapedText.split(/\s+/).join('\\s+');
const regex = new RegExp(flexiblePattern, 'i');
const regexMatch = nodeText.match(regex);
if (regexMatch && regexMatch.index !== undefined) {
actualIndex = regexMatch.index;
const actualMatchText = regexMatch[0];
const parent = textNode.parentNode;
if (!parent) continue;
if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
continue;
}
const before = nodeText.substring(0, actualIndex);
const after = nodeText.substring(actualIndex + actualMatchText.length);
const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0";
highlightSpan.style.color = "inherit";
highlightSpan.style.fontWeight = "inherit";
highlightSpan.textContent = actualMatchText;
const fragment = document.createDocumentFragment();
if (before) fragment.appendChild(document.createTextNode(before));
fragment.appendChild(highlightSpan);
if (after) fragment.appendChild(document.createTextNode(after));
parent.replaceChild(fragment, textNode);
console.debug(`[HighlightLayer] Successfully highlighted text in publication-scoped highlight`);
if (targetAddress) {
const retryKey = `${targetAddress}:${text}`;
textHighlightRetries.delete(retryKey);
}
return; // Found and highlighted, done
}
} else {
// Found exact match
const parent = textNode.parentNode;
if (!parent) continue;
if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
continue;
}
const matchLength = text.length;
const before = nodeText.substring(0, actualIndex);
const match = nodeText.substring(actualIndex, actualIndex + matchLength);
const after = nodeText.substring(actualIndex + matchLength);
const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0";
highlightSpan.style.color = "inherit";
highlightSpan.style.fontWeight = "inherit";
highlightSpan.textContent = match;
const fragment = document.createDocumentFragment();
if (before) fragment.appendChild(document.createTextNode(before));
fragment.appendChild(highlightSpan);
if (after) fragment.appendChild(document.createTextNode(after));
parent.replaceChild(fragment, textNode);
console.debug(`[HighlightLayer] Successfully highlighted text in publication-scoped highlight`);
if (targetAddress) {
const retryKey = `${targetAddress}:${text}`;
textHighlightRetries.delete(retryKey);
}
return; // Found and highlighted, done
}
}
}
}
}
}
// If we get here, we searched all sections but didn't find the text
// This might mean the content isn't loaded yet, so we'll retry
const retryKey = `${targetAddress}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] Text not found in publication sections yet, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
} else {
// Only warn if we truly couldn't find it after all retries
// Check if highlight was actually rendered before warning
const queryRoot = containerRef || document;
const existingHighlights = queryRoot.querySelectorAll("mark.highlight");
const highlightFound = Array.from(existingHighlights).some(mark =>
mark.textContent?.toLowerCase().includes(text.toLowerCase())
);
if (!highlightFound) {
console.debug(`[HighlightLayer] Text "${text}" not found in publication sections after ${MAX_TEXT_HIGHLIGHT_RETRIES} retries (content may not be loaded yet)`);
}
return;
}
} else {
// No sections found for this publication - might not be loaded yet
const retryKey = `${targetAddress}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] No sections found for publication ${targetAddress}, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
} else {
// Only warn if we truly couldn't find sections after all retries
console.debug(`[HighlightLayer] No sections found for publication ${targetAddress} after ${MAX_TEXT_HIGHLIGHT_RETRIES} retries (sections may not be loaded yet)`);
return;
}
}
} else {
// Section-scoped highlight - search in specific section element
sectionElement = document.getElementById(targetAddress);
if (sectionElement) { if (sectionElement) {
// AI-NOTE: The actual content is in a nested <section class="whitespace-normal publication-leather">
// created by contentParagraph snippet. Look for that nested section first.
const contentSection = sectionElement.querySelector('section.publication-leather');
if (contentSection) {
searchRoot = contentSection as HTMLElement;
console.debug(`[HighlightLayer] Found nested content section for ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
} else {
// Fallback to .section-content div if nested section not found
const contentDiv = sectionElement.querySelector(".section-content");
if (contentDiv) {
searchRoot = contentDiv as HTMLElement;
console.debug(`[HighlightLayer] Found section-content div for ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
} else {
// Last resort: search entire section
searchRoot = sectionElement; searchRoot = sectionElement;
console.debug(`[HighlightLayer] Found section element for ${targetAddress} (no nested content found), searching for text: "${text.substring(0, 50)}"`);
}
}
} else {
// Section not found - might not be loaded yet, retry if we haven't exceeded max retries
const retryKey = `${targetAddress}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] Section element not found for ${targetAddress}, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
} else {
// Only warn if we truly couldn't find the section after all retries
console.debug(`[HighlightLayer] Section element not found for ${targetAddress} after ${MAX_TEXT_HIGHLIGHT_RETRIES} retries (section may not be loaded yet)`);
return;
}
}
}
}
// If no target address, use containerRef if available, otherwise document
if (!targetAddress) {
if (containerRef) {
searchRoot = containerRef;
console.debug(`[HighlightLayer] No target address, searching in containerRef for text: "${text.substring(0, 50)}"`);
} else {
searchRoot = document;
console.debug(`[HighlightLayer] No target address and no containerRef, searching in document for text: "${text.substring(0, 50)}"`);
} }
} }
if (!searchRoot) {
return;
}
// Use TreeWalker to find all text nodes // Use TreeWalker to find all text nodes
const walker = document.createTreeWalker( const walker = document.createTreeWalker(
searchRoot, searchRoot,
@ -318,39 +645,216 @@
null, null,
); );
// AI-NOTE: First, check if the text exists in the full content
// This helps us know if we should continue searching
// Normalize whitespace for matching - highlights may have different whitespace than DOM
const fullText = searchRoot instanceof HTMLElement
? searchRoot.textContent || searchRoot.innerText || ""
: "";
const normalizedSearchText = text.trim().replace(/\s+/g, ' ').toLowerCase();
const normalizedFullText = fullText.replace(/\s+/g, ' ').toLowerCase();
// AI-NOTE: If content is empty or very short, the section might still be loading
// Check if we have meaningful content (more than just whitespace/formatting)
const hasContent = fullText.trim().length > 5; // At least 5 characters of actual content
if (!hasContent && sectionElement && retryCount < MAX_TEXT_HIGHLIGHT_RETRIES) {
// Content not loaded yet, retry
const retryKey = `${targetAddress || 'no-address'}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] Section content not loaded yet for ${targetAddress}, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
}
}
if (!normalizedFullText.includes(normalizedSearchText)) {
// Text not found - retry if section exists and we haven't exceeded max retries
if (sectionElement && retryCount < MAX_TEXT_HIGHLIGHT_RETRIES) {
const retryKey = `${targetAddress || 'no-address'}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] Text "${text}" not found in content yet, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES}). Full text (first 200 chars): "${fullText.substring(0, 200)}"`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
}
}
// Only warn if we truly couldn't find the text after checking
// This might be a false negative if content is still loading
console.debug(
`[HighlightLayer] Text "${text}" not found in full content. Full text (first 200 chars): "${fullText.substring(0, 200)}"`,
);
return;
}
console.debug(
`[HighlightLayer] Text "${text}" found in full content. Searching text nodes...`,
);
// Collect all text nodes
const textNodes: Node[] = []; const textNodes: Node[] = [];
let node: Node | null; let node: Node | null;
while ((node = walker.nextNode())) { while ((node = walker.nextNode())) {
textNodes.push(node); textNodes.push(node);
} }
console.debug(`[HighlightLayer] Found ${textNodes.length} text nodes to search`);
// Log first few text nodes for debugging
const sampleNodes = textNodes.slice(0, 20).filter(n => n.textContent && n.textContent.trim().length > 0);
console.log(`[HighlightLayer] Sample text nodes (first 20 non-empty):`, sampleNodes.map(n => `"${n.textContent?.substring(0, 50)}${(n.textContent?.length || 0) > 50 ? '...' : ''}"`));
// Search for the highlight text in text nodes // Search for the highlight text in text nodes
for (const textNode of textNodes) { // normalizedSearchText is already defined above with whitespace normalization
for (let i = 0; i < textNodes.length; i++) {
const textNode = textNodes[i];
const nodeText = textNode.textContent || ""; const nodeText = textNode.textContent || "";
const index = nodeText.toLowerCase().indexOf(text.toLowerCase()); if (!nodeText || nodeText.trim().length === 0) {
continue; // Skip empty text nodes
}
// Normalize node text for comparison (collapse whitespace)
const normalizedNodeText = nodeText.replace(/\s+/g, ' ').toLowerCase();
// Log every text node that contains the search text (for debugging)
if (normalizedNodeText.includes(normalizedSearchText)) {
console.log(`[HighlightLayer] Text node ${i} contains search text: "${nodeText.substring(0, 100)}${nodeText.length > 100 ? '...' : ''}"`);
}
// Try normalized match first (case-insensitive, whitespace-normalized)
let index = normalizedNodeText.indexOf(normalizedSearchText);
// If normalized match found, find the actual position in the original text
// by searching for the normalized pattern in the original text
if (index !== -1) { if (index !== -1) {
// Find the actual start position in the original text
// We need to account for whitespace differences
const searchPattern = text.trim();
let actualIndex = nodeText.toLowerCase().indexOf(searchPattern.toLowerCase());
// If that doesn't work, try finding by character position accounting for whitespace
if (actualIndex === -1) {
// Build a regex that matches the text with flexible whitespace
const escapedText = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const flexiblePattern = escapedText.split(/\s+/).join('\\s+');
const regex = new RegExp(flexiblePattern, 'i');
const regexMatch = nodeText.match(regex);
if (regexMatch && regexMatch.index !== undefined) {
actualIndex = regexMatch.index;
// Update the search text to the actual matched text for highlighting
const actualMatchText = regexMatch[0];
// Use the actual matched text length for highlighting
const before = nodeText.substring(0, actualIndex);
const matchedText = actualMatchText;
const after = nodeText.substring(actualIndex + actualMatchText.length);
const parent = textNode.parentNode; const parent = textNode.parentNode;
if (!parent) continue; if (!parent) {
console.warn(`[HighlightLayer] Text node has no parent, skipping`);
continue;
}
// Skip if already highlighted
if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
console.debug(`[HighlightLayer] Text node already highlighted, skipping`);
continue;
}
console.debug(`[HighlightLayer] Found match at index ${actualIndex}: "${matchedText}" in node: "${nodeText.substring(0, 100)}${nodeText.length > 100 ? '...' : ''}"`);
// Create highlight span with visible styling
const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0";
highlightSpan.style.color = "inherit";
highlightSpan.style.fontWeight = "inherit";
highlightSpan.textContent = matchedText;
// Replace the text node with the highlighted version
const fragment = document.createDocumentFragment();
if (before) fragment.appendChild(document.createTextNode(before));
fragment.appendChild(highlightSpan);
if (after) fragment.appendChild(document.createTextNode(after));
parent.replaceChild(fragment, textNode);
console.debug(
`[HighlightLayer] Successfully highlighted text "${matchedText}" at index ${actualIndex} in node with text: "${nodeText.substring(0, 50)}${nodeText.length > 50 ? "..." : ""}"`,
);
// Clear retry count on success
if (targetAddress) {
const retryKey = `${targetAddress}:${text}`;
textHighlightRetries.delete(retryKey);
}
return; // Only highlight first occurrence to avoid multiple highlights
}
} else {
// Found exact match (case-insensitive) - use it directly
index = actualIndex;
}
}
// Fallback: try exact match (case-sensitive) if normalized match failed
if (index === -1) {
index = nodeText.indexOf(text);
}
// Fallback: try case-insensitive exact match
if (index === -1) {
const lowerNodeText = nodeText.toLowerCase();
const lowerSearchText = text.toLowerCase();
index = lowerNodeText.indexOf(lowerSearchText);
}
if (index !== -1) {
const parent = textNode.parentNode;
if (!parent) {
console.warn(`[HighlightLayer] Text node has no parent, skipping`);
continue;
}
// Skip if already highlighted // Skip if already highlighted
if ( if (
parent.nodeName === "MARK" || parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight")) (parent instanceof Element && parent.classList?.contains("highlight"))
) { ) {
console.debug(`[HighlightLayer] Text node already highlighted, skipping`);
continue; continue;
} }
// Find the actual match - use the original text length
const matchLength = text.length;
const before = nodeText.substring(0, index); const before = nodeText.substring(0, index);
const match = nodeText.substring(index, index + text.length); const match = nodeText.substring(index, index + matchLength);
const after = nodeText.substring(index + text.length); const after = nodeText.substring(index + matchLength);
// Create highlight span console.debug(`[HighlightLayer] Found match at index ${index}: "${match}" in node: "${nodeText.substring(0, 100)}${nodeText.length > 100 ? '...' : ''}"`);
// Create highlight span with visible styling
const highlightSpan = document.createElement("mark"); const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight"; highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color; highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px"; highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0"; highlightSpan.style.padding = "2px 0";
highlightSpan.style.color = "inherit"; // Ensure text color is visible
highlightSpan.style.fontWeight = "inherit"; // Preserve font weight
highlightSpan.textContent = match; highlightSpan.textContent = match;
// Replace the text node with the highlighted version // Replace the text node with the highlighted version
@ -361,35 +865,92 @@
parent.replaceChild(fragment, textNode); parent.replaceChild(fragment, textNode);
console.debug(
`[HighlightLayer] Successfully highlighted text "${match}" at index ${index} in node with text: "${nodeText.substring(0, 50)}${nodeText.length > 50 ? "..." : ""}"`,
);
// Clear retry count on success
if (targetAddress) {
const retryKey = `${targetAddress}:${text}`;
textHighlightRetries.delete(retryKey);
}
return; // Only highlight first occurrence to avoid multiple highlights return; // Only highlight first occurrence to avoid multiple highlights
} }
} }
// AI-NOTE: If no match found, log for debugging
console.warn(
`[HighlightLayer] Could not find highlight text "${text}" in section. Searched ${textNodes.length} text nodes.`,
);
if (textNodes.length > 0) {
// Log more text nodes and their full content to help debug
const sampleNodes = textNodes.slice(0, 10);
console.debug(
`[HighlightLayer] Sample text nodes (first 10):`,
sampleNodes.map((n, i) => ({
index: i,
text: n.textContent?.substring(0, 100) || "",
fullLength: n.textContent?.length || 0,
parentTag: n.parentElement?.tagName || "unknown",
})),
);
// Also log the full text content of the search root to see what's actually there
if (searchRoot instanceof HTMLElement) {
const fullText = searchRoot.textContent || "";
console.debug(
`[HighlightLayer] Full text content of search root (first 500 chars): "${fullText.substring(0, 500)}"`,
);
console.debug(
`[HighlightLayer] Full text contains "${text}": ${fullText.toLowerCase().includes(text.toLowerCase())}`,
);
}
}
} }
/** /**
* Render all highlights on the page * Render all highlights on the page
*/ */
function renderHighlights() { function renderHighlights() {
if (!visible || !containerRef) { if (!visible) {
return; return;
} }
if (highlights.length === 0) { if (filteredHighlights.length === 0) {
return; return;
} }
// Clear existing highlights // AI-NOTE: When viewing a section directly (leaf), containerRef might not be set
// But we can still highlight by searching the document for section elements
// Only clear highlights if containerRef exists, otherwise clear from document
if (containerRef) {
clearHighlights(); clearHighlights();
} else {
// Clear highlights from entire document
const highlightElements = document.querySelectorAll("mark.highlight");
highlightElements.forEach((el) => {
const parent = el.parentNode;
if (parent) {
const textNode = document.createTextNode(el.textContent || "");
parent.replaceChild(textNode, el);
parent.normalize();
}
});
}
// Apply each highlight // Apply each highlight (only filtered highlights for current view)
for (const highlight of highlights) { for (const highlight of filteredHighlights) {
const content = highlight.content; const content = highlight.content;
const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.3)"; const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.5)";
console.log(`[HighlightLayer] Processing highlight: content="${content}", color="${color}"`);
// Extract the target address from the highlight's "a" tag // Extract the target address from the highlight's "a" tag
const aTag = highlight.tags.find((tag) => tag[0] === "a"); const aTag = highlight.tags.find((tag) => tag[0] === "a");
const targetAddress = aTag ? aTag[1] : undefined; const targetAddress = aTag ? aTag[1] : undefined;
console.log(`[HighlightLayer] Highlight targetAddress: "${targetAddress}"`);
// Check for offset tags (position-based highlighting) // Check for offset tags (position-based highlighting)
const offsetTag = highlight.tags.find((tag) => tag[0] === "offset"); const offsetTag = highlight.tags.find((tag) => tag[0] === "offset");
const hasOffset = const hasOffset =
@ -412,19 +973,32 @@
} }
// Check if any highlights were actually rendered // Check if any highlights were actually rendered
const renderedHighlights = containerRef.querySelectorAll("mark.highlight"); const renderedHighlights = containerRef
? containerRef.querySelectorAll("mark.highlight")
: document.querySelectorAll("mark.highlight");
console.log( console.log(
`[HighlightLayer] Rendered ${renderedHighlights.length} highlight marks in DOM`, `[HighlightLayer] Rendered ${renderedHighlights.length} highlight marks in DOM`,
); );
// AI-NOTE: Debug logging to help diagnose highlight visibility issues
if (renderedHighlights.length === 0 && filteredHighlights.length > 0) {
console.warn(`[HighlightLayer] No highlights rendered despite ${filteredHighlights.length} filtered highlights available. Container:`, containerRef, "Visible:", visible, "CurrentView:", currentViewAddress);
// Log highlight details for debugging
filteredHighlights.forEach((h, i) => {
const aTag = h.tags.find((tag) => tag[0] === "a");
const offsetTag = h.tags.find((tag) => tag[0] === "offset");
console.debug(`[HighlightLayer] Highlight ${i}: content="${h.content.substring(0, 50)}", targetAddress="${aTag?.[1]}", hasOffset=${!!offsetTag}`);
});
}
} }
/** /**
* Clear all highlights from the page * Clear all highlights from the page
* AI-NOTE: If containerRef is not set (e.g., blog entries), clear from document
*/ */
function clearHighlights() { function clearHighlights() {
if (!containerRef) return; const queryRoot = containerRef || document;
const highlightElements = queryRoot.querySelectorAll("mark.highlight");
const highlightElements = containerRef.querySelectorAll("mark.highlight");
highlightElements.forEach((el) => { highlightElements.forEach((el) => {
const parent = el.parentNode; const parent = el.parentNode;
if (parent) { if (parent) {
@ -473,12 +1047,33 @@
}); });
// Watch for visibility AND highlights changes - render when both are ready // Watch for visibility AND highlights changes - render when both are ready
// AI-NOTE: Also watch containerRef to ensure it's set before rendering
// AI-NOTE: For blog entries viewed directly, containerRef might not be set, so we render without it
$effect(() => { $effect(() => {
// This effect runs when either visible or highlights.length changes // This effect runs when either visible, highlights.length, or containerRef changes
const highlightCount = highlights.length; const highlightCount = filteredHighlights.length;
if (visible && highlightCount > 0) { if (visible && highlightCount > 0) {
// AI-NOTE: Retry rendering with increasing delays to handle async content loading
// This is especially important when viewing sections directly
let retryCount = 0;
const maxRetries = 5;
const retryDelays = [100, 300, 500, 1000, 2000];
const tryRender = () => {
renderHighlights(); renderHighlights();
const renderedCount = containerRef?.querySelectorAll("mark.highlight").length || 0;
if (renderedCount === 0 && retryCount < maxRetries) {
console.debug(`[HighlightLayer] No highlights rendered, retrying (attempt ${retryCount + 1}/${maxRetries})...`);
setTimeout(tryRender, retryDelays[retryCount]);
retryCount++;
} else if (renderedCount > 0) {
console.debug(`[HighlightLayer] Successfully rendered ${renderedCount} highlights after ${retryCount} retries`);
}
};
tryRender();
} else if (!visible) { } else if (!visible) {
clearHighlights(); clearHighlights();
} }
@ -631,7 +1226,7 @@
</div> </div>
{/if} {/if}
{#if visible && highlights.length > 0} {#if visible && filteredHighlights.length > 0}
<div <div
class="fixed bottom-4 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 max-w-sm w-80" class="fixed bottom-4 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 max-w-sm w-80"
> >
@ -643,7 +1238,7 @@
{@const isExpanded = expandedAuthors.has(pubkey)} {@const isExpanded = expandedAuthors.has(pubkey)}
{@const profile = authorProfiles.get(pubkey)} {@const profile = authorProfiles.get(pubkey)}
{@const displayName = getAuthorDisplayName(profile, pubkey)} {@const displayName = getAuthorDisplayName(profile, pubkey)}
{@const color = colorMap.get(pubkey) || "hsla(60, 70%, 60%, 0.3)"} {@const color = colorMap.get(pubkey) || "hsla(60, 70%, 60%, 0.5)"}
{@const sortedHighlights = sortHighlightsByTime(authorHighlights)} {@const sortedHighlights = sortHighlightsByTime(authorHighlights)}
<div class="border-b border-gray-200 dark:border-gray-700 pb-2"> <div class="border-b border-gray-200 dark:border-gray-700 pb-2">

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

@ -58,13 +58,21 @@
const ndk = getNdkContext(); const ndk = getNdkContext();
// AI-NOTE: Default visibility logic:
// - Blogs: comments and highlights ON by default
// - Articles/sections: comments and highlights ON by default
// - Publication indexes (kind 30040): comments and highlights OFF by default (for undisturbed reading)
const isPublicationIndex = publicationType === "publication" && indexEvent.kind === 30040;
const defaultCommentsVisible = !isPublicationIndex;
const defaultHighlightsVisible = !isPublicationIndex;
// Highlight layer state // Highlight layer state
let highlightsVisible = $state(false); let highlightsVisible = $state(defaultHighlightsVisible);
let highlightLayerRef: any = null; let highlightLayerRef: any = null;
let publicationContentRef: HTMLElement | null = $state(null); let publicationContentRef: HTMLElement | null = $state(null);
// Comment layer state // Comment layer state
let commentsVisible = $state(false); let commentsVisible = $state(defaultCommentsVisible);
let comments = $state<NDKEvent[]>([]); let comments = $state<NDKEvent[]>([]);
let commentLayerRef: any = null; let commentLayerRef: any = null;
let showArticleCommentUI = $state(false); let showArticleCommentUI = $state(false);
@ -108,14 +116,27 @@
}); });
// Filter comments for the root publication (kind 30040) // Filter comments for the root publication (kind 30040)
// AI-NOTE: NIP-22: Uppercase A tag points to root scope (publication/section)
// Use uppercase A tag to match comments scoped to the root publication
let articleComments = $derived( let articleComments = $derived(
comments.filter((comment) => { comments.filter((comment) => {
// Check if comment targets the root publication via #a tag // NIP-22: Look for uppercase A tag (root scope)
const aTag = comment.tags.find((t) => t[0] === "a"); const rootATag = comment.tags.find((t) => t[0] === "A");
return aTag && aTag[1] === rootAddress; return rootATag && rootATag[1] === rootAddress;
}), }),
); );
// Filter comments for the current blog entry
// AI-NOTE: NIP-22: Uppercase A tag points to root scope (blog entry address)
let blogComments = $derived.by(() => {
if (!currentBlog) return [];
return comments.filter((comment) => {
// NIP-22: Look for uppercase A tag (root scope)
const rootATag = comment.tags.find((t) => t[0] === "A");
return rootATag && rootATag[1] === currentBlog;
});
});
// #region Loading // #region Loading
let leaves = $state<Array<NDKEvent | null>>([]); let leaves = $state<Array<NDKEvent | null>>([]);
let isLoading = $state(false); let isLoading = $state(false);
@ -650,6 +671,14 @@
let currentBlogEvent: null | NDKEvent = $state(null); let currentBlogEvent: null | NDKEvent = $state(null);
const isLeaf = $derived(indexEvent.kind === 30041); const isLeaf = $derived(indexEvent.kind === 30041);
// AI-NOTE: Determine current view address for filtering highlights
// - If viewing a blog entry, use the blog address
// - If viewing a section directly (leaf), use the root address
// - Otherwise (publication index), undefined (show all highlights)
const currentViewAddress = $derived(
currentBlog || (isLeaf ? rootAddress : undefined)
);
function isInnerActive() { function isInnerActive() {
return currentBlog !== null && $publicationColumnVisibility.inner; return currentBlog !== null && $publicationColumnVisibility.inner;
@ -699,15 +728,29 @@
function toggleComments() { function toggleComments() {
commentsVisible = !commentsVisible; commentsVisible = !commentsVisible;
// AI-NOTE: When toggling comments on, ensure CommentLayer fetches comments
// The effect in CommentLayer should handle this, but we can also trigger a refresh
if (commentsVisible && commentLayerRef) {
console.debug("[Publication] Comments toggled on, triggering refresh");
// Small delay to ensure addresses are available
setTimeout(() => {
if (commentLayerRef && commentsVisible) {
commentLayerRef.refresh();
}
}, 100);
}
} }
function handleCommentPosted() { function handleCommentPosted() {
// Refresh the comment layer after a short delay to allow relay indexing // AI-NOTE: Refresh the comment layer after a delay to allow relay indexing
// Increased delay to 3 seconds to give relays more time to index the new comment
setTimeout(() => { setTimeout(() => {
if (commentLayerRef) { if (commentLayerRef) {
console.debug("[Publication] Refreshing CommentLayer after comment posted");
commentLayerRef.refresh(); commentLayerRef.refresh();
} }
}, 500); }, 3000);
} }
async function submitArticleComment() { async function submitArticleComment() {
@ -1301,25 +1344,16 @@
{/if} {/if}
</div> </div>
<!-- Mobile article comments - shown below header on smaller screens --> <!-- Article comments - shown below header only when viewing full publication (not a section directly) -->
<div class="xl:hidden mt-4 max-w-4xl mx-auto px-4"> {#if !currentBlog && !isLeaf}
<SectionComments <div class="mt-4 max-w-4xl mx-auto px-4">
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
</div>
<!-- Desktop article comments - positioned on right side on XL+ screens -->
<div
class="hidden xl:block absolute left-[calc(50%+26rem)] top-0 w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]"
>
<SectionComments <SectionComments
sectionAddress={rootAddress} sectionAddress={rootAddress}
comments={articleComments} comments={articleComments}
visible={commentsVisible} visible={commentsVisible}
/> />
</div> </div>
{/if}
</div> </div>
@ -1338,6 +1372,7 @@
placeholder="Write your comment on this article..." placeholder="Write your comment on this article..."
rows={4} rows={4}
disabled={isSubmittingArticleComment} disabled={isSubmittingArticleComment}
class="w-full"
/> />
{#if articleCommentError} {#if articleCommentError}
@ -1407,6 +1442,7 @@
{commentsVisible} {commentsVisible}
publicationTitle={publicationTitle} publicationTitle={publicationTitle}
{isFirstSection} {isFirstSection}
onCommentPosted={handleCommentPosted}
ref={(el) => onPublicationSectionMounted(el, address)} ref={(el) => onPublicationSectionMounted(el, address)}
/> />
{/if} {/if}
@ -1476,6 +1512,7 @@
{toc} {toc}
allComments={comments} allComments={comments}
{commentsVisible} {commentsVisible}
onCommentPosted={handleCommentPosted}
ref={(el) => onPublicationSectionMounted(el, address)} ref={(el) => onPublicationSectionMounted(el, address)}
/> />
{:else} {:else}
@ -1530,9 +1567,32 @@
event={currentBlogEvent} event={currentBlogEvent}
onBlogUpdate={loadBlog} onBlogUpdate={loadBlog}
active={true} active={true}
showActionsMenu={true}
commentsVisible={commentsVisible}
highlightsVisible={highlightsVisible}
onToggleComments={toggleComments}
onToggleHighlights={toggleHighlights}
/> />
{/if} {/if}
<!-- Article comments in discussion sidebar - show for full publication or blog entry -->
{#if (!currentBlog && !isLeaf) || (currentBlog && currentBlogEvent)}
<div class="flex flex-col w-full space-y-4"> <div class="flex flex-col w-full space-y-4">
{#if currentBlog && currentBlogEvent}
<!-- Blog entry comments -->
<SectionComments
sectionAddress={currentBlog}
comments={blogComments}
visible={commentsVisible}
/>
{#if blogComments.length === 0}
<p
class="text-sm text-gray-500 dark:text-gray-400 text-center py-4"
>
No comments yet. Be the first to comment!
</p>
{/if}
{:else}
<!-- Publication article comments -->
<SectionComments <SectionComments
sectionAddress={rootAddress} sectionAddress={rootAddress}
comments={articleComments} comments={articleComments}
@ -1545,7 +1605,9 @@
No comments yet. Be the first to comment! No comments yet. Be the first to comment!
</p> </p>
{/if} {/if}
{/if}
</div> </div>
{/if}
</div> </div>
</SidebarGroup> </SidebarGroup>
</SidebarWrapper> </SidebarWrapper>
@ -1608,12 +1670,16 @@
{/if} {/if}
<!-- Highlight Layer Component --> <!-- Highlight Layer Component -->
<!-- AI-NOTE: Pass currentViewAddress, rootAddress, and publicationType to filter highlights to current view -->
<HighlightLayer <HighlightLayer
bind:this={highlightLayerRef} bind:this={highlightLayerRef}
eventIds={allEventIds} eventIds={allEventIds}
eventAddresses={allEventAddresses} eventAddresses={allEventAddresses}
bind:visible={highlightsVisible} bind:visible={highlightsVisible}
{useMockHighlights} {useMockHighlights}
currentViewAddress={currentViewAddress}
rootAddress={rootAddress}
publicationType={publicationType}
/> />
<!-- Comment Layer Component --> <!-- Comment Layer Component -->
@ -1630,5 +1696,7 @@
<CardActions <CardActions
event={indexEvent} event={indexEvent}
bind:detailsModalOpen={detailsModalOpen} bind:detailsModalOpen={detailsModalOpen}
sectionAddress={rootAddress}
onCommentPosted={handleCommentPosted}
/> />
</div> </div>

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

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { indexKind } from "$lib/consts"; import { indexKind } from "$lib/consts";
import { SEARCH_LIMITS } from "$lib/utils/search_constants.ts";
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { filterValidIndexEvents, debounceAsync } from "$lib/utils"; import { filterValidIndexEvents, debounceAsync } from "$lib/utils";
import { Button, P, Skeleton, Spinner } from "flowbite-svelte"; import { Button, P, Skeleton, Spinner } from "flowbite-svelte";
@ -241,7 +242,7 @@
ws.send(JSON.stringify([ ws.send(JSON.stringify([
"REQ", "REQ",
subId, subId,
{ kinds: [indexKind], limit: 1000 } { kinds: [indexKind], limit: SEARCH_LIMITS.PUBLICATION_FEED_LIMIT }
])); ]));
// Set up cleanup // Set up cleanup
@ -277,6 +278,11 @@
// Sort by created_at descending // Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!); allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
// AI-NOTE: Clear publication search cache when new events are loaded to prevent stale results
// This ensures searches will re-run with the updated event set
searchCache.clearType("publication");
console.debug(`[PublicationFeed] Cleared publication search cache after loading ${newEvents.length} new events`);
// Update the view immediately with new events // Update the view immediately with new events
eventsInView = allIndexEvents.slice(0, publicationsToDisplay); eventsInView = allIndexEvents.slice(0, publicationsToDisplay);
endOfFeed = allIndexEvents.length <= publicationsToDisplay; endOfFeed = allIndexEvents.length <= publicationsToDisplay;

132
src/lib/components/publications/PublicationSection.svelte

@ -28,6 +28,7 @@
commentsVisible = true, commentsVisible = true,
publicationTitle, publicationTitle,
isFirstSection = false, isFirstSection = false,
onCommentPosted,
}: { }: {
address: string; address: string;
rootAddress: string; rootAddress: string;
@ -39,20 +40,98 @@
commentsVisible?: boolean; commentsVisible?: boolean;
publicationTitle?: string; publicationTitle?: string;
isFirstSection?: boolean; isFirstSection?: boolean;
onCommentPosted?: () => void;
} = $props(); } = $props();
const asciidoctor: Asciidoctor = getContext("asciidoctor"); const asciidoctor: Asciidoctor = getContext("asciidoctor");
const ndk: NDK = getContext("ndk"); const ndk: NDK = getContext("ndk");
// Filter comments for this section // Filter comments for this section
let sectionComments = $derived( // AI-NOTE: NIP-22: Uppercase tags (A, E, I, K, P) point to root scope (section/publication)
allComments.filter((comment) => { // Lowercase tags (a, e, i, k, p) point to parent item (comment being replied to)
// Check if comment targets this section via #a tag // All comments scoped to this section will have uppercase A tag matching section address
const aTag = comment.tags.find((t) => t[0] === "a"); let sectionComments = $derived.by(() => {
return aTag && aTag[1] === address; // Step 1: Find all comments scoped to this section (have uppercase A tag matching section address)
}), const directComments = allComments.filter((comment) => {
// NIP-22: Look for uppercase A tag (root scope)
const rootATag = comment.tags.find((t) => t[0] === "A");
const matches = rootATag && rootATag[1] === address;
// AI-NOTE: Debug logging to help diagnose comment filtering issues
if (rootATag) {
console.debug("[PublicationSection] Comment filtering:", {
sectionAddress: address,
commentRootATag: rootATag[1],
matches,
commentId: comment.id?.substring(0, 8),
});
}
return matches;
});
// Step 2: Build a set of comment IDs that match this section (for efficient lookup)
const matchingCommentIds = new Set(
directComments.map(c => c.id?.toLowerCase()).filter(Boolean)
); );
// Step 3: Recursively find all replies to matching comments
// NIP-22: Replies have lowercase e tag pointing to parent comment ID
// They also have uppercase A tag matching section address (same root scope)
const allMatchingComments = new Set<NDKEvent>(directComments);
let foundNewReplies = true;
// Keep iterating until we find no new replies (handles nested replies)
while (foundNewReplies) {
foundNewReplies = false;
for (const comment of allComments) {
// Skip if already included
if (allMatchingComments.has(comment)) {
continue;
}
// NIP-22: Check if this comment is scoped to this section (uppercase A tag)
const rootATag = comment.tags.find((t) => t[0] === "A");
if (!rootATag || rootATag[1] !== address) {
// Not scoped to this section, skip
continue;
}
// NIP-22: Check if this is a reply (has lowercase e tag pointing to a matching comment)
const lowercaseETags = comment.tags.filter(t => t[0] === "e");
for (const eTag of lowercaseETags) {
const parentId = eTag[1]?.toLowerCase();
if (parentId && matchingCommentIds.has(parentId)) {
// This is a reply to a matching comment - include it
allMatchingComments.add(comment);
matchingCommentIds.add(comment.id?.toLowerCase() || "");
foundNewReplies = true;
console.debug(`[PublicationSection] Found reply ${comment.id?.substring(0, 8)} to matching comment ${parentId.substring(0, 8)} (NIP-22)`);
break; // Found a match, no need to check other e tags
}
}
}
}
const filtered = Array.from(allMatchingComments);
console.debug(`[PublicationSection] Filtered ${filtered.length} comments (${directComments.length} direct, ${filtered.length - directComments.length} replies) for section ${address} from ${allComments.length} total comments`);
// AI-NOTE: Debug logging to check for nested replies in filtered comments
const filteredCommentIds = new Set(filtered.map(c => c.id?.toLowerCase()).filter(Boolean));
for (const comment of filtered) {
const lowercaseETags = comment.tags.filter(t => t[0] === "e");
for (const eTag of lowercaseETags) {
const parentId = eTag[1]?.toLowerCase();
if (parentId && filteredCommentIds.has(parentId)) {
console.debug(`[PublicationSection] Found nested reply ${comment.id?.substring(0, 8)} to filtered comment ${parentId.substring(0, 8)}`);
}
}
}
return filtered;
});
let leafEvent: Promise<NDKEvent | null> = $derived.by( let leafEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(address), async () => await publicationTree.getEvent(address),
); );
@ -224,10 +303,32 @@
ref(sectionRef); ref(sectionRef);
}); });
// Initialize ABC notation blocks after content is rendered
$effect(() => {
if (typeof window === "undefined") return;
// Watch for content changes
leafContent.then(() => {
// Wait for content to be rendered in DOM
const initABC = () => {
if (typeof (window as any).initializeABCBlocks === "function") {
(window as any).initializeABCBlocks();
} else {
// If function not available yet, wait a bit and try again
setTimeout(initABC, 100);
}
};
// Initialize after a short delay to ensure DOM is ready
setTimeout(initABC, 200);
});
});
</script> </script>
<!-- Wrapper for positioning context --> <!-- Wrapper for positioning context -->
<div class="relative w-full overflow-x-hidden"> <!-- AI-NOTE: Removed overflow-x-hidden to allow comments panel to be visible when positioned absolutely -->
<div class="relative w-full">
<section <section
id={address} id={address}
bind:this={sectionRef} bind:this={sectionRef}
@ -257,6 +358,7 @@
{event} {event}
sectionAddress={address} sectionAddress={address}
onDelete={handleDelete} onDelete={handleDelete}
onCommentPosted={onCommentPosted}
/> />
{/if} {/if}
{/await} {/await}
@ -271,8 +373,8 @@
)} )}
</div> </div>
<!-- Mobile comments - shown below content on smaller screens --> <!-- Comments - shown below content on all screens -->
<div class="xl:hidden mt-8 w-full text-left"> <div class="mt-8 w-full text-left">
<SectionComments <SectionComments
sectionAddress={address} sectionAddress={address}
comments={sectionComments} comments={sectionComments}
@ -281,18 +383,6 @@
</div> </div>
{/await} {/await}
</section> </section>
<!-- Comments area: positioned below menu, top-center of section -->
<div
class="hidden xl:block absolute left-[calc(50%+26rem)] top-[calc(20%+3rem)] w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]"
>
<SectionComments
sectionAddress={address}
comments={sectionComments}
visible={commentsVisible}
/>
</div>
</div> </div>
<style> <style>

173
src/lib/components/publications/SectionComments.svelte

@ -36,42 +36,143 @@
// Subscribe to userStore // Subscribe to userStore
let user = $derived($userStore); let user = $derived($userStore);
// AI-NOTE: Debug logging to track component rendering and comment reception
$effect(() => {
console.debug(`[SectionComments] Component rendered/re-rendered:`, {
sectionAddress,
commentsCount: comments.length,
visible,
commentIds: comments.map(c => c.id?.substring(0, 8)),
});
});
/** /**
* Parse comment threading structure * Parse comment threading structure according to NIP-22
* Root comments have no 'e' tag with 'reply' marker * NIP-22: Uppercase tags (A, E, I, K, P) = root scope
* Lowercase tags (a, e, i, k, p) = parent item (comment being replied to)
* Root comments have no lowercase e tag (or lowercase e tag pointing to section itself)
* Replies have lowercase e tag pointing to parent comment ID
*/ */
function buildThreadStructure(allComments: NDKEvent[]) { function buildThreadStructure(allComments: NDKEvent[]) {
const rootComments: NDKEvent[] = []; const rootComments: NDKEvent[] = [];
const repliesByParent = new Map<string, NDKEvent[]>(); const repliesByParent = new Map<string, NDKEvent[]>();
// AI-NOTE: Normalize comment IDs to lowercase for consistent matching
const allCommentIds = new Set(allComments.map(c => c.id?.toLowerCase()).filter(Boolean));
// AI-NOTE: Debug logging to track comment threading
console.debug(`[SectionComments] Building thread structure from ${allComments.length} comments (NIP-22)`);
// NIP-22: First pass - identify replies by looking for lowercase e tags
// Lowercase e tags point to the parent comment ID
// This works for both direct replies and nested replies (replies to replies)
for (const comment of allComments) { for (const comment of allComments) {
// Check if this is a reply by looking for 'e' tags with 'reply' marker const commentId = comment.id?.toLowerCase();
const replyTag = comment.tags.find(t => t[0] === 'e' && t[3] === 'reply'); if (!commentId) {
console.warn(`[SectionComments] Comment missing ID, skipping`);
continue;
}
// NIP-22: Look for lowercase e tag (parent item reference)
const lowercaseETags = comment.tags.filter(t => t[0] === 'e');
if (lowercaseETags.length > 0) {
// Check if any lowercase e tag points to a comment in our set
let isReply = false;
for (const eTag of lowercaseETags) {
const parentId = eTag[1]?.trim().toLowerCase();
if (!parentId) {
continue;
}
// NIP-22: If lowercase e tag points to a comment in our set, it's a reply
// This works for both direct replies (parent is root comment) and nested replies (parent is another reply)
if (allCommentIds.has(parentId)) {
isReply = true;
if (replyTag) {
const parentId = replyTag[1];
if (!repliesByParent.has(parentId)) { if (!repliesByParent.has(parentId)) {
repliesByParent.set(parentId, []); repliesByParent.set(parentId, []);
} }
repliesByParent.get(parentId)!.push(comment); repliesByParent.get(parentId)!.push(comment);
// Check if this is a nested reply (reply to a reply)
const isNestedReply = !rootComments.some(rc => rc.id?.toLowerCase() === parentId);
const replyType = isNestedReply ? "nested reply (reply to reply)" : "direct reply";
console.debug(`[SectionComments] Comment ${commentId.substring(0, 8)} is a ${replyType} to ${parentId.substring(0, 8)} (NIP-22 lowercase e tag)`);
break; // Found parent, no need to check other e tags
}
}
if (!isReply) {
// Has lowercase e tag but doesn't reference any comment in our set
// This might be a root comment that references an external event, or malformed
rootComments.push(comment);
console.debug(`[SectionComments] Comment ${commentId.substring(0, 8)} is a root comment (lowercase e tag references external event)`);
}
} else { } else {
// This is a root comment (no reply tag) // No lowercase e tags - this is a root comment
rootComments.push(comment); rootComments.push(comment);
console.debug(`[SectionComments] Comment ${commentId.substring(0, 8)} is a root comment (no lowercase e tags)`);
} }
} }
console.debug(`[SectionComments] Thread structure: ${rootComments.length} root comments, ${repliesByParent.size} reply groups`);
// AI-NOTE: Log reply details for debugging
for (const [parentId, replies] of repliesByParent.entries()) {
console.debug(`[SectionComments] Parent ${parentId.substring(0, 8)} has ${replies.length} replies:`, replies.map(r => r.id?.substring(0, 8)));
// Check if any of these replies themselves have replies (nested replies)
for (const reply of replies) {
const replyId = reply.id?.toLowerCase();
if (replyId && repliesByParent.has(replyId)) {
const nestedReplies = repliesByParent.get(replyId)!;
console.debug(`[SectionComments] → Reply ${replyId.substring(0, 8)} has ${nestedReplies.length} nested replies (replies to replies)`);
}
}
}
return { rootComments, repliesByParent }; return { rootComments, repliesByParent };
} }
let threadStructure = $derived(buildThreadStructure(comments)); let threadStructure = $derived.by(() => {
const structure = buildThreadStructure(comments);
// AI-NOTE: Debug logging when structure changes
if (structure.rootComments.length > 0 || comments.length > 0) {
console.debug(`[SectionComments] Thread structure updated:`, {
totalComments: comments.length,
rootComments: structure.rootComments.length,
replyGroups: structure.repliesByParent.size,
visible,
});
// AI-NOTE: Log all parent IDs in the map to help debug nested reply lookup
const allParentIds = Array.from(structure.repliesByParent.keys());
console.debug(`[SectionComments] All parent IDs in repliesByParent map:`, allParentIds.map(id => id.substring(0, 8)));
// Log which replies have nested replies
for (const [parentId, replies] of structure.repliesByParent.entries()) {
for (const reply of replies) {
const replyId = reply.id?.toLowerCase();
if (replyId && structure.repliesByParent.has(replyId)) {
const nested = structure.repliesByParent.get(replyId)!;
console.debug(`[SectionComments] Reply ${replyId.substring(0, 8)} has ${nested.length} nested replies`);
}
}
}
}
return structure;
});
/** /**
* Count replies for a comment thread * Count replies for a comment thread
*/ */
function countReplies(commentId: string, repliesMap: Map<string, NDKEvent[]>): number { function countReplies(commentId: string, repliesMap: Map<string, NDKEvent[]>): number {
const directReplies = repliesMap.get(commentId) || []; // AI-NOTE: Normalize comment ID for lookup (must match how we stored it)
const normalizedCommentId = commentId?.trim().toLowerCase();
const directReplies = repliesMap.get(normalizedCommentId) || [];
let count = directReplies.length; let count = directReplies.length;
// AI-NOTE: Debug logging to track reply counting
if (directReplies.length > 0) {
console.debug(`[SectionComments] Found ${directReplies.length} direct replies for comment ${normalizedCommentId.substring(0, 8)}`);
}
// Recursively count nested replies // Recursively count nested replies
for (const reply of directReplies) { for (const reply of directReplies) {
count += countReplies(reply.id, repliesMap); count += countReplies(reply.id, repliesMap);
@ -166,7 +267,26 @@
* Render nested replies recursively * Render nested replies recursively
*/ */
function renderReplies(parentId: string, repliesMap: Map<string, NDKEvent[]>, level: number = 0) { function renderReplies(parentId: string, repliesMap: Map<string, NDKEvent[]>, level: number = 0) {
const replies = repliesMap.get(parentId) || []; // AI-NOTE: Normalize parent ID for lookup (must match how we stored it)
const normalizedParentId = parentId?.trim().toLowerCase();
const replies = repliesMap.get(normalizedParentId) || [];
// AI-NOTE: Debug logging to track reply rendering
if (replies.length > 0) {
console.debug(`[SectionComments] renderReplies: Found ${replies.length} replies for parent ${normalizedParentId.substring(0, 8)} (level ${level})`);
// Log all parent IDs in the map for debugging
const allParentIds = Array.from(repliesMap.keys());
console.debug(`[SectionComments] renderReplies: All parent IDs in map:`, allParentIds.map(id => id.substring(0, 8)));
} else {
// AI-NOTE: Debug when no replies found - check if map has any entries for similar IDs
const allParentIds = Array.from(repliesMap.keys());
const similarIds = allParentIds.filter(id => id.substring(0, 8) === normalizedParentId.substring(0, 8));
if (similarIds.length > 0) {
console.debug(`[SectionComments] renderReplies: No replies found for ${normalizedParentId.substring(0, 8)}, but found similar IDs:`, similarIds.map(id => id.substring(0, 8)));
} else {
console.debug(`[SectionComments] renderReplies: No replies found for ${normalizedParentId.substring(0, 8)}. Map has ${repliesMap.size} entries.`);
}
}
return replies; return replies;
} }
@ -339,8 +459,12 @@
}); });
</script> </script>
{#if visible && threadStructure.rootComments.length > 0} <!-- AI-NOTE: Debug info for comment display -->
<div class="space-y-1"> {#if visible}
{console.debug(`[SectionComments] RENDERING CHECK: visible=${visible}, rootComments=${threadStructure.rootComments.length}, totalComments=${comments.length}, repliesByParent.size=${threadStructure.repliesByParent.size}`)}
{#if threadStructure.rootComments.length > 0}
{console.debug(`[SectionComments] RENDERING COMMENTS: visible=${visible}, rootComments=${threadStructure.rootComments.length}, totalComments=${comments.length}`)}
<div class="space-y-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg p-4 shadow-lg">
{#each threadStructure.rootComments as rootComment (rootComment.id)} {#each threadStructure.rootComments as rootComment (rootComment.id)}
{@const replyCount = countReplies(rootComment.id, threadStructure.repliesByParent)} {@const replyCount = countReplies(rootComment.id, threadStructure.repliesByParent)}
{@const isExpanded = expandedThreads.has(rootComment.id)} {@const isExpanded = expandedThreads.has(rootComment.id)}
@ -566,7 +690,7 @@
placeholder="Write your reply..." placeholder="Write your reply..."
rows={3} rows={3}
disabled={isSubmittingReply} disabled={isSubmittingReply}
class="mb-2" class="w-full mb-2"
/> />
{#if replyError} {#if replyError}
@ -602,8 +726,17 @@
<!-- Replies --> <!-- Replies -->
{#if replyCount > 0} {#if replyCount > 0}
{console.debug(`[SectionComments] Rendering ${replyCount} replies for comment ${rootComment.id?.substring(0, 8)}`)}
<div class="pl-4 border-l-2 border-gray-200 dark:border-gray-600 space-y-2"> <div class="pl-4 border-l-2 border-gray-200 dark:border-gray-600 space-y-2">
{#each renderReplies(rootComment.id, threadStructure.repliesByParent) as reply (reply.id)} {#each renderReplies(rootComment.id, threadStructure.repliesByParent) as reply (reply.id)}
{@const replyId = reply.id?.toLowerCase() || ""}
{@const nestedReplies = replyId ? renderReplies(reply.id, threadStructure.repliesByParent) : []}
{console.debug(`[SectionComments] Processing reply ${reply.id?.substring(0, 8)}, nestedReplies.length=${nestedReplies.length}, replyId=${replyId.substring(0, 8)}, checking map for: ${replyId.substring(0, 8)}`)}
{#if nestedReplies.length > 0}
{console.debug(`[SectionComments] ✓ Found ${nestedReplies.length} nested replies for reply ${reply.id?.substring(0, 8)}, rendering them now`)}
{:else}
{console.debug(`[SectionComments] ✗ No nested replies found for reply ${reply.id?.substring(0, 8)}. Map has ${threadStructure.repliesByParent.size} entries.`)}
{/if}
<div class="bg-gray-50 dark:bg-gray-700/30 rounded p-3"> <div class="bg-gray-50 dark:bg-gray-700/30 rounded p-3">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<button <button
@ -700,7 +833,7 @@
placeholder="Write your reply..." placeholder="Write your reply..."
rows={3} rows={3}
disabled={isSubmittingReply} disabled={isSubmittingReply}
class="mb-2" class="w-full mb-2"
/> />
{#if replyError} {#if replyError}
@ -734,8 +867,10 @@
</div> </div>
{/if} {/if}
<!-- Nested replies (one level deep) --> <!-- Nested replies (replies to replies) -->
{#each renderReplies(reply.id, threadStructure.repliesByParent) as nestedReply (nestedReply.id)} {#if nestedReplies.length > 0}
{console.debug(`[SectionComments] Rendering ${nestedReplies.length} nested replies for reply ${reply.id?.substring(0, 8)}`)}
{#each nestedReplies as nestedReply (nestedReply.id)}
<div class="ml-4 mt-2 bg-gray-100 dark:bg-gray-600/30 rounded p-2"> <div class="ml-4 mt-2 bg-gray-100 dark:bg-gray-600/30 rounded p-2">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<button <button
@ -832,7 +967,7 @@
placeholder="Write your reply..." placeholder="Write your reply..."
rows={2} rows={2}
disabled={isSubmittingReply} disabled={isSubmittingReply}
class="mb-2 text-xs" class="w-full mb-2 text-xs"
/> />
{#if replyError} {#if replyError}
@ -867,6 +1002,7 @@
{/if} {/if}
</div> </div>
{/each} {/each}
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@ -877,6 +1013,9 @@
</div> </div>
{/each} {/each}
</div> </div>
{:else}
{console.debug(`[SectionComments] NOT RENDERING: visible=${visible} but no root comments (totalComments=${comments.length})`)}
{/if}
{/if} {/if}
<!-- Details Modal --> <!-- Details Modal -->

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

@ -30,12 +30,14 @@
event, event,
onDelete, onDelete,
sectionAddress, sectionAddress,
detailsModalOpen = $bindable(false) detailsModalOpen = $bindable(false),
onCommentPosted
} = $props<{ } = $props<{
event: NDKEvent; event: NDKEvent;
onDelete?: () => void; onDelete?: () => void;
sectionAddress?: string; // If provided, shows "Comment on section" option sectionAddress?: string; // If provided, shows "Comment on section" option
detailsModalOpen?: boolean; // Bindable prop to control modal from outside detailsModalOpen?: boolean; // Bindable prop to control modal from outside
onCommentPosted?: () => void; // Callback when a comment is successfully posted
}>(); }>();
const ndk = getNdkContext(); const ndk = getNdkContext();
@ -385,6 +387,16 @@
} }
commentSuccess = true; commentSuccess = true;
// AI-NOTE: Trigger callback to refresh comments after successful publish
// This allows parent components to refresh their comment displays
if (onCommentPosted) {
// Delay callback slightly to allow relay indexing
setTimeout(() => {
onCommentPosted();
}, 1000);
}
setTimeout(() => { setTimeout(() => {
commentModalOpen = false; commentModalOpen = false;
commentSuccess = false; commentSuccess = false;

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

@ -298,6 +298,8 @@
isLoadingExtension = false; isLoadingExtension = false;
try { try {
const ndk = new NDK(); const ndk = new NDK();
// AI-NOTE: relay.nsec.app is the specific relay for Amber/NIP-46 signer service
// This is service-specific and not a general-purpose relay, so it remains hard-coded
const relay = "wss://relay.nsec.app"; const relay = "wss://relay.nsec.app";
const localNsec = const localNsec =
localStorage.getItem("amber/nsec") ?? localStorage.getItem("amber/nsec") ??

4
src/lib/consts.ts

@ -11,12 +11,11 @@ export const communityRelays = [
]; ];
export const searchRelays = [ export const searchRelays = [
"wss://profiles.nostr1.com", "wss://thecitadel.nostr1.com",
"wss://aggr.nostr.land", "wss://aggr.nostr.land",
"wss://relay.noswhere.com", "wss://relay.noswhere.com",
"wss://nostr.wine", "wss://nostr.wine",
"wss://relay.damus.io", "wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://freelay.sovbit.host", "wss://freelay.sovbit.host",
]; ];
@ -46,6 +45,7 @@ export const localRelays: string[] = [
"ws://localhost:8080", "ws://localhost:8080",
"ws://localhost:4869", "ws://localhost:4869",
"ws://localhost:3334", "ws://localhost:3334",
"ws://localhost:7777"
]; ];
export enum FeedType { export enum FeedType {

144
src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts

@ -8,6 +8,7 @@ import plantumlEncoder from "plantuml-encoder";
* - PlantUML diagrams * - PlantUML diagrams
* - BPMN diagrams * - BPMN diagrams
* - TikZ diagrams * - TikZ diagrams
* - ABC notation (music)
*/ */
export async function postProcessAdvancedAsciidoctorHtml( export async function postProcessAdvancedAsciidoctorHtml(
html: string, html: string,
@ -25,6 +26,8 @@ export async function postProcessAdvancedAsciidoctorHtml(
processedHtml = processBPMNBlocks(processedHtml); processedHtml = processBPMNBlocks(processedHtml);
// Process TikZ blocks // Process TikZ blocks
processedHtml = processTikZBlocks(processedHtml); processedHtml = processTikZBlocks(processedHtml);
// Process ABC notation blocks
processedHtml = processABCBlocks(processedHtml);
// After all processing, apply highlight.js if available // After all processing, apply highlight.js if available
if ( if (
typeof globalThis !== "undefined" && typeof globalThis !== "undefined" &&
@ -366,6 +369,147 @@ function processTikZBlocks(html: string): string {
return html; return html;
} }
/**
* Processes ABC notation blocks in HTML content
* Uses data attributes to mark blocks for rendering, which will be processed by a global function
*/
function processABCBlocks(html: string): string {
// Match code blocks with class 'language-abc' or 'abc'
html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre class="highlight">\s*<code[^>]*class="[^"]*(?:language-abc|abc)[^"]*"[^>]*>([\s\S]*?)<\/code>\s*<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => {
try {
const rawContent = decodeHTMLEntities(content);
const blockId = `abc-${Math.random().toString(36).substring(2, 9)}`;
// Escape the ABC content for data attribute
const escapedContent = escapeHtml(rawContent).replace(/"/g, "&quot;");
return `<div class="abc-block my-4">
<div id="${blockId}"
class="abc-diagram bg-white dark:bg-gray-800 px-6 py-4 rounded-lg border border-gray-300 dark:border-gray-600 shadow-lg"
data-abc-content="${escapedContent}"></div>
<details class="mt-2">
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400">
Show ABC source
</summary>
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto">
<code>${escapeHtml(rawContent)}</code>
</pre>
</details>
</div>`;
} catch (error) {
console.warn("Failed to process ABC block:", error);
return match;
}
},
);
// Fallback: match <pre> blocks whose content starts with X: (ABC notation header)
html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => {
const lines = content.trim().split("\n");
// ABC notation typically starts with X: (tune number) or contains ABC-specific patterns
if (
lines.some((line: string) =>
line.trim().startsWith("X:") ||
line.trim().startsWith("T:") ||
line.trim().startsWith("M:") ||
line.trim().startsWith("K:")
)
) {
try {
const rawContent = decodeHTMLEntities(content);
const blockId = `abc-${Math.random().toString(36).substring(2, 9)}`;
const escapedContent = escapeHtml(rawContent).replace(/"/g, "&quot;");
return `<div class="abc-block my-4">
<div id="${blockId}"
class="abc-diagram bg-white dark:bg-gray-800 px-6 py-4 rounded-lg border border-gray-300 dark:border-gray-600 shadow-lg"
data-abc-content="${escapedContent}"></div>
<details class="mt-2">
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400">
Show ABC source
</summary>
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto">
<code>${escapeHtml(rawContent)}</code>
</pre>
</details>
</div>`;
} catch (error) {
console.warn("Failed to process ABC fallback block:", error);
return match;
}
}
return match;
},
);
return html;
}
/**
* Initializes ABC notation rendering for all blocks marked with data-abc-content
* This function is called after HTML is inserted into the DOM
*/
function initializeABCBlocks(): void {
if (typeof window === "undefined") return;
const abcBlocks = document.querySelectorAll('[data-abc-content]');
if (abcBlocks.length === 0) return;
// Load abcjs from CDN if not already loaded
if (typeof (window as any).ABCJS === "undefined") {
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/abcjs@6.2.0/dist/abcjs-basic.min.js";
script.onload = () => {
renderAllABCBlocks();
};
script.onerror = () => {
console.warn("Failed to load abcjs library");
};
document.head.appendChild(script);
} else {
renderAllABCBlocks();
}
function renderAllABCBlocks(): void {
const abcjs = (window as any).ABCJS;
if (!abcjs) return;
abcBlocks.forEach((block) => {
const container = block as HTMLElement;
const abcContent = container.getAttribute("data-abc-content");
if (!abcContent) return;
// Decode HTML entities
const textarea = document.createElement("textarea");
textarea.innerHTML = abcContent;
const decodedContent = textarea.value;
try {
abcjs.renderAbc(container.id || container, decodedContent, {
responsive: "resize",
staffwidth: 740,
scale: 1.0,
paddingleft: 20,
paddingright: 20,
paddingtop: 20,
paddingbottom: 20,
});
// Remove data attribute after rendering to avoid re-rendering
container.removeAttribute("data-abc-content");
} catch (error) {
console.warn("Failed to render ABC notation:", error);
container.innerHTML = '<p class="text-red-600 dark:text-red-400">Error rendering ABC notation. Please check the source.</p>';
}
});
}
}
// Make initializeABCBlocks available globally so it can be called from Svelte components
if (typeof window !== "undefined") {
(window as any).initializeABCBlocks = initializeABCBlocks;
}
/** /**
* Escapes HTML characters for safe display * Escapes HTML characters for safe display
*/ */

1
src/lib/utils/markup/asciidoctorExtensions.ts

@ -76,6 +76,7 @@ export function createAdvancedExtensions(): any {
registerDiagramBlock("plantuml"); registerDiagramBlock("plantuml");
registerDiagramBlock("tikz"); registerDiagramBlock("tikz");
registerDiagramBlock("bpmn"); registerDiagramBlock("bpmn");
registerDiagramBlock("abc");
// --- END NEW --- // --- END NEW ---
return extensions; return extensions;

15
src/lib/utils/mockCommentData.ts

@ -1,5 +1,6 @@
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk";
import { secondaryRelays } from "../consts.ts";
/** /**
* Generate mock comment data for testing comment UI and threading * Generate mock comment data for testing comment UI and threading
@ -49,18 +50,20 @@ function createMockComment(
replyToId?: string, replyToId?: string,
replyToAuthor?: string, replyToAuthor?: string,
): any { ): any {
// Use first search relay from constants instead of hard-coded relay
const relayUrl = secondaryRelays[0] || "wss://thecitadel.nostr1.com";
const tags: string[][] = [ const tags: string[][] = [
["A", targetAddress, "wss://relay.damus.io", pubkey], ["A", targetAddress, relayUrl, pubkey],
["K", "30041"], ["K", "30041"],
["P", pubkey, "wss://relay.damus.io"], ["P", pubkey, relayUrl],
["a", targetAddress, "wss://relay.damus.io"], ["a", targetAddress, relayUrl],
["k", "30041"], ["k", "30041"],
["p", pubkey, "wss://relay.damus.io"], ["p", pubkey, relayUrl],
]; ];
if (replyToId && replyToAuthor) { if (replyToId && replyToAuthor) {
tags.push(["e", replyToId, "wss://relay.damus.io", "reply"]); tags.push(["e", replyToId, relayUrl, "reply"]);
tags.push(["p", replyToAuthor, "wss://relay.damus.io"]); tags.push(["p", replyToAuthor, relayUrl]);
} }
// Return a plain object that matches NDKEvent structure // Return a plain object that matches NDKEvent structure

8
src/lib/utils/mockHighlightData.ts

@ -1,3 +1,5 @@
import { secondaryRelays } from "../consts.ts";
/** /**
* Generate mock highlight data (kind 9802) for testing highlight UI * Generate mock highlight data (kind 9802) for testing highlight UI
* Creates realistic highlight events with context and optional annotations * Creates realistic highlight events with context and optional annotations
@ -76,10 +78,12 @@ function createMockHighlight(
offsetStart?: number, offsetStart?: number,
offsetEnd?: number, offsetEnd?: number,
): any { ): any {
// Use first search relay from constants instead of hard-coded relay
const relayUrl = secondaryRelays[0] || "wss://thecitadel.nostr1.com";
const tags: string[][] = [ const tags: string[][] = [
["a", targetAddress, "wss://relay.damus.io"], ["a", targetAddress, relayUrl],
["context", context], ["context", context],
["p", authorPubkey, "wss://relay.damus.io", "author"], ["p", authorPubkey, relayUrl, "author"],
]; ];
// Add optional annotation // Add optional annotation

9
src/lib/utils/network_detection.ts

@ -25,8 +25,9 @@ const NETWORK_ENDPOINTS = [
export async function isNetworkOnline(): Promise<boolean> { export async function isNetworkOnline(): Promise<boolean> {
for (const endpoint of NETWORK_ENDPOINTS) { for (const endpoint of NETWORK_ENDPOINTS) {
try { try {
// Use a simple fetch without HEAD method to avoid CORS issues // Use window.fetch explicitly for client-side network detection
await fetch(endpoint, { // This is intentionally client-side code, not a SvelteKit load function
await window.fetch(endpoint, {
method: "GET", method: "GET",
cache: "no-cache", cache: "no-cache",
signal: AbortSignal.timeout(3000), signal: AbortSignal.timeout(3000),
@ -56,7 +57,9 @@ export async function testNetworkSpeed(): Promise<number> {
for (const endpoint of NETWORK_ENDPOINTS) { for (const endpoint of NETWORK_ENDPOINTS) {
try { try {
await fetch(endpoint, { // Use window.fetch explicitly for client-side network detection
// This is intentionally client-side code, not a SvelteKit load function
await window.fetch(endpoint, {
method: "GET", method: "GET",
cache: "no-cache", cache: "no-cache",
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),

23
src/lib/utils/nostrUtils.ts

@ -646,11 +646,17 @@ export function pubkeyToHue(pubkey: string): number {
* if they are not already prefixed and are not part of a hyperlink * if they are not already prefixed and are not part of a hyperlink
*/ */
export function prefixNostrAddresses(content: string): string { export function prefixNostrAddresses(content: string): string {
// Early exit: if content already has double-prefixed addresses, return as-is to prevent further damage
if (content.includes("nostr:nostr:")) {
return content;
}
// Regex to match Nostr addresses that are not already prefixed with "nostr:" // Regex to match Nostr addresses that are not already prefixed with "nostr:"
// and are not part of a markdown link or HTML link // and are not part of a markdown link or HTML link
// Must be followed by at least 20 alphanumeric characters to be considered an address // Must be followed by at least 20 alphanumeric characters to be considered an address
// Use negative lookbehind to prevent matching if "nostr:" is immediately before
const nostrAddressPattern = const nostrAddressPattern =
/\b(npub|nprofile|nevent|naddr|note)[a-zA-Z0-9]{20,}\b/g; /(?<!nostr:)\b(npub|nprofile|nevent|naddr|note)[a-zA-Z0-9]{20,}\b/g;
return content.replace(nostrAddressPattern, (match, offset) => { return content.replace(nostrAddressPattern, (match, offset) => {
// Check if this match is part of a markdown link [text](url) // Check if this match is part of a markdown link [text](url)
@ -682,12 +688,23 @@ export function prefixNostrAddresses(content: string): string {
} }
// Check if it's already prefixed with "nostr:" // Check if it's already prefixed with "nostr:"
// First check: is "nostr:" immediately before the match (last 6 characters)?
const textBeforeMatch = beforeMatch.slice(-6);
if (textBeforeMatch === "nostr:") {
return match; // Already prefixed with "nostr:", don't add another prefix
}
// Second check: is there "nostr:" anywhere before with no whitespace between it and the match?
const beforeNostr = beforeMatch.lastIndexOf("nostr:"); const beforeNostr = beforeMatch.lastIndexOf("nostr:");
if (beforeNostr !== -1) { if (beforeNostr !== -1) {
const textAfterNostr = beforeMatch.substring(beforeNostr + 6); const textAfterNostr = beforeMatch.substring(beforeNostr + 6);
if (!textAfterNostr.includes(" ")) { // If there's no whitespace or newline between "nostr:" and the match, it's already prefixed
return match; // Already prefixed if (!/[\s\n\r\t]/.test(textAfterNostr)) {
return match; // Already prefixed, don't add another prefix
}
} }
// Third check: does the match itself start with "nostr:"? (shouldn't happen, but safety check)
if (match.startsWith("nostr:")) {
return match; // Already prefixed, don't add another prefix
} }
// Additional check: ensure it's actually a valid Nostr address format // Additional check: ensure it's actually a valid Nostr address format

13
src/lib/utils/searchCache.ts

@ -98,6 +98,19 @@ class SearchCache {
size(): number { size(): number {
return this.cache.size; return this.cache.size;
} }
/**
* Clear cache entries for a specific search type
*/
clearType(searchType: string): void {
const keysToDelete: string[] = [];
for (const [key] of this.cache.entries()) {
if (key.startsWith(`${searchType}:`)) {
keysToDelete.push(key);
}
}
keysToDelete.forEach((key) => this.cache.delete(key));
}
} }
export const searchCache = new SearchCache(); export const searchCache = new SearchCache();

3
src/lib/utils/search_constants.ts

@ -66,6 +66,9 @@ export const SEARCH_LIMITS = {
/** Maximum events to fetch before processing in subscription search */ /** Maximum events to fetch before processing in subscription search */
SUBSCRIPTION_FETCH_LIMIT: 1000, SUBSCRIPTION_FETCH_LIMIT: 1000,
/** Maximum index events to fetch per relay for publication feed */
PUBLICATION_FEED_LIMIT: 10000,
} as const; } as const;
// Nostr event kind ranges // Nostr event kind ranges

21
src/lib/utils/subscription_search.ts

@ -339,6 +339,18 @@ export async function searchBySubscription(
ndk, ndk,
); );
console.log("subscription_search: Created search filter:", searchFilter); console.log("subscription_search: Created search filter:", searchFilter);
// AI-NOTE: Validate filter to prevent "No filters to merge!" error from NDK
if (!searchFilter.filter || Object.keys(searchFilter.filter).length === 0) {
console.error(
"subscription_search: Invalid or empty filter created:",
searchFilter,
);
throw new Error(
`Failed to create valid filter for search type: ${searchType}`,
);
}
const primaryRelaySet = createPrimaryRelaySet(searchType, ndk); const primaryRelaySet = createPrimaryRelaySet(searchType, ndk);
console.log( console.log(
"subscription_search: Created primary relay set with", "subscription_search: Created primary relay set with",
@ -1076,6 +1088,15 @@ function searchOtherRelaysInBackground(
Array.from(ndk.pool.relays.values()).map((r: any) => r.url), Array.from(ndk.pool.relays.values()).map((r: any) => r.url),
); );
// AI-NOTE: Validate filter before subscribing to prevent "No filters to merge!" error
if (!searchFilter.filter || Object.keys(searchFilter.filter).length === 0) {
console.warn(
"subscription_search: Invalid or empty filter, skipping background search",
searchFilter,
);
return Promise.resolve(createSearchResult(searchState, searchType, ""));
}
// Subscribe to events from other relays // Subscribe to events from other relays
const sub = ndk.subscribe( const sub = ndk.subscribe(
searchFilter.filter, searchFilter.filter,

5
src/lib/utils/websocket_utils.ts

@ -92,8 +92,9 @@ export async function fetchNostrEvent(
const { searchRelays, secondaryRelays } = await import("../consts.ts"); const { searchRelays, secondaryRelays } = await import("../consts.ts");
availableRelays = [...searchRelays, ...secondaryRelays]; availableRelays = [...searchRelays, ...secondaryRelays];
if (availableRelays.length === 0) { // Final fallback: use first secondary relay if all else fails
availableRelays = ["wss://thecitadel.nostr1.com"]; if (availableRelays.length === 0 && secondaryRelays.length > 0) {
availableRelays = [secondaryRelays[1]];
} }
} }

4
src/routes/about/+page.svelte

@ -27,9 +27,9 @@
<P class="mb-3"> <P class="mb-3">
Alexandria is a reader and writer for <A Alexandria is a reader and writer for <A
href="./publication/d/gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1" href="./publication/naddr/naddr1qvzqqqrcvgpzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyfhwue69uhkcmmrv9kxsmmnwsarwdehxuq3jamnwvaz7tmxwfjk2mrp0yh8xmmkvf5hgtngdaehgqgmwaehxw309a6xsetrd96xzer9dshxummnw3erztnrdakszxnhwden5te0w35x2en0wfjhxapwdehhxarjxyhxxmmdqyg8wumn8ghj7mn0wd68ytnvv9hxgqgswaehxw309ahx7um5wgh8w6twv5q3wamnwvaz7tmwdaehgu3wwdhhvcnfwshxsmmnwsq3zamnwvaz7tmwdaehgu3jxyhxxmmdqqyxu6mzd9cz6vp3cvtwu9"
>curated publications</A >curated publications</A
> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form > (in Asciidoc), wiki pages (Asciidoc), and long-form
articles (markup). It is produced by the <A articles (markup). It is produced by the <A
href="./publication/d/gitcitadel-project-documentation-by-stella-v-1" href="./publication/d/gitcitadel-project-documentation-by-stella-v-1"
>GitCitadel project team</A >GitCitadel project team</A

10
src/routes/contact/+page.svelte

@ -35,7 +35,7 @@
let submissionSuccess = $state(false); let submissionSuccess = $state(false);
let submissionError = $state(""); let submissionError = $state("");
let submittedEvent = $state<NDKEvent | null>(null); let submittedEvent = $state<NDKEvent | null>(null);
let issueLink = $state(""); let issueLink = $state("https://gitworkshop.dev/silberengel@gitcitadel.com/Alexandria/issues/");
let successfulRelays = $state<string[]>([]); let successfulRelays = $state<string[]>([]);
// Store form data when user needs to login // Store form data when user needs to login
@ -48,10 +48,6 @@
let user = $state($userStore); let user = $state($userStore);
userStore.subscribe((val) => (user = val)); userStore.subscribe((val) => (user = val));
// Repository event address from the task
const repoAddress =
"naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr";
// Use the new relay management system with anonymous relays as fallbacks // Use the new relay management system with anonymous relays as fallbacks
const allRelays = [ const allRelays = [
...$activeInboxRelays, ...$activeInboxRelays,
@ -198,7 +194,7 @@
// Create the issue link using the repository address // Create the issue link using the repository address
const noteId = nip19.noteEncode(event.id); const noteId = nip19.noteEncode(event.id);
issueLink = `https://gitcitadel.com/r/${repoAddress}/issues/${noteId}`; issueLink = `${issueLink}${noteId}`;
// Clear form and show success message // Clear form and show success message
clearForm(); clearForm();
@ -279,7 +275,7 @@
"GitCitadel", "GitCitadel",
ndk, ndk,
)} or you can view submitted issues on the <A )} or you can view submitted issues on the <A
href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" href="https://gitworkshop.dev/silberengel@gitcitadel.com/Alexandria/issues"
target="_blank">Alexandria repo page.</A target="_blank">Alexandria repo page.</A
> >
</P> </P>

91
src/routes/start/+page.svelte

@ -18,11 +18,11 @@
Alexandria opens up to the <button Alexandria opens up to the <button
class="underline text-primary-700 bg-transparent border-none p-0" class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("./")}>landing page</button onclick={() => goto("./")}>landing page</button
>, where the user can: login (top-right), select whether to only view the >, where the user can see the publications found on their own relays and the app's default relays.
publications hosted on the <A The user can also search the publications by title, author, or content.
href="https://thecitadel.nostr1.com/" The relays can be viewed on the <button
target="_blank">thecitadel document relay</A class="underline text-primary-700 bg-transparent border-none p-0"
> or add in their own relays, and scroll/search the publications. onclick={() => goto("/about/relay-stats")}>Relay Status</button> page.
</P> </P>
<div class="flex flex-col items-center space-y-4 my-4"> <div class="flex flex-col items-center space-y-4 my-4">
@ -32,20 +32,8 @@
class="image-border rounded-lg" class="image-border rounded-lg"
width="400" width="400"
/> />
<Img
src="/screenshots/YourRelays.png"
alt="Relay selection"
class="image-border rounded-lg"
width="400"
/>
</div> </div>
<P class="mb-3">
There is also the ability to view the publications as a diagram, if you
click on "Visualize", and to publish an e-book or other document (coming
soon).
</P>
<P class="mb-3"> <P class="mb-3">
If you click on a card, which represents a 30040 index event, the If you click on a card, which represents a 30040 index event, the
associated reading view opens to the publication. The app then pulls all associated reading view opens to the publication. The app then pulls all
@ -58,8 +46,7 @@
contents, which can be accessed from the floating icon top-left in the contents, which can be accessed from the floating icon top-left in the
reading view. This allows for navigation within the publication. reading view. This allows for navigation within the publication.
Publications of type "blog" have a ToC which emphasizes that each entry is Publications of type "blog" have a ToC which emphasizes that each entry is
a blog post. (This functionality has been temporarily disabled, but the a blog post.
TOC is visible.)
</P> </P>
<div class="flex flex-col items-center space-y-4 my-4"> <div class="flex flex-col items-center space-y-4 my-4">
@ -77,6 +64,23 @@
/> />
</div> </div>
<P class="mb-3">
There is the ability to view and navigate the publications as a diagram, if you
click on <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/visualize")}>Visualize</button>.
</P>
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/Visualization.png"
alt="Visualization page"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h2" class="h-leather mt-4 mb-2">Typical use cases</Heading> <Heading tag="h2" class="h-leather mt-4 mb-2">Typical use cases</Heading>
<Heading tag="h3" class="h-leather mb-3">For e-books</Heading> <Heading tag="h3" class="h-leather mb-3">For e-books</Heading>
@ -107,23 +111,12 @@
<Heading tag="h3" class="h-leather mb-3">For scientific papers</Heading> <Heading tag="h3" class="h-leather mb-3">For scientific papers</Heading>
<P class="mb-3"> <P class="mb-3">
Alexandria will also display research papers with Asciimath and LaTeX Alexandria displays research papers with Asciimath and LaTeX
embedding, and the normal advanced formatting options available for embedding, and the normal advanced formatting options available for
Asciidoc. In addition, we will be implementing special citation events, Asciidoc. In addition, we will be implementing special citation events,
which will serve as an alternative or addition to the normal footnotes. which will serve as an alternative or addition to the normal footnotes.
</P> </P>
<P class="mb-3">
Correctly displaying such papers, integrating citations, and allowing them
to be reviewed (with kind 1111 comments), and annotated (with highlights)
by users, is the focus of the second minor version, Euler.
</P>
<P class="mb-3">
Euler will also pioneer the HTTP-based (rather than websocket-based)
e-paper compatible version of the web app.
</P>
<P class="mb-3"> <P class="mb-3">
An example of a research paper is <a An example of a research paper is <a
href="/publication/d/less-partnering-less-children-or-both-by-julia-hellstrand-v-1" href="/publication/d/less-partnering-less-children-or-both-by-julia-hellstrand-v-1"
@ -177,4 +170,40 @@
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>
<Heading tag="h3" class="h-leather mb-3">For long-form articles</Heading>
<P class="mb-3">
We also display long-form articles (kind 30023), which are stand-alone documents. An example is
the article that launched this project's concept, <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/publication/naddr/naddr1qvzqqqr4gupzphtxf40yq9jr82xdd8cqtts5szqyx5tcndvaukhsvfmduetr85ceqyw8wumn8ghj7cmgwf5hxarsd9kxctnwdaehgu339e3k7mf0qyf8wumn8ghj7mn0wd68yv339e3k7mf0qqxnzde38yerqdpexsmnyvek0wj7rv")}> Project Alexandria</button>.
Long-form articles utilize simple Markdown formatting.
</P>
<Heading tag="h3" class="h-leather mb-3">Additional features</Heading>
<P class="mb-3">
The app also has a powerful search interface, a composition form, and a universal publisher.
It comes along wtih two other apps, Jumble and Wikistr. <a href="https://jumble.imwald.eu/notes/naddr1qvzqqqr4tqpzq4ekxjmysc6vhtgs7fz3wasgn63ppyxegplhzh5rc4mmgcg6umkuqyw8wumn8ghj7argv43kjarpv3jkctnwdaehgu339e3k7mf0qpg9g6r9942xzmr994hkvt2sv46x2u3d2fskycnfwskkzmny948hg6r9wfej6cne94px2ct5wf5hst2sda68getj94mz6st4v35k7cn0da4hxttxwfhk6t2vd938yetkdauqrhxcs8" target="_blank">Jumble</a> is a meant as a daily driver, but also supports
basic Alexandria features. <a href="https://wikistr.imwald.eu/jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition*fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1" target="_blank">Wikistr</a> is
a more advanced app, with a focus on wiki pages and collaboration. It contains
the exporting function, utilizing an Asciidoctor server to download publications as Asciidoc, PDFs, EPUB, or HTML files.
</P>
<P class="mb-3">
Thank you for your time. Feel free to explore the app and see how it works, and make sure to <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/about")}>save our contact information</button>!
</P>
<Img src="https://raw.githubusercontent.com/ShadowySupercode/gitcitadel/master/logos/GitCitadel_Logo.png" alt="GitCitadel Logo" class="image-border rounded-lg" width="200" />
<P class="mb-3">
If you have any questions or feedback, please feel free to <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/contact")}>contact us</button>.
</P>
</div> </div>

BIN
static/screenshots/JaneEyre.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 296 KiB

BIN
static/screenshots/LandingPage.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 798 KiB

BIN
static/screenshots/ToC_blog.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 590 KiB

BIN
static/screenshots/ToC_normal.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 284 KiB

BIN
static/screenshots/Visualization.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
static/screenshots/YourRelays.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Loading…
Cancel
Save