Browse Source

Interim changes

master
Silberengel 10 months ago
parent
commit
b35774b9a0
  1. 4
      .vscode/settings.json
  2. 57
      README.md
  3. 206
      package-lock.json
  4. 8
      package.json
  5. 705
      src/app.css
  6. 80
      src/lib/components/EmoticonPicker.svelte
  7. 16
      src/lib/components/EventLimitControl.svelte
  8. 11
      src/lib/components/EventRenderLevelLimit.svelte
  9. 23
      src/lib/components/Login.svelte
  10. 49
      src/lib/components/LoginModal.svelte
  11. 238
      src/lib/components/MarkdownForm.svelte
  12. 24
      src/lib/components/Navigation.svelte
  13. 10
      src/lib/components/Preview.svelte
  14. 81
      src/lib/components/Publication.svelte
  15. 4
      src/lib/components/PublicationFeed.svelte
  16. 2
      src/lib/components/PublicationHeader.svelte
  17. 4
      src/lib/components/PublicationSection.svelte
  18. 8
      src/lib/components/Toc.svelte
  19. 8
      src/lib/parser.ts
  20. 11
      src/lib/types/markdown-it-plugins.d.ts
  21. 4
      src/lib/types/svelte-heros.d.ts
  22. 378
      src/lib/utils/advancedMarkdownParser.ts
  23. 182
      src/lib/utils/basicMarkdownParser.ts
  24. 85
      src/lib/utils/emoticons.ts
  25. 391
      src/lib/utils/markdown/markdownItParser.ts
  26. 4
      src/lib/utils/markdown/markdownTestfile.md
  27. 12
      src/routes/[...catchall]/+page.svelte
  28. 123
      src/routes/about/+page.svelte
  29. 391
      src/routes/contact/+page.svelte
  30. 125
      src/routes/contact/ContactForm.svelte
  31. 38
      src/routes/new/edit/+page.svelte
  32. 19
      src/routes/visualize/+page.svelte

4
.vscode/settings.json vendored

@ -1,3 +1,5 @@
{ {
"editor.tabSize": 2 "editor.tabSize": 2,
"css.validate": false,
"tailwindCSS.validate": true
} }

57
README.md

@ -114,4 +114,59 @@ npm run test
For the Playwright end-to-end (e2e) tests: For the Playwright end-to-end (e2e) tests:
```bash ```bash
npx playwright test npx playwright test
``` ```
## Markdown Support
In addition to the [standard Asciidoc](https://asciidoc.org/) support for publication and wiki events, Alexandria supports a rich set of Markdown features for content creation:
### Text Formatting
- **Bold**: `*text*` or `**text**`
- **Italic**: `_text_` or `__text__`
- **Strikethrough**: `~text~` or `~~text~~`
- **Inline Code**: `` `code` ``
- **Links**: `[text](url)`
- **Images**: `![alt](url)`
### Structure
- **Headings**:
- ATX style: `# Heading 1` through `###### Heading 6`
- Setext style:
```markdown
Heading 1
========
```
- **Lists**:
- Unordered: `* item`
- Ordered: `1. item`, `2. item`
- Nested lists supported
- **Code Blocks with language-specific highlighting**:
```markdown
```javascript
const code = 'example';
```
```
- **Blockquotes**: `> Quote text`
- **Horizontal Rules**: `---`
- **Tables**:
```markdown
| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
```
### Special Features
- **Footnotes**: `[^1]` and `[^1]: definition`
- **Emojis**: `:smile:`
- **Hashtags**: `#tag`
- **Nostr Identifiers**:
- Profiles: `npub...` or `nprofile...`
- Notes: `note...`, `nevent...`, or `naddr...`
### Media Support
- **YouTube Videos**: Automatically embedded
- **Video Files**: mp4, webm, mov, avi
- **Audio Files**: mp3, wav, ogg, m4a
- **Images**: jpg, jpeg, gif, png, webp
All media URLs are automatically cleaned of tracking parameters for privacy.

206
package-lock.json generated

@ -15,19 +15,25 @@
"@tailwindcss/typography": "0.5.x", "@tailwindcss/typography": "0.5.x",
"asciidoctor": "3.0.x", "asciidoctor": "3.0.x",
"d3": "^7.9.0", "d3": "^7.9.0",
"easymde": "^2.20.0",
"he": "1.2.x", "he": "1.2.x",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"nostr-tools": "2.10.x" "markdown-it": "^14.1.0",
"markdown-it-emoji": "^3.0.0",
"markdown-it-footnote": "^4.0.0",
"nostr-tools": "2.10.x",
"svelte-heros": "^7.0.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@sveltejs/adapter-auto": "3.x", "@sveltejs/adapter-auto": "3.x",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/adapter-static": "3.x", "@sveltejs/adapter-static": "3.x",
"@sveltejs/kit": "2.x", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "4.x", "@sveltejs/vite-plugin-svelte": "4.x",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/he": "1.2.x", "@types/he": "1.2.x",
"@types/markdown-it": "^14.1.2",
"@types/node": "22.x", "@types/node": "22.x",
"autoprefixer": "10.x", "autoprefixer": "10.x",
"eslint-plugin-svelte": "2.x", "eslint-plugin-svelte": "2.x",
@ -65,7 +71,6 @@
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
@ -1526,6 +1531,15 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20" "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20"
} }
}, },
"node_modules/@types/codemirror": {
"version": "5.60.15",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz",
"integrity": "sha512-dTOvwEQ+ouKJ/rE9LT1Ue2hmP6H1mZv5+CCnNWu2qtiOe2LQa9lCprEY20HxiDmV/Bxh+dXjywmy5aKvoGjULA==",
"license": "MIT",
"dependencies": {
"@types/tern": "*"
}
},
"node_modules/@types/cookie": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@ -1821,7 +1835,6 @@
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": { "node_modules/@types/geojson": {
@ -1846,6 +1859,37 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/marked": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.2.tgz",
"integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==",
"license": "MIT"
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.10.2", "version": "22.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
@ -1863,6 +1907,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.6.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.6.tgz",
@ -2003,7 +2056,6 @@
"version": "8.14.0", "version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
@ -2026,7 +2078,6 @@
"version": "1.4.13", "version": "1.4.13",
"resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz", "resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz",
"integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==", "integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"acorn": ">=8.9.0" "acorn": ">=8.9.0"
@ -2131,15 +2182,12 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true, "license": "Python-2.0"
"license": "Python-2.0",
"peer": true
}, },
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -2237,7 +2285,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -2520,6 +2567,21 @@
"wrap-ansi": "^7.0.0" "wrap-ansi": "^7.0.0"
} }
}, },
"node_modules/codemirror": {
"version": "5.65.19",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.19.tgz",
"integrity": "sha512-+aFkvqhaAVr1gferNMuN8vkTSrWIFvzlMV9I2KBLCWS2WpZ2+UAkZjlMZmEuT+gcXTi6RrGQCkWq1/bDtGqhIA==",
"license": "MIT"
},
"node_modules/codemirror-spell-checker": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz",
"integrity": "sha512-2Tl6n0v+GJRsC9K3MLCdLaMOmvWL0uukajNJseorZJsslaxZyZMgENocPU8R0DyoTAiKsyqiemSOZo7kjGV0LQ==",
"license": "MIT",
"dependencies": {
"typo-js": "*"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -3134,6 +3196,19 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/easymde": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.20.0.tgz",
"integrity": "sha512-V1Z5f92TfR42Na852OWnIZMbM7zotWQYTddNaLYZFVKj7APBbyZ3FYJ27gBw2grMW3R6Qdv9J8n5Ij7XRSIgXQ==",
"license": "MIT",
"dependencies": {
"@types/codemirror": "^5.60.10",
"@types/marked": "^4.0.7",
"codemirror": "^5.65.15",
"codemirror-spell-checker": "1.1.2",
"marked": "^4.1.0"
}
},
"node_modules/ejs": { "node_modules/ejs": {
"version": "3.1.10", "version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@ -3162,6 +3237,18 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -3513,7 +3600,6 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/esniff": { "node_modules/esniff": {
@ -3568,7 +3654,6 @@
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.3.2.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.3.2.tgz",
"integrity": "sha512-C4PXusxYhFT98GjLSmb20k9PREuUdporer50dhzGuJu9IJXktbMddVCMLAERl5dAHyAi73GWWCE4FVHGP1794g==", "integrity": "sha512-C4PXusxYhFT98GjLSmb20k9PREuUdporer50dhzGuJu9IJXktbMddVCMLAERl5dAHyAi73GWWCE4FVHGP1794g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15" "@jridgewell/sourcemap-codec": "^1.4.15"
@ -4339,7 +4424,6 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "^1.0.6" "@types/estree": "^1.0.6"
@ -4553,11 +4637,19 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/locate-character": { "node_modules/locate-character": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/locate-path": { "node_modules/locate-path": {
@ -4612,12 +4704,52 @@
"version": "0.30.17", "version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.0"
} }
}, },
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it-emoji": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/markdown-it-emoji/-/markdown-it-emoji-3.0.0.tgz",
"integrity": "sha512-+rUD93bXHubA4arpEZO3q80so0qgoFJEKRkRbjKX8RTdca89v2kfyF+xR3i2sQTwql9tpPZPOQN5B+PunspXRg==",
"license": "MIT"
},
"node_modules/markdown-it-footnote": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/markdown-it-footnote/-/markdown-it-footnote-4.0.0.tgz",
"integrity": "sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==",
"license": "MIT"
},
"node_modules/marked": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
"integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -4627,6 +4759,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -5550,6 +5688,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -6009,7 +6156,6 @@
"version": "5.14.4", "version": "5.14.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.14.4.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.14.4.tgz",
"integrity": "sha512-2iR/UHHA2Dsldo4JdXDcdqT+spueuh+uNYw1FoTKBbpnFEECVISeqSo0uubPS4AfBE0xI6u7DGHxcdq3DTDmoQ==", "integrity": "sha512-2iR/UHHA2Dsldo4JdXDcdqT+spueuh+uNYw1FoTKBbpnFEECVISeqSo0uubPS4AfBE0xI6u7DGHxcdq3DTDmoQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.3.0", "@ampproject/remapping": "^2.3.0",
@ -6160,6 +6306,19 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/svelte-heros": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/svelte-heros/-/svelte-heros-7.0.2.tgz",
"integrity": "sha512-JsnGvthDn2EJq+1JbRqebHnUIJTLMFtPJt7IpjdHoY2Ei24DQOdQ9QQDmFZ/7oUB6/d6Wwne4G+vnp4mvvYSDg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0",
"npm": ">=7.0.0"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/svg.draggable.js": { "node_modules/svg.draggable.js": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz", "resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz",
@ -6522,6 +6681,18 @@
"integrity": "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA==", "integrity": "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/typo-js": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.2.5.tgz",
"integrity": "sha512-F45vFWdGX8xahIk/sOp79z2NJs8ETMYsmMChm9D5Hlx3+9j7VnCyQyvij5MOCrNY3NNe8noSyokRjQRfq+Bc7A==",
"license": "BSD-3-Clause"
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/uglify-js": { "node_modules/uglify-js": {
"version": "3.19.3", "version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@ -7023,7 +7194,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"dev": true,
"license": "MIT" "license": "MIT"
} }
} }

8
package.json

@ -21,9 +21,14 @@
"@tailwindcss/typography": "0.5.x", "@tailwindcss/typography": "0.5.x",
"asciidoctor": "3.0.x", "asciidoctor": "3.0.x",
"d3": "^7.9.0", "d3": "^7.9.0",
"easymde": "^2.20.0",
"he": "1.2.x", "he": "1.2.x",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"nostr-tools": "2.10.x" "markdown-it": "^14.1.0",
"markdown-it-emoji": "^3.0.0",
"markdown-it-footnote": "^4.0.0",
"nostr-tools": "2.10.x",
"svelte-heros": "^7.0.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
@ -34,6 +39,7 @@
"@sveltejs/vite-plugin-svelte": "4.x", "@sveltejs/vite-plugin-svelte": "4.x",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/he": "1.2.x", "@types/he": "1.2.x",
"@types/markdown-it": "^14.1.2",
"@types/node": "22.x", "@types/node": "22.x",
"autoprefixer": "10.x", "autoprefixer": "10.x",
"eslint-plugin-svelte": "2.x", "eslint-plugin-svelte": "2.x",

705
src/app.css

@ -2,361 +2,610 @@
@import './styles/publications.css'; @import './styles/publications.css';
@import './styles/visualize.css'; @import './styles/visualize.css';
/* Custom styles */ /* Base styles */
@layer base { @layer base {
.leather { /* Common text colors and typography */
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-200; .text-primary {
@apply text-gray-800 dark:text-gray-300;
} }
.btn-leather.text-xs { .text-secondary {
@apply px-2 py-1; @apply text-gray-600 dark:text-gray-400;
} }
.btn-leather.text-xs svg { .text-sm-secondary {
@apply h-3 w-3; @apply text-sm text-gray-600 dark:text-gray-400;
} }
.btn-leather.text-sm { /* Common link styles */
@apply px-3 py-2; .link {
@apply underline cursor-pointer hover:text-primary-700 dark:hover:text-primary-400;
} }
.btn-leather.text-sm svg { .link-secondary {
@apply h-4 w-4; @apply text-gray-800 hover:text-primary-700 dark:text-gray-300 dark:hover:text-primary-400;
} }
div[role='tooltip'] button.btn-leather { /* Common container styles */
@apply hover:text-primary-400 dark:hover:text-primary-500 hover:border-primary-400 dark:hover:border-primary-500 hover:bg-gray-200 dark:hover:bg-gray-700; .container-base {
@apply w-full flex justify-center;
} }
.image-border { .container-main {
@apply border border-primary-700; @apply main-leather flex flex-col space-y-4 max-w-2xl w-full my-6 px-4;
} }
div.card-leather { .container-main-wide {
@apply shadow-none text-primary-1000 border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700; @apply container-main max-w-3xl;
@apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
} }
div.card-leather h1, /* Common form styles */
div.card-leather h2, .form-base {
div.card-leather h3, @apply space-y-4 mt-6;
div.card-leather h4,
div.card-leather h5,
div.card-leather h6 {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
} }
div.card-leather .font-thin { .form-input {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-200 dark:hover:text-primary-200; @apply w-full;
} }
main { .form-textarea {
@apply max-w-full; @apply w-full h-full resize-none border-0 focus:ring-0 bg-white dark:bg-gray-800 p-4;
} }
main.main-leather, .form-preview {
article.article-leather { @apply absolute inset-0 p-4 prose dark:prose-invert max-w-none bg-white dark:bg-gray-800 prose-content;
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300;
} }
div.note-leather, /* Common button styles */
p.note-leather, .btn-base {
section.note-leather { @apply btn-leather !w-fit;
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 p-2 rounded;
} }
div.note-leather:hover:not(:has(.note-leather:hover)), .btn-secondary {
p.note-leather:hover:not(:has(.note-leather:hover)), @apply btn-base outline;
section.note-leather:hover:not(:has(.note-leather:hover)) {
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
} }
h1.h-leather, .btn-sm {
h2.h-leather, @apply text-sm px-3 py-2;
h3.h-leather,
h4.h-leather,
h5.h-leather,
h6.h-leather {
@apply text-gray-800 dark:text-gray-300;
} }
h1.h-leather { .btn-xs {
@apply text-4xl font-bold; @apply text-xs px-2 py-1;
} }
h2.h-leather { /* Common message styles */
@apply text-3xl font-bold; .message-base {
@apply p-4 mb-4 text-sm rounded-lg;
} }
h3.h-leather { .message-success {
@apply text-2xl font-bold; @apply message-base bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 relative;
} }
h4.h-leather { .message-error {
@apply text-xl font-bold; @apply message-base text-red-700 bg-red-100;
} }
h5.h-leather { /* Common image styles */
@apply text-lg font-semibold; .image-container {
@apply flex flex-col items-center space-y-4 my-4;
} }
h6.h-leather { .image-bordered {
@apply text-base font-semibold; @apply border border-primary-700 rounded-lg;
} }
div.modal-leather>div { /* Common heading styles */
@apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100 dark:border-primary-600; .heading-base {
@apply text-gray-800 dark:text-gray-300 font-bold;
} }
div.modal-leather>div>h1, .heading-1 {
div.modal-leather>div>h2, @apply heading-base text-4xl;
div.modal-leather>div>h3,
div.modal-leather>div>h4,
div.modal-leather>div>h5,
div.modal-leather>div>h6 {
@apply text-gray-800 hover:text-gray-800 dark:text-gray-300 dark:hover:text-gray-300;
} }
div.modal-leather button { .heading-2 {
@apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; @apply heading-base text-3xl;
} }
nav.navbar-leather { .heading-3 {
@apply bg-primary-0 dark:bg-primary-1000 z-10; @apply heading-base text-2xl;
} }
nav.navbar-leather svg { .heading-4 {
@apply fill-gray-800 hover:fill-primary-400 dark:fill-gray-300 dark:hover:fill-primary-500; @apply heading-base text-xl;
} }
nav.navbar-leather h1, .heading-5 {
nav.navbar-leather h2, @apply heading-base text-lg font-semibold;
nav.navbar-leather h3,
nav.navbar-leather h4,
nav.navbar-leather h5,
nav.navbar-leather h6 {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
} }
aside.sidebar-leather>div { .heading-6 {
@apply bg-primary-0 dark:bg-primary-1000; @apply heading-base text-base font-semibold;
} }
a.sidebar-item-leather { /* Update link colors for better contrast */
@apply hover:bg-primary-100 dark:hover:bg-primary-800; .link {
@apply underline cursor-pointer hover:text-primary-700 dark:hover:text-primary-400;
}
/* Update secondary text colors for better contrast */
.text-gray-500 {
@apply text-gray-600 dark:text-gray-400;
}
/* Update link colors in notes */
.note-leather p a {
@apply underline hover:text-primary-700 dark:hover:text-primary-400;
}
/* Update link colors in lists */
.ol-leather li a,
.ul-leather li a {
@apply text-gray-800 hover:text-primary-700 dark:text-gray-300 dark:hover:text-primary-400;
}
} }
div.skeleton-leather div { /* Component styles */
@apply bg-primary-100 dark:bg-primary-800; @layer components {
/* Page containers */
.about-container,
.contact-container,
.edit-container,
.visualize-container,
.not-found-container {
@apply container-base;
} }
div.textarea-leather { .about-main,
@apply bg-primary-0 dark:bg-primary-1000; .contact-main {
@apply container-main-wide;
} }
div.textarea-leather>div:nth-child(1), .edit-main {
div.toolbar-leather { @apply container-main;
@apply border-none;
} }
div.textarea-leather>div:nth-child(2) { /* About page */
@apply bg-primary-0 dark:bg-primary-1000; .about-header {
@apply flex justify-between items-center;
} }
div.textarea-leather, .about-version {
div.textarea-leather textarea { @apply text-sm-secondary bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-nowrap;
@apply text-gray-800 dark:text-gray-300;
} }
div.tooltip-leather { .about-image-container {
@apply text-gray-800 dark:text-gray-300; @apply image-container;
} }
div[role='tooltip'] button.btn-leather .tooltip-leather { .about-image {
@apply bg-primary-100 dark:bg-primary-800; @apply image-bordered;
} }
/* Network visualization */ /* Contact page */
.network-link-leather { .contact-form {
@apply stroke-primary-200 fill-primary-200; @apply form-base;
} }
.network-node-leather { .contact-form-input {
@apply stroke-primary-600; @apply form-input;
} }
.network-node-content { .contact-form-textarea-container {
@apply fill-primary-100; @apply relative border border-gray-300 dark:border-gray-600 rounded-lg transition-all duration-200 sm:w-[95vw] md:w-full;
} }
}
/* Utilities can be applied via the @apply directive. */ .contact-form-textarea-container.expanded {
@layer utilities { @apply h-[800px];
.h-leather {
@apply text-gray-800 dark:text-gray-300 pt-4;
} }
.h1-leather { .contact-form-textarea-container.collapsed {
@apply text-4xl font-bold; @apply h-[200px];
} }
.h2-leather { .contact-form-tabs {
@apply text-3xl font-bold; @apply flex flex-wrap -mb-px text-sm font-medium text-center;
} }
.h3-leather { .contact-form-tab {
@apply text-2xl font-bold; @apply inline-block p-4 rounded-t-lg;
} }
.h4-leather { .contact-form-tab.active {
@apply text-xl font-bold; @apply border-b-2 border-primary-600 text-primary-600;
} }
.h5-leather { .contact-form-tab.inactive {
@apply text-lg font-semibold; @apply hover:text-gray-600 hover:border-gray-300;
} }
.h6-leather { .contact-form-tab-content {
@apply text-base font-semibold; @apply absolute inset-0;
} }
/* Lists */ .contact-form-textarea {
.ol-leather li a, @apply form-textarea;
.ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
} }
.link { .contact-form-preview {
@apply underline cursor-pointer hover:text-primary-400 dark:hover:text-primary-500; @apply form-preview;
} }
}
@layer components { .contact-form-toggle {
@apply absolute bottom-2 right-2 z-10 opacity-60 hover:opacity-100;
}
.contact-form-actions {
@apply flex justify-end space-x-4;
}
.contact-form-success {
@apply message-success;
}
.contact-form-error {
@apply message-error;
}
/* Edit page */
.edit-preview-form {
@apply border border-gray-400 dark:border-gray-600 rounded-lg flex flex-col space-y-2 h-fit;
}
.edit-preview-toolbar {
@apply toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800;
}
/* Visualize page */
.visualize-header {
@apply flex items-center mb-4;
}
.visualize-loading {
@apply flex justify-center items-center h-64;
}
.visualize-error {
@apply message-error;
}
.visualize-error-title {
@apply font-bold mb-2;
}
.visualize-error-retry {
@apply btn-base text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800;
}
/* Legend */ /* 404 page */
.leather-legend { .not-found-container {
@apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 rounded; @apply leather flex flex-col items-center justify-center min-h-screen text-center px-4;
@apply shadow-none text-primary-1000 border border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700;
@apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
} }
/* Tooltip */ .not-found-title {
.tooltip-leather { @apply heading-1 mb-4;
@apply fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 border border-gray-200 dark:border-gray-700 transition-colors duration-200;
max-width: 400px;
z-index: 1000;
} }
.leather-legend button { .not-found-message {
@apply dark:text-white; @apply text-secondary mb-6;
} }
/* Rendered publication content */ .not-found-actions {
.publication-leather { @apply flex space-x-4;
}
.not-found-button {
@apply btn-base;
}
/* Publication component styles */
.publication-container {
@apply flex flex-col space-y-4; @apply flex flex-col space-y-4;
}
h1, .publication-header {
h2, @apply flex flex-col space-y-2;
h3, }
h4,
h5,
h6 {
@apply h-leather;
}
h1 { .publication-title {
@apply h1-leather; @apply heading-1;
} }
h2 { .publication-metadata {
@apply h2-leather; @apply text-sm-secondary;
} }
h3 { .publication-content {
@apply h3-leather; @apply flex flex-col space-y-4;
} }
h4 { .publication-section {
@apply h4-leather; @apply flex flex-col space-y-4;
} }
h5 { .publication-section-title {
@apply h5-leather; @apply heading-2;
} }
h6 { .publication-section-content {
@apply h6-leather; @apply text-secondary;
} }
div { /* Login component styles */
@apply flex flex-col space-y-4; .login-container {
} @apply flex flex-col items-center justify-center min-h-screen p-4;
}
.olist { .login-form {
@apply flex flex-col space-y-4; @apply form-base w-full max-w-md;
}
ol { .login-input {
@apply ol-leather list-decimal px-6 flex flex-col space-y-2; @apply form-input;
}
li { .login-button {
.paragraph { @apply btn-base w-full;
@apply py-2; }
}
}
}
}
.ulist { .login-error {
@apply flex flex-col space-y-4; @apply message-error;
}
ul { /* Modal component styles */
@apply ul-leather list-disc px-6 flex flex-col space-y-2; .modal-container {
@apply fixed inset-0 z-50 overflow-y-auto;
}
li { .modal-backdrop {
.paragraph { @apply fixed inset-0 bg-black bg-opacity-50 transition-opacity;
@apply py-2; }
}
}
}
}
a { .modal-content {
@apply link; @apply relative bg-white dark:bg-gray-800 rounded-lg shadow-xl mx-auto my-8 max-w-lg p-6;
} }
.imageblock { .modal-header {
@apply flex flex-col items-center; @apply flex items-center justify-between mb-4;
}
.title { .modal-title {
@apply text-sm text-center; @apply heading-2;
} }
}
.stemblock { .modal-close {
@apply bg-gray-100 dark:bg-gray-900 p-4 rounded-lg; @apply text-gray-400 hover:text-gray-500 dark:hover:text-gray-300;
} }
.literalblock { .modal-body {
pre { @apply text-secondary;
@apply text-wrap; }
}
}
table { .modal-footer {
@apply w-full overflow-x-auto; @apply flex justify-end space-x-4 mt-6;
}
caption { /* Navigation component styles */
@apply text-sm; .nav-container {
} @apply bg-primary-0 dark:bg-primary-1000 z-10;
}
thead, .nav-content {
tbody { @apply flex items-center justify-between p-4;
}
th, .nav-brand {
td { @apply heading-3;
@apply border border-gray-200 dark:border-gray-700; }
}
} .nav-links {
} @apply flex items-center space-x-4;
}
.nav-link {
@apply link-secondary;
}
/* Table of Contents styles */
.toc-container {
@apply fixed top-4 left-4 z-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 max-w-xs;
}
.toc-title {
@apply heading-3 mb-2;
}
.toc-list {
@apply space-y-2;
}
.toc-item {
@apply link-secondary text-sm;
}
/* Event control styles */
.event-control-container {
@apply flex items-center space-x-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg;
}
.event-control-label {
@apply text-sm-secondary;
}
.event-control-input {
@apply form-input max-w-24;
}
/* Preview component styles */
.preview-container {
@apply flex flex-col space-y-4;
}
.preview-header {
@apply flex items-center justify-between;
}
.preview-title {
@apply heading-2;
}
.preview-content {
@apply text-secondary;
}
/* Publication Feed styles */
.feed-container {
@apply flex flex-col space-y-4;
}
.feed-header {
@apply flex items-center justify-between;
}
.feed-title {
@apply heading-2;
}
.feed-list {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4;
}
.feed-item {
@apply card-leather p-4;
}
.feed-item-title {
@apply heading-3;
}
.feed-item-metadata {
@apply text-sm-secondary mt-2;
}
}
/* Keep existing utility classes */
@layer utilities {
.h-leather {
@apply text-gray-800 dark:text-gray-300 pt-4;
}
.h1-leather {
@apply text-4xl font-bold;
}
.h2-leather {
@apply text-3xl font-bold;
}
.h3-leather {
@apply text-2xl font-bold;
}
.h4-leather {
@apply text-xl font-bold;
}
.h5-leather {
@apply text-lg font-semibold;
}
.h6-leather {
@apply text-base font-semibold;
}
/* Lists */
.ol-leather li a,
.ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
} }
.link {
@apply underline cursor-pointer hover:text-primary-400 dark:hover:text-primary-500;
}
.avatar-small {
@apply w-6 h-6 cursor-pointer;
}
.toc-toggle-btn {
@apply fixed top-20 left-4 h-6 w-6;
}
.no-padding {
@apply p-0;
}
.flex { @apply flex; }
.flex-col { @apply flex-col; }
.flex-row { @apply flex-row; }
.space-x-2 { @apply space-x-2; }
.space-y-2 { @apply space-y-2; }
.justify-between { @apply justify-between; }
.justify-end { @apply justify-end; }
.justify-start { @apply justify-start; }
.w-full { @apply w-full; }
.min-w-fit { @apply min-w-fit; }
.visible { @apply visible; }
.invisible { @apply invisible; }
.break-words { @apply break-words; }
.text-wrap { @apply text-wrap; }
.whitespace-normal { @apply whitespace-normal; }
.w-lg { @apply w-[32rem]; }
.col { @apply flex-col; }
.align-middle { @apply align-middle; }
.max-h-36 { @apply max-h-36; }
.max-w-24 { @apply max-w-24; }
.overflow-hidden { @apply overflow-hidden; }
.rounded { @apply rounded; }
.object-cover { @apply object-cover; }
.text-lg { @apply text-lg; }
.text-base { @apply text-base; }
.font-bold { @apply font-bold; }
.font-normal { @apply font-normal; }
.font-thin { @apply font-thin; }
.line-clamp-2 { @apply line-clamp-2; }
.mb-2 { @apply mb-2; }
.mb-3 { @apply mb-3; }
.mt-4 { @apply mt-4; }
.mr-2 { @apply mr-2; }
.flex-1 { @apply flex-1; }
.min-h-0 { @apply min-h-0; }
.relative { @apply relative; }
.border-b { @apply border-b; }
.border-gray-300 { @apply border-gray-300; }
.dark\:border-gray-600 { @apply dark:border-gray-600; }
.text-gray-500 { @apply text-gray-500; }
.text-red-500 { @apply text-red-500; }
.w-5 { @apply w-5; }
.h-5 { @apply h-5; }
.items-center { @apply items-center; }
.font-semibold { @apply font-semibold; }
.mt-1 { @apply mt-1; }
.hover\:underline:hover { @apply underline; }
.text-primary-600 { @apply text-primary-600; }
.dark\:text-primary-500 { @apply dark:text-primary-500; }
.break-all { @apply break-all; }
.text-sm { @apply text-sm; }
.list-disc { @apply list-disc; }
.list-inside { @apply list-inside; }
.text-success-700 { @apply text-success-700; }
.dark\:text-success-300 { @apply dark:text-success-300; }
}
.backdrop {
@apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50;
}
.Modal {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 max-w-lg w-full;
}
.toc {
@apply p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-xs mx-auto;
}
.toc-title {
text-align: center;
} }

80
src/lib/components/EmoticonPicker.svelte

@ -0,0 +1,80 @@
<script lang="ts">
import { heroiconEmoticons, unicodeEmojis } from '../utils/emoticons';
import { createEventDispatcher } from 'svelte';
import { onMount } from 'svelte';
const dispatch = createEventDispatcher();
let search = '';
let showMore = false;
let filteredHeroicons = heroiconEmoticons;
let filteredUnicode = unicodeEmojis;
function handleSelect(shortcode: string) {
dispatch('select', { shortcode });
}
function filterEmoticons() {
const s = search.trim().toLowerCase();
filteredHeroicons = heroiconEmoticons.filter(e =>
e.name.toLowerCase().includes(s) || e.shortcode.includes(s)
);
filteredUnicode = unicodeEmojis.filter(e =>
e.name.toLowerCase().includes(s) || e.shortcode.includes(s)
);
}
$: filterEmoticons();
</script>
<div class="emoticon-picker bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg p-2 w-72">
<input
type="text"
class="emoticon-search mb-2 w-full px-2 py-1 rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-sm"
placeholder="Search emoticons..."
bind:value={search}
on:input={filterEmoticons}
autocomplete="off"
/>
<div class="flex flex-wrap gap-2 mb-2">
{#each filteredHeroicons as emoticon}
<button
type="button"
class="emoticon-btn flex flex-col items-center justify-center p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
title={emoticon.name + ' ' + emoticon.shortcode}
on:click={() => handleSelect(emoticon.shortcode)}
>
<svelte:component this={emoticon.component} class="w-6 h-6 text-gray-700 dark:text-gray-200" />
<span class="text-xs text-gray-500">{emoticon.shortcode}</span>
</button>
{/each}
</div>
<button
type="button"
class="emoticon-more-btn w-full text-center py-1 text-xs text-gray-600 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
on:click={() => showMore = !showMore}
>
{showMore ? 'Hide more...' : '... more'}
</button>
{#if showMore}
<div class="flex flex-wrap gap-2 mt-2 max-h-32 overflow-y-auto">
{#each filteredUnicode as emoticon}
<button
type="button"
class="emoticon-btn flex flex-col items-center justify-center p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
title={emoticon.name + ' ' + emoticon.shortcode}
on:click={() => handleSelect(emoticon.shortcode)}
>
<span class="w-6 h-6 text-2xl emoji-muted">{emoticon.char}</span>
<span class="text-xs text-gray-500">{emoticon.shortcode}</span>
</button>
{/each}
</div>
{/if}
</div>
<style>
.emoji-muted {
filter: grayscale(1) opacity(0.7);
display: inline-block;
}
</style>

16
src/lib/components/EventLimitControl.svelte

@ -29,23 +29,23 @@
} }
</script> </script>
<div class="flex items-center gap-2 mb-4"> <div class="event-control-container">
<label for="event-limit" class="leather bg-transparent text-sm font-medium" <label for="event-limit" class="event-control-label">
>Number of root events: Number of root events:
</label> </label>
<input <input
type="number" type="number"
id="event-limit" id="event-limit"
min="1" min="1"
max="50" max="50"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white" class="event-control-input"
bind:value={inputValue} bind:value={inputValue}
on:input={handleInput} oninput={handleInput}
on:keydown={handleKeyDown} onkeydown={handleKeyDown}
/> />
<button <button
on:click={handleUpdate} onclick={handleUpdate}
class="btn-leather px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800" class="btn-base"
> >
Update Update
</button> </button>

11
src/lib/components/EventRenderLevelLimit.svelte

@ -28,24 +28,23 @@
} }
</script> </script>
<div class="flex items-center gap-2 mb-4"> <div class="event-control-container">
<label for="levels-to-render" class="leather bg-transparent text-sm font-medium" <label for="levels-to-render" class="event-control-label">
>Levels to render: Levels to render:
</label> </label>
<label for="event-limit" class="leather bg-transparent text-sm font-medium">Limit: </label>
<input <input
type="number" type="number"
id="levels-to-render" id="levels-to-render"
min="1" min="1"
max="50" max="50"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white" class="event-control-input"
bind:value={inputValue} bind:value={inputValue}
oninput={handleInput} oninput={handleInput}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
/> />
<button <button
onclick={handleUpdate} onclick={handleUpdate}
class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800" class="btn-base"
> >
Update Update
</button> </button>

23
src/lib/components/Login.svelte

@ -46,33 +46,28 @@
</script> </script>
<div class="m-4"> <div class="login-container">
{#if $ndkSignedIn} {#if $ndkSignedIn}
<Profile pubkey={$activePubkey} isNav={true} /> <Profile pubkey={$activePubkey} isNav={true} />
{:else} {:else}
<Avatar rounded class='h-6 w-6 cursor-pointer' id='avatar' /> <Avatar rounded class="avatar-small" id="avatar" />
<Popover <Popover
class='popover-leather w-fit' class="modal-content"
placement='bottom' placement="bottom"
triggeredBy='#avatar' triggeredBy="#avatar"
> >
<div class='w-full flex flex-col space-y-2'> <div class="login-form">
<Button <Button
onclick={handleSignInClick} class="login-button"
on:click={handleSignInClick}
> >
Extension Sign-In Extension Sign-In
</Button> </Button>
{#if signInFailed} {#if signInFailed}
<div class="p-2 text-sm text-red-600 bg-red-100 rounded"> <div class="login-error">
{errorMessage} {errorMessage}
</div> </div>
{/if} {/if}
<!-- <Button
color='alternative'
on:click={signInWithBunker}
>
Bunker Sign-In
</Button> -->
</div> </div>
</Popover> </Popover>
{/if} {/if}

49
src/lib/components/LoginModal.svelte

@ -18,28 +18,33 @@
</script> </script>
{#if show} {#if show}
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden overflow-y-auto outline-none focus:outline-none bg-gray-900 bg-opacity-50"> <div class="modal-container">
<div class="relative w-auto my-6 mx-auto max-w-3xl"> <Button
<div class="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white outline-none focus:outline-none"> class="modal-backdrop"
<!-- Header --> aria-label="Close modal"
<div class="flex items-start justify-between p-5 border-b border-solid border-gray-300 rounded-t"> color="none"
<h3 class="text-xl font-medium text-gray-900">Login Required</h3> style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5);"
<button onclick={onClose}
class="ml-auto bg-transparent border-0 text-gray-400 float-right text-3xl leading-none font-semibold outline-none focus:outline-none" ></Button>
onclick={onClose} <div class="modal-content">
> <div class="modal-header">
<span class="bg-transparent text-gray-500 h-6 w-6 text-2xl block outline-none focus:outline-none">×</span> <h3 class="modal-title">Login Required</h3>
</button> <Button
</div> class="modal-close"
type="button"
<!-- Body --> aria-label="Close"
<div class="relative p-6 flex-auto"> color="light"
<p class="text-base leading-relaxed text-gray-500 mb-4"> onclick={onClose}
You need to be logged in to submit an issue. Your form data will be preserved. >
</p> <span>×</span>
<div class="flex justify-center"> </Button>
<Login /> </div>
</div> <div class="modal-body">
<p class="text-secondary mb-4">
You need to be logged in to submit an issue. Your form data will be preserved.
</p>
<div class="flex justify-center">
<Login />
</div> </div>
</div> </div>
</div> </div>

238
src/lib/components/MarkdownForm.svelte

@ -0,0 +1,238 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import EasyMDE from 'easymde';
import 'easymde/dist/easymde.min.css';
import { createEventDispatcher } from 'svelte';
import { parseMarkdown } from '../utils/markdown/markdownItParser';
import EmoticonPicker from './EmoticonPicker.svelte';
import { Popover } from 'flowbite-svelte';
const dispatch = createEventDispatcher();
export let labelSubject = 'Subject';
export let labelContent = 'Content';
export let initialSubject = '';
export let initialContent = '';
export let submitLabel = 'Submit';
export let showSubject = true;
let subject = initialSubject;
let content = initialContent;
let submissionError = '';
let easyMDE: EasyMDE | null = null;
let textareaEl: HTMLTextAreaElement;
let showHelp = false;
let showEmojiPicker = false;
let emojiButtonEl: HTMLButtonElement;
const markdownHelp = `# Markdown Guide
## Text Formatting
- **Bold**: \`*text*\` or \`**text**\`
- **Italic**: \`_text_\` or \`__text__\`
- **Strikethrough**: \`~text~\` or \`~~text~~\`
- **Inline Code**: \`\` \`code\` \`\`
- **Links**: \`[text](url)\`
- **Images**: \`![alt](url)\`
## Structure
- **Headings**:
- \`# Heading 1\` through \`###### Heading 6\`
- Or:
\`\`\`
Heading 1
========
Heading 2
---------
\`\`\`
- **Lists**:
- Unordered: \`- item\`, \`* item\`, or \`+ item\`
- Ordered: \`1. item\`, \`2. item\`
- **Code Blocks**:
\`\`\`javascript
\`\`\`language
const code = 'example';
\`\`\`
\`\`\`
- **Blockquotes**: \`> Quote text\`
- **Tables**:
\`\`\`
| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
\`\`\`
## Special Features
- **Footnotes**: \`[^1]\` and \`[^1]: definition\`
- **Emojis**: \`:smile:\`
- **Hashtags**: \`#tag\`
- **Nostr Identifiers**:
- Profiles: \`npub...\` or \`nprofile...\`
- Notes: \`note...\`, \`nevent...\`, or \`naddr...\`
## Media Support
- **YouTube Videos**: Automatically embedded
- **Video Files**: mp4, webm, mov, avi
- **Audio Files**: mp3, wav, ogg, m4a
- **Images**: jpg, jpeg, gif, png, webp
All media URLs are automatically cleaned of tracking parameters for privacy.`;
let helpContent = '';
async function toggleHelp() {
showHelp = !showHelp;
if (showHelp) {
helpContent = await parseMarkdown(markdownHelp);
}
}
function handleSubmit(e: Event) {
e.preventDefault();
if ((showSubject && !subject) || !content) {
submissionError = 'Please fill in all fields';
return;
}
// Emit submit event with markdown content
dispatch('submit', { subject, content });
}
function insertEmoji(shortcode: string) {
if (easyMDE) {
const cm = easyMDE.codemirror;
const doc = cm.getDoc();
const cursor = doc.getCursor();
doc.replaceRange(shortcode, cursor);
showEmojiPicker = false;
}
}
onMount(() => {
easyMDE = new EasyMDE({
element: textareaEl,
initialValue: content,
toolbar: [
'bold', 'italic', 'heading', '|',
'quote', 'unordered-list', 'ordered-list', '|',
'link', 'image', '|',
{
name: 'emoji',
action: () => showEmojiPicker = !showEmojiPicker,
className: 'fa fa-heart',
title: 'Insert Emoji',
icon: `<svg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor' class='w-5 h-5'><path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M11.995 21.003c-.512 0-1.023-.195-1.414-.586l-7.003-7.003a5.002 5.002 0 017.072-7.072l.345.345.345-.345a5.002 5.002 0 017.072 7.072l-7.003 7.003a1.997 1.997 0 01-1.414.586z'/></svg>`
},
'|',
'preview', 'side-by-side', 'fullscreen', '|',
'guide'
],
status: false,
spellChecker: false,
previewRender: (text: string, previewElement: HTMLElement) => {
parseMarkdown(text).then(html => {
previewElement.innerHTML = html;
});
return null;
}
});
if (easyMDE) {
easyMDE.codemirror.on('change', () => {
content = easyMDE!.value();
});
}
});
onDestroy(() => {
if (easyMDE) {
easyMDE.toTextArea();
}
});
</script>
<form class="contact-form" on:submit={handleSubmit}>
<div class="flex justify-between items-center mb-4">
<div class="flex-1">
{#if showSubject}
<div>
<label for="subject" class="mb-2">{labelSubject}</label>
<input id="subject" class="contact-form-input" bind:value={subject} required />
</div>
{/if}
</div>
<button
type="button"
class="btn-secondary btn-sm ml-4"
on:click={toggleHelp}
title="Markdown Help"
>
?
</button>
</div>
<div class="relative">
<label for="content" class="mb-2">{labelContent}</label>
<textarea bind:this={textareaEl} class="hidden"></textarea>
{#if showEmojiPicker}
<Popover
class="emoji-picker-popover"
placement="bottom"
trigger="click"
open={showEmojiPicker}
on:clickoutside={() => showEmojiPicker = false}
>
<EmoticonPicker on:select={({ detail }) => insertEmoji(detail.shortcode)} />
</Popover>
{/if}
</div>
{#if showHelp}
<div class="mt-4 p-4 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 prose dark:prose-invert max-w-none">
{@html helpContent}
</div>
{/if}
<div class="contact-form-actions">
<button type="button" on:click={() => { subject = ''; content = ''; submissionError = ''; }}>
Clear Form
</button>
<button type="submit">{submitLabel}</button>
</div>
{#if submissionError}
<div class="contact-form-error" role="alert">
{submissionError}
</div>
{/if}
</form>
<style>
:global(.EasyMDEContainer) {
@apply border border-gray-300 dark:border-gray-600 rounded-lg;
}
:global(.EasyMDEContainer .CodeMirror) {
@apply bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-300;
}
:global(.EasyMDEContainer .editor-toolbar) {
@apply border-b border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700;
}
:global(.EasyMDEContainer .editor-toolbar button) {
@apply text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white;
}
:global(.EasyMDEContainer .editor-toolbar button.active) {
@apply text-primary-600 dark:text-primary-400;
}
:global(.EasyMDEContainer .editor-preview) {
@apply bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-300;
}
:global(.emoji-button) {
@apply p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded;
}
:global(.emoji-picker-popover) {
@apply z-50;
}
</style>

24
src/lib/components/Navigation.svelte

@ -7,23 +7,23 @@
let leftMenuOpen = $state(false); let leftMenuOpen = $state(false);
</script> </script>
<Navbar class={`Navbar navbar-leather ${className}`}> <Navbar class={`nav-container ${className}`}>
<div class='flex flex-grow justify-between'> <div class="nav-content">
<NavBrand href='/'> <NavBrand href="/" class="nav-brand">
<h1>Alexandria</h1> <h1>Alexandria</h1>
</NavBrand> </NavBrand>
</div> </div>
<div class='flex md:order-2'> <div class="nav-links">
<Login /> <Login />
<NavHamburger class='btn-leather' /> <NavHamburger class="btn-base" />
</div> </div>
<NavUl class='ul-leather'> <NavUl class="nav-links">
<NavLi href='/new/edit'>Publish</NavLi> <NavLi href="/new/edit" class="nav-link">Publish</NavLi>
<NavLi href='/visualize'>Visualize</NavLi> <NavLi href="/visualize" class="nav-link">Visualize</NavLi>
<NavLi href='/about'>About</NavLi> <NavLi href="/about" class="nav-link">About</NavLi>
<NavLi href='/contact'>Contact</NavLi> <NavLi href="/contact" class="nav-link">Contact</NavLi>
<NavLi> <NavLi>
<DarkMode btnClass='btn-leather p-0'/> <DarkMode btnClass="btn-base no-padding" />
</NavLi> </NavLi>
</NavUl> </NavUl>
</Navbar> </Navbar>

10
src/lib/components/Preview.svelte

@ -188,7 +188,7 @@
<div slot='footer' class='flex space-x-2 justify-end'> <div slot='footer' class='flex space-x-2 justify-end'>
<Button <Button
type='reset' type='reset'
class='btn-leather min-w-fit' class='btn-base min-w-fit'
size='sm' size='sm'
outline outline
onclick={() => toggleEditing(rootId, false)} onclick={() => toggleEditing(rootId, false)}
@ -197,7 +197,7 @@
</Button> </Button>
<Button <Button
type='submit' type='submit'
class='btn-leather min-w-fit' class='btn-base min-w-fit'
size='sm' size='sm'
onclick={() => toggleEditing(rootId, true)} onclick={() => toggleEditing(rootId, true)}
> >
@ -217,7 +217,7 @@
<Input type='text' class='input-leather' size='lg' bind:value={title}> <Input type='text' class='input-leather' size='lg' bind:value={title}>
<CloseButton slot='right' onclick={() => toggleEditing(rootId, false)} /> <CloseButton slot='right' onclick={() => toggleEditing(rootId, false)} />
</Input> </Input>
<Button class='btn-leather' color='primary' size='lg' onclick={() => toggleEditing(rootId, true)}> <Button class='btn-base' color='primary' size='lg' onclick={() => toggleEditing(rootId, true)}>
Save Save
</Button> </Button>
</ButtonGroup> </ButtonGroup>
@ -246,12 +246,12 @@
{#if allowEditing && depth > 0} {#if allowEditing && depth > 0}
<div class={`flex flex-col space-y-2 justify-start ${buttonsVisible ? 'visible' : 'invisible'}`}> <div class={`flex flex-col space-y-2 justify-start ${buttonsVisible ? 'visible' : 'invisible'}`}>
{#if hasPreviousSibling && parentId} {#if hasPreviousSibling && parentId}
<Button class='btn-leather' size='sm' outline onclick={() => moveUp(rootId, parentId)}> <Button class='btn-base' size='sm' outline onclick={() => moveUp(rootId, parentId)}>
<CaretUpSolid /> <CaretUpSolid />
</Button> </Button>
{/if} {/if}
{#if hasNextSibling && parentId} {#if hasNextSibling && parentId}
<Button class='btn-leather' size='sm' outline onclick={() => moveDown(rootId, parentId)}> <Button class='btn-base' size='sm' outline onclick={() => moveDown(rootId, parentId)}>
<CaretDownSolid /> <CaretDownSolid />
</Button> </Button>
{/if} {/if}

81
src/lib/components/Publication.svelte

@ -12,7 +12,7 @@
} from "flowbite-svelte"; } from "flowbite-svelte";
import { getContext, onMount } from "svelte"; import { getContext, onMount } from "svelte";
import { BookOutline, ExclamationCircleOutline } from "flowbite-svelte-icons"; import { BookOutline, ExclamationCircleOutline } from "flowbite-svelte-icons";
import { page } from "$app/state"; import { page } from "$app/stores";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import PublicationSection from "./PublicationSection.svelte"; import PublicationSection from "./PublicationSection.svelte";
import type { PublicationTree } from "$lib/data_structures/publication_tree"; import type { PublicationTree } from "$lib/data_structures/publication_tree";
@ -80,10 +80,12 @@
const tocBreakpoint = 1140; const tocBreakpoint = 1140;
let activeHash = $state(page.url.hash); let activeHash = $state($page.url.hash);
let showToc: boolean = $state(true); let showToc: boolean = $state(true);
let showTocButton: boolean = $state(false); let showTocButton: boolean = $state(false);
let currentPath = $page.url.pathname;
function normalizeHashPath(str: string): string { function normalizeHashPath(str: string): string {
return str return str
.toLowerCase() .toLowerCase()
@ -166,41 +168,12 @@
<!-- TODO: Keep track of already-loaded leaves. --> <!-- TODO: Keep track of already-loaded leaves. -->
<!-- TODO: Handle entering mid-document and scrolling up. --> <!-- TODO: Handle entering mid-document and scrolling up. -->
{#if showTocButton && !showToc} <div class="publication-container">
<!-- <Button
class="btn-leather fixed top-20 left-4 h-6 w-6"
outline={true}
on:click={(ev) => {
showToc = true;
ev.stopPropagation();
}}
>
<BookOutline />
</Button>
<Tooltip>Show Table of Contents</Tooltip> -->
{/if}
<!-- TODO: Use loader to build ToC. -->
<!-- {#if showToc}
<Sidebar class='sidebar-leather fixed top-20 left-0 px-4 w-60' {activeHash}>
<SidebarWrapper>
<SidebarGroup class='sidebar-group-leather overflow-y-scroll'>
{#each events as event}
<SidebarItem
class='sidebar-item-leather'
label={event.getMatchingTags('title')[0][1]}
href={`${$page.url.pathname}#${normalizeHashPath(event.getMatchingTags('title')[0][1])}`}
/>
{/each}
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if} -->
<div class="flex flex-col space-y-4 max-w-2xl">
{#each leaves as leaf, i} {#each leaves as leaf, i}
{#if leaf == null} {#if leaf == null}
<Alert class='flex space-x-2'> <Alert class="message-error">
<ExclamationCircleOutline class='w-5 h-5' /> <ExclamationCircleOutline class="w-5 h-5" />
Error loading content. One or more events could not be loaded. Error loading content. One or more events could not be loaded.
</Alert> </Alert>
{:else} {:else}
<PublicationSection <PublicationSection
@ -211,19 +184,51 @@
/> />
{/if} {/if}
{/each} {/each}
<div class="flex justify-center my-4"> <div class="publication-footer">
{#if isLoading} {#if isLoading}
<Button disabled color="primary"> <Button disabled class="btn-base">
Loading... Loading...
</Button> </Button>
{:else if !isDone} {:else if !isDone}
<Button color="primary" on:click={() => loadMore(1)}> <Button class="btn-base" on:click={() => loadMore(1)}>
Show More Show More
</Button> </Button>
{/if} {/if}
</div> </div>
</div> </div>
{#if showTocButton && !showToc}
<Button
class="btn-secondary toc-toggle-btn"
outline={true}
on:click={(ev) => {
showToc = true;
ev.stopPropagation();
}}
>
<BookOutline />
</Button>
<Tooltip>Show Table of Contents</Tooltip>
{/if}
{#if showToc}
<Sidebar class="toc-container">
<SidebarWrapper>
<SidebarGroup class="toc-list">
{#each leaves as leaf}
{#if leaf && leaf.getMatchingTags('title').length > 0}
<SidebarItem
class="toc-item"
label={leaf.getMatchingTags('title')[0][1]}
href={`${currentPath}#${normalizeHashPath(leaf.getMatchingTags('title')[0][1])}`}
/>
{/if}
{/each}
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if}
<style> <style>
:global(.sidebar-group-leather) { :global(.sidebar-group-leather) {
max-height: calc(100vh - 8rem); max-height: calc(100vh - 8rem);

4
src/lib/components/PublicationFeed.svelte

@ -80,7 +80,7 @@
}); });
</script> </script>
<div class='leather flex flex-col space-y-4'> <div class='feed-container'>
{#if eventsInView.length === 0} {#if eventsInView.length === 0}
{#each getSkeletonIds() as id} {#each getSkeletonIds() as id}
<Skeleton divClass='skeleton-leather w-full' size='lg' /> <Skeleton divClass='skeleton-leather w-full' size='lg' />
@ -109,7 +109,7 @@
</div> </div>
{:else} {:else}
<div class='flex justify-center mt-4 mb-8'> <div class='flex justify-center mt-4 mb-8'>
<P class='text-sm text-gray-600'>You've reached the end of the feed.</P> <P class='text-sm-secondary'>You've reached the end of the feed.</P>
</div> </div>
{/if} {/if}
</div> </div>

2
src/lib/components/PublicationHeader.svelte

@ -32,7 +32,7 @@
</script> </script>
{#if title != null && href != null} {#if title != null && href != null}
<Card class='ArticleBox card-leather w-lg flex flex-row space-x-2'> <Card class='article-box card-leather w-lg flex flex-row space-x-2'>
{#if image} {#if image}
<div class="flex col justify-center align-middle max-h-36 max-w-24 overflow-hidden"> <div class="flex col justify-center align-middle max-h-36 max-w-24 overflow-hidden">
<Img src={image} class="rounded w-full h-full object-cover"/> <Img src={image} class="rounded w-full h-full object-cover"/>

4
src/lib/components/PublicationSection.svelte

@ -104,9 +104,9 @@
}); });
</script> </script>
<section bind:this={sectionRef} class='publication-leather content-visibility-auto'> <section bind:this={sectionRef} class="publication-section content-visibility-auto">
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} {#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])}
<TextPlaceholder size='xxl' /> <TextPlaceholder size="xxl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{#each divergingBranches as [branch, depth]} {#each divergingBranches as [branch, depth]}
{@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)} {@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)}

8
src/lib/components/Toc.svelte

@ -9,16 +9,10 @@
</script> </script>
<div class="toc"> <div class="toc">
<h2>Table of contents</h2> <h2 class="toc-title">Table of contents</h2>
<ul> <ul>
{#each notes as note} {#each notes as note}
<li><a href="#{nip19.noteEncode(note.id)}">{note.getMatchingTags('title')[0][1]}</a></li> <li><a href="#{nip19.noteEncode(note.id)}">{note.getMatchingTags('title')[0][1]}</a></li>
{/each} {/each}
</ul> </ul>
</div> </div>
<style>
.toc h2 {
text-align: center;
}
</style>

8
src/lib/parser.ts

@ -12,7 +12,8 @@ import type {
} from 'asciidoctor'; } from 'asciidoctor';
import he from 'he'; import he from 'he';
import { writable, type Writable } from 'svelte/store'; import { writable, type Writable } from 'svelte/store';
import { zettelKinds } from './consts.ts'; import { zettelKinds } from './consts';
import { replaceEmojisWithUnicode } from './utils/markdown/markdownItParser';
interface IndexMetadata { interface IndexMetadata {
authors?: string[]; authors?: string[];
@ -208,10 +209,11 @@ export default class Pharos {
/** /**
* Gets the entire HTML content of the AsciiDoc document. * Gets the entire HTML content of the AsciiDoc document.
* @returns The HTML content of the converted document. * @returns The HTML content of the converted document, with emoji shortcodes replaced by Unicode.
*/ */
getHtml(): string { getHtml(): string {
return this.html?.toString() || ''; const html = this.html?.toString() || '';
return replaceEmojisWithUnicode(html);
} }
/** /**

11
src/lib/types/markdown-it-plugins.d.ts vendored

@ -0,0 +1,11 @@
declare module 'markdown-it-footnote' {
import MarkdownIt from 'markdown-it';
const plugin: MarkdownIt.PluginWithParams;
export default plugin;
}
declare module 'markdown-it-emoji' {
import MarkdownIt from 'markdown-it';
const plugin: MarkdownIt.PluginWithParams;
export default plugin;
}

4
src/lib/types/svelte-heros.d.ts vendored

@ -0,0 +1,4 @@
declare module 'svelte-heros/dist/*.svelte' {
import { SvelteComponentTyped } from 'svelte';
export default class Icon extends SvelteComponentTyped<any, any, any> {}
}

378
src/lib/utils/advancedMarkdownParser.ts

@ -1,378 +0,0 @@
import { parseBasicMarkdown } from './basicMarkdownParser';
import hljs from 'highlight.js';
import 'highlight.js/lib/common'; // Import common languages
import 'highlight.js/styles/github-dark.css'; // Dark theme only
// Register common languages
hljs.configure({
ignoreUnescapedHTML: true
});
// Regular expressions for advanced markdown elements
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm;
const ALTERNATE_HEADING_REGEX = /^([^\n]+)\n(=+|-+)\n/gm;
const INLINE_CODE_REGEX = /`([^`\n]+)`/g;
const HORIZONTAL_RULE_REGEX = /^(?:[-*_]\s*){3,}$/gm;
const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g;
const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm;
/**
* Process headings (both styles)
*/
function processHeadings(content: string): string {
// Process ATX-style headings (# Heading)
let processedContent = content.replace(HEADING_REGEX, (_, level, text) => {
const headingLevel = level.length;
return `<h${headingLevel} class="text-2xl font-bold mt-6 mb-4">${text.trim()}</h${headingLevel}>`;
});
// Process Setext-style headings (Heading\n====)
processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => {
const headingLevel = level[0] === '=' ? 1 : 2;
return `<h${headingLevel} class="text-2xl font-bold mt-6 mb-4">${text.trim()}</h${headingLevel}>`;
});
return processedContent;
}
/**
* Process tables
*/
function processTables(content: string): string {
try {
if (!content) return '';
return content.replace(/^\|(.*(?:\n\|.*)*)/gm, (match) => {
try {
// Split into rows and clean up
const rows = match.split('\n').filter(row => row.trim());
if (rows.length < 1) return match;
// Helper to process a row into cells
const processCells = (row: string): string[] => {
return row
.split('|')
.slice(1, -1) // Remove empty cells from start/end
.map(cell => cell.trim());
};
// Check if second row is a delimiter row (only hyphens)
const hasHeader = rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/);
// Extract header and body rows
let headerCells: string[] = [];
let bodyRows: string[] = [];
if (hasHeader) {
// If we have a header, first row is header, skip delimiter, rest is body
headerCells = processCells(rows[0]);
bodyRows = rows.slice(2);
} else {
// No header, all rows are body
bodyRows = rows;
}
// Build table HTML
let html = '<div class="overflow-x-auto my-4">\n';
html += '<table class="min-w-full border-collapse">\n';
// Add header if exists
if (hasHeader) {
html += '<thead>\n<tr>\n';
headerCells.forEach(cell => {
html += `<th class="py-2 px-4 text-left border-b-2 border-gray-200 dark:border-gray-700 font-semibold">${cell}</th>\n`;
});
html += '</tr>\n</thead>\n';
}
// Add body
html += '<tbody>\n';
bodyRows.forEach(row => {
const cells = processCells(row);
html += '<tr>\n';
cells.forEach(cell => {
html += `<td class="py-2 px-4 text-left border-b border-gray-200 dark:border-gray-700">${cell}</td>\n`;
});
html += '</tr>\n';
});
html += '</tbody>\n</table>\n</div>';
return html;
} catch (error) {
console.error('Error processing table row:', error);
return match;
}
});
} catch (error) {
console.error('Error in processTables:', error);
return content;
}
}
/**
* Process horizontal rules
*/
function processHorizontalRules(content: string): string {
return content.replace(HORIZONTAL_RULE_REGEX,
'<hr class="my-8 h-px border-0 bg-gray-200 dark:bg-gray-700">'
);
}
/**
* Process footnotes
*/
function processFootnotes(content: string): string {
try {
if (!content) return '';
// First collect all footnote references and definitions
const footnotes = new Map<string, string>();
const references = new Map<string, number>();
const referenceLocations = new Set<string>();
let nextNumber = 1;
// First pass: collect all references to establish order
let processedContent = content.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
if (!referenceLocations.has(id) && !references.has(id)) {
references.set(id, nextNumber++);
}
referenceLocations.add(id);
return match; // Keep the reference for now
});
// Second pass: collect all definitions
processedContent = processedContent.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, text) => {
footnotes.set(id, text.trim());
return ''; // Remove the definition
});
// Third pass: process references with collected information
processedContent = processedContent.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
if (!footnotes.has(id)) {
console.warn(`Footnote reference [^${id}] found but no definition exists`);
return match;
}
const num = references.get(id)!;
return `<sup><a href="#fn-${id}" id="fnref-${id}" class="text-primary-600 hover:underline">[${num}]</a></sup>`;
});
// Add footnotes section if we have any
if (references.size > 0) {
processedContent += '\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside">\n';
// Sort footnotes by their reference number
const sortedFootnotes = Array.from(references.entries())
.sort((a, b) => a[1] - b[1])
.filter(([id]) => footnotes.has(id)); // Only include footnotes that have definitions
// Add each footnote in order
for (const [id, num] of sortedFootnotes) {
const text = footnotes.get(id) || '';
processedContent += `<li id="fn-${id}" value="${num}"><span class="marker">${text}</span> <a href="#fnref-${id}" class="text-primary-600 hover:underline">↩</a></li>\n`;
}
processedContent += '</ol>';
}
return processedContent;
} catch (error) {
console.error('Error processing footnotes:', error);
return content;
}
}
/**
* Process blockquotes
*/
function processBlockquotes(content: string): string {
// Match blockquotes that might span multiple lines
const blockquoteRegex = /^>[ \t]?(.+(?:\n>[ \t]?.+)*)/gm;
return content.replace(blockquoteRegex, (match) => {
// Remove the '>' prefix from each line and preserve line breaks
const text = match
.split('\n')
.map(line => line.replace(/^>[ \t]?/, ''))
.join('\n')
.trim();
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4 whitespace-pre-wrap">${text}</blockquote>`;
});
}
/**
* Process code blocks by finding consecutive code lines and preserving their content
*/
function processCodeBlocks(text: string): { text: string; blocks: Map<string, string> } {
const lines = text.split('\n');
const processedLines: string[] = [];
const blocks = new Map<string, string>();
let inCodeBlock = false;
let currentCode: string[] = [];
let currentLanguage = '';
let blockCount = 0;
let lastWasCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const codeBlockStart = line.match(/^```(\w*)$/);
if (codeBlockStart) {
if (!inCodeBlock) {
// Starting a new code block
inCodeBlock = true;
currentLanguage = codeBlockStart[1];
currentCode = [];
lastWasCodeBlock = true;
} else {
// Ending current code block
blockCount++;
const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n');
// Try to format JSON if specified
let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') {
try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e) {
formattedCode = code;
}
}
blocks.set(id, JSON.stringify({
code: formattedCode,
language: currentLanguage,
raw: true
}));
processedLines.push(''); // Add spacing before code block
processedLines.push(id);
processedLines.push(''); // Add spacing after code block
inCodeBlock = false;
currentCode = [];
currentLanguage = '';
}
} else if (inCodeBlock) {
currentCode.push(line);
} else {
if (lastWasCodeBlock && line.trim()) {
processedLines.push('');
lastWasCodeBlock = false;
}
processedLines.push(line);
}
}
// Handle unclosed code block
if (inCodeBlock && currentCode.length > 0) {
blockCount++;
const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n');
// Try to format JSON if specified
let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') {
try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e) {
formattedCode = code;
}
}
blocks.set(id, JSON.stringify({
code: formattedCode,
language: currentLanguage,
raw: true
}));
processedLines.push('');
processedLines.push(id);
processedLines.push('');
}
return {
text: processedLines.join('\n'),
blocks
};
}
/**
* Restore code blocks with proper formatting
*/
function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
let result = text;
for (const [id, blockData] of blocks) {
try {
const { code, language } = JSON.parse(blockData);
let html;
if (language && hljs.getLanguage(language)) {
try {
const highlighted = hljs.highlight(code, {
language,
ignoreIllegals: true
}).value;
html = `<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
} catch (e) {
console.warn('Failed to highlight code block:', e);
html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ''}">${code}</code></pre>`;
}
} else {
html = `<pre class="code-block"><code class="hljs">${code}</code></pre>`;
}
result = result.replace(id, html);
} catch (error) {
console.error('Error restoring code block:', error);
result = result.replace(id, '<pre class="code-block"><code class="hljs">Error processing code block</code></pre>');
}
}
return result;
}
/**
* Parse markdown text with advanced formatting
*/
export async function parseAdvancedMarkdown(text: string): Promise<string> {
if (!text) return '';
try {
// Step 1: Extract and save code blocks first
const { text: withoutCode, blocks } = processCodeBlocks(text);
let processedText = withoutCode;
// Step 2: Process block-level elements
processedText = processTables(processedText);
processedText = processBlockquotes(processedText);
processedText = processHeadings(processedText);
processedText = processHorizontalRules(processedText);
// Process inline elements
processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => {
const escapedCode = code
.trim()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
return `<code class="px-1.5 py-0.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm font-mono">${escapedCode}</code>`;
});
// Process footnotes
processedText = processFootnotes(processedText);
// Process basic markdown (which will also handle Nostr identifiers)
processedText = await parseBasicMarkdown(processedText);
// Step 3: Restore code blocks
processedText = restoreCodeBlocks(processedText, blocks);
return processedText;
} catch (error) {
console.error('Error in parseAdvancedMarkdown:', error);
return `<div class="text-red-500">Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}</div>`;
}
}

182
src/lib/utils/basicMarkdownParser.ts

@ -1,182 +0,0 @@
import { processNostrIdentifiers } from './nostrUtils';
// Regular expressions for basic markdown elements
const BOLD_REGEX = /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g;
const ITALIC_REGEX = /\b(_[^_\n]+_|\b__[^_\n]+__)\b/g;
const STRIKETHROUGH_REGEX = /~~([^~\n]+)~~|~([^~\n]+)~/g;
const HASHTAG_REGEX = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g;
const BLOCKQUOTE_REGEX = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm;
// List regex patterns
const UNORDERED_LIST_REGEX = /^(\s*[-*+]\s+)(.*?)$/gm;
const ORDERED_LIST_REGEX = /^(\s*\d+\.\s+)(.*?)$/gm;
// Markdown patterns
const MARKDOWN_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
const MARKDOWN_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g;
// URL patterns
const WSS_URL = /wss:\/\/[^\s<>"]+/g;
const DIRECT_LINK = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/g;
// Media URL patterns
const IMAGE_URL_REGEX = /https?:\/\/[^\s<]+\.(?:jpg|jpeg|gif|png|webp)(?:[^\s<]*)?/i;
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/i;
function processBasicFormatting(content: string): string {
if (!content) return '';
let processedText = content;
try {
// Process Markdown images first
processedText = processedText.replace(MARKDOWN_IMAGE, (match, alt, url) => {
if (YOUTUBE_URL_REGEX.test(url)) {
const videoId = extractYouTubeVideoId(url);
if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${alt || 'YouTube video'}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(url)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${url}">${alt || 'Video'}</video>`;
}
if (AUDIO_URL_REGEX.test(url)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${url}">${alt || 'Audio'}</audio>`;
}
return `<img src="${url}" alt="${alt}" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
});
// Process Markdown links
processedText = processedText.replace(MARKDOWN_LINK, (match, text, url) =>
`<a href="${url}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`
);
// Process WebSocket URLs
processedText = processedText.replace(WSS_URL, match => {
// Remove 'wss://' from the start and any trailing slashes
const cleanUrl = match.slice(6).replace(/\/+$/, '');
return `<a href="https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-500 hover:underline">${match}</a>`;
});
// Process direct media URLs
processedText = processedText.replace(DIRECT_LINK, match => {
if (YOUTUBE_URL_REGEX.test(match)) {
const videoId = extractYouTubeVideoId(match);
if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="YouTube video" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation" class="text-primary-600 dark:text-primary-500 hover:underline"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(match)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${match}">Your browser does not support the video tag.</video>`;
}
if (AUDIO_URL_REGEX.test(match)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${match}">Your browser does not support the audio tag.</audio>`;
}
if (IMAGE_URL_REGEX.test(match)) {
return `<img src="${match}" alt="Embedded media" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
}
return `<a href="${match}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${match}</a>`;
});
// Process text formatting
processedText = processedText.replace(BOLD_REGEX, '<strong>$2</strong>');
processedText = processedText.replace(ITALIC_REGEX, match => {
const text = match.replace(/^_+|_+$/g, '');
return `<em>${text}</em>`;
});
processedText = processedText.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => {
const text = doubleText || singleText;
return `<del class="line-through">${text}</del>`;
});
// Process hashtags
processedText = processedText.replace(HASHTAG_REGEX, '<span class="text-gray-500 dark:text-gray-400">#$1</span>');
} catch (error) {
console.error('Error in processBasicFormatting:', error);
}
return processedText;
}
// Helper function to extract YouTube video ID
function extractYouTubeVideoId(url: string): string | null {
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/);
return match ? match[1] : null;
}
function processBlockquotes(content: string): string {
try {
if (!content) return '';
return content.replace(BLOCKQUOTE_REGEX, match => {
const lines = match.split('\n').map(line => {
return line.replace(/^[ \t]*>[ \t]?/, '').trim();
});
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${
lines.join('\n')
}</blockquote>`;
});
} catch (error) {
console.error('Error in processBlockquotes:', error);
return content;
}
}
export async function parseBasicMarkdown(text: string): Promise<string> {
if (!text) return '';
try {
// Process basic text formatting first
let processedText = processBasicFormatting(text);
// Process lists - handle ordered lists first
processedText = processedText
// Process ordered lists
.replace(ORDERED_LIST_REGEX, (match, marker, content) => {
// Count leading spaces to determine nesting level
const indent = marker.match(/^\s*/)[0].length;
const extraIndent = indent > 0 ? ` ml-${indent * 4}` : '';
return `<li class="py-2${extraIndent}">${content}</li>`;
})
.replace(/<li.*?>.*?<\/li>\n?/gs, '<ol class="list-decimal my-4 ml-8">$&</ol>')
// Process unordered lists
.replace(UNORDERED_LIST_REGEX, (match, marker, content) => {
// Count leading spaces to determine nesting level
const indent = marker.match(/^\s*/)[0].length;
const extraIndent = indent > 0 ? ` ml-${indent * 4}` : '';
return `<li class="py-2${extraIndent}">${content}</li>`;
})
.replace(/<li.*?>.*?<\/li>\n?/gs, '<ul class="list-disc my-4 ml-8">$&</ul>');
// Process blockquotes
processedText = processBlockquotes(processedText);
// Process paragraphs - split by double newlines and wrap in p tags
processedText = processedText
.split(/\n\n+/)
.map(para => para.trim())
.filter(para => para.length > 0)
.map(para => `<p class="my-4">${para}</p>`)
.join('\n');
// Process Nostr identifiers last
processedText = await processNostrIdentifiers(processedText);
return processedText;
} catch (error) {
console.error('Error in parseBasicMarkdown:', error);
return `<div class="text-red-500">Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}</div>`;
}
}

85
src/lib/utils/emoticons.ts

@ -0,0 +1,85 @@
// Heroicon Svelte components (assume these are available in src/lib/icons/heroicons)
import Heart from 'svelte-heros/dist/Heart.svelte';
import FaceSmile from 'svelte-heros/dist/FaceSmile.svelte';
import FaceFrown from 'svelte-heros/dist/FaceFrown.svelte';
import Fire from 'svelte-heros/dist/Fire.svelte';
import HandRaised from 'svelte-heros/dist/HandRaised.svelte';
import ThumbDown from 'svelte-heros/dist/ThumbDown.svelte';
import ThumbUp from 'svelte-heros/dist/ThumbUp.svelte';
import Eye from 'svelte-heros/dist/Eye.svelte';
import LightBulb from 'svelte-heros/dist/LightBulb.svelte';
import Pencil from 'svelte-heros/dist/Pencil.svelte';
import RocketLaunch from 'svelte-heros/dist/RocketLaunch.svelte';
import Star from 'svelte-heros/dist/Star.svelte';
import Sun from 'svelte-heros/dist/Sun.svelte';
import Moon from 'svelte-heros/dist/Moon.svelte';
import Trash from 'svelte-heros/dist/Trash.svelte';
import Trophy from 'svelte-heros/dist/Trophy.svelte';
import Cake from 'svelte-heros/dist/Cake.svelte';
import CurrencyDollar from 'svelte-heros/dist/CurrencyDollar.svelte';
import CurrencyEuro from 'svelte-heros/dist/CurrencyEuro.svelte';
import ExclamationCircle from 'svelte-heros/dist/ExclamationCircle.svelte';
export const heroiconEmoticons = [
{ name: 'Heart', shortcode: ':heart:', component: Heart },
{ name: 'Smile', shortcode: ':face-smile:', component: FaceSmile },
{ name: 'Frown', shortcode: ':face-frown:', component: FaceFrown },
{ name: 'Fire', shortcode: ':fire:', component: Fire },
{ name: 'Hand Raised', shortcode: ':hand-raised:', component: HandRaised },
{ name: 'Thumb Down', shortcode: ':hand-thumb-down:', component: ThumbDown },
{ name: 'Thumb Up', shortcode: ':hand-thumb-up:', component: ThumbUp },
{ name: 'Eye', shortcode: ':eye:', component: Eye },
{ name: 'Light Bulb', shortcode: ':light-bulb:', component: LightBulb },
{ name: 'Pencil Square', shortcode: ':pencil-square:', component: Pencil },
{ name: 'Rocket', shortcode: ':rocket-launch:', component: RocketLaunch },
{ name: 'Star', shortcode: ':star:', component: Star },
{ name: 'Sun', shortcode: ':sun:', component: Sun },
{ name: 'Moon', shortcode: ':moon:', component: Moon },
{ name: 'Trash', shortcode: ':trash:', component: Trash },
{ name: 'Trophy', shortcode: ':trophy:', component: Trophy },
{ name: 'Cake', shortcode: ':cake:', component: Cake },
{ name: 'Dollar Sign', shortcode: ':dollar-sign:', component: CurrencyDollar },
{ name: 'Euro Sign', shortcode: ':euro-sign:', component: CurrencyEuro },
{ name: 'Exclamation Circle', shortcode: ':exclamation-circle:', component: ExclamationCircle }
];
// Unicode emojis, excluding those covered by heroicons
export const unicodeEmojis = [
{ name: 'Laughing', shortcode: ':joy:', char: '😂' },
{ name: 'Crying', shortcode: ':sob:', char: '😭' },
{ name: 'Call Me Hand', shortcode: ':call-me-hand:', char: '🤙' },
{ name: 'Waving Hand', shortcode: ':wave:', char: '👋' },
{ name: 'Pinched Fingers', shortcode: ':pinched-fingers:', char: '🤌' },
// ...add more as needed, ensuring no overlap with heroiconEmoticons
];
/**
* Get the Unicode character for a given shortcode, searching both heroicon and unicode lists.
* Returns undefined if not found.
*/
export function getUnicodeEmoji(shortcode: string): string | undefined {
// Map heroicon shortcodes to a reasonable Unicode fallback
const heroiconFallbacks: Record<string, string> = {
':heart:': '❤',
':face-smile:': '🙂',
':face-frown:': '🙁',
':fire:': '🔥',
':hand-raised:': '✋',
':hand-thumb-down:': '👎',
':hand-thumb-up:': '👍',
':bell:': '🔔',
':eye:': '👁',
':light-bulb:': '💡',
':pencil-square:': '✏',
':rocket-launch:': '🚀',
':star:': '⭐',
':sun:': '☀',
':moon:': '🌙',
':trash:': '🗑',
':trophy:': '🏆',
};
if (heroiconFallbacks[shortcode]) return heroiconFallbacks[shortcode];
const unicode = unicodeEmojis.find(e => e.shortcode === shortcode);
return unicode ? unicode.char : undefined;
}

391
src/lib/utils/markdown/markdownItParser.ts

@ -0,0 +1,391 @@
import MarkdownIt from 'markdown-it';
import footnote from 'markdown-it-footnote';
import emoji from 'markdown-it-emoji';
import { processNostrIdentifiers } from '../nostrUtils';
import hljs from 'highlight.js';
import 'highlight.js/lib/common';
import 'highlight.js/styles/github-dark.css';
import asciidoc from 'highlight.js/lib/languages/asciidoc';
import { getUnicodeEmoji } from '../emoticons';
// Configure highlight.js
hljs.configure({
ignoreUnescapedHTML: true
});
hljs.registerLanguage('asciidoc', asciidoc);
// URL patterns for custom rendering
const WSS_URL = /wss:\/\/[^\s<>"]+/g;
const IMAGE_URL_REGEX = /https?:\/\/[^\s<]+\.(?:jpg|jpeg|gif|png|webp)(?:[^\s<]*)?/i;
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/i;
// Tracking parameters to remove
const TRACKING_PARAMS = new Set([
// Common tracking parameters
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
'ref', 'source', 'campaign', 'si', 't', 'v', 'ab_channel',
// YouTube specific
'feature', 'hl', 'gl', 'app', 'persist_app', 'app-arg',
'autoplay', 'loop', 'controls', 'modestbranding', 'rel',
'showinfo', 'iv_load_policy', 'fs', 'playsinline'
]);
/**
* Clean URL by removing tracking parameters
*/
function cleanUrl(url: string): string {
try {
const urlObj = new URL(url);
const params = new URLSearchParams(urlObj.search);
// Remove tracking parameters
for (const param of TRACKING_PARAMS) {
params.delete(param);
}
// For YouTube URLs, only keep the video ID
if (YOUTUBE_URL_REGEX.test(url)) {
const videoId = url.match(YOUTUBE_URL_REGEX)?.[1];
if (videoId) {
return `https://www.youtube-nocookie.com/embed/${videoId}`;
}
}
// Reconstruct URL without tracking parameters
urlObj.search = params.toString();
return urlObj.toString();
} catch (e) {
// If URL parsing fails, return original URL
return url;
}
}
// Create markdown-it instance with plugins
const md = new MarkdownIt({
html: true, // Enable HTML tags in source
xhtmlOut: true, // Use '/' to close single tags (<br />)
breaks: true, // Convert '\n' in paragraphs into <br>
linkify: true, // Autoconvert URL-like text to links
typographer: true, // Enable some language-neutral replacement + quotes beautification
highlight: function (str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
} catch (__) {}
}
return ''; // use external default escaping
}
})
.use(footnote)
.use(emoji);
// Enable strikethrough using markdown-it's built-in rule
md.inline.ruler.after('emphasis', 'strikethrough', (state, silent) => {
let found = false, token, pos = state.pos, max = state.posMax, start = pos, marker = state.src.charCodeAt(pos);
if (silent) return false;
if (marker !== 0x7E/* ~ */) return false;
let scan = pos, mem = pos;
while (scan < max && state.src.charCodeAt(scan) === 0x7E/* ~ */) { scan++; }
let len = scan - mem;
if (len < 2) return false;
let markup = state.src.slice(mem, scan);
let end = scan;
while (end < max) {
if (state.src.charCodeAt(end) === marker) {
if (state.src.slice(end, end + len) === markup) {
found = true;
break;
}
}
end++;
}
if (!found) {
state.pos = scan;
return false;
}
if (!silent) {
state.pos = mem + len;
token = state.push('s_open', 's', 1);
token.markup = markup;
token = state.push('text', '', 0);
token.content = state.src.slice(mem + len, end);
token = state.push('s_close', 's', -1);
token.markup = markup;
}
state.pos = end + len;
return true;
});
// Custom renderer rules for Nostr identifiers
const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g;
const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
// Add custom rule for hashtags
md.inline.ruler.after('emphasis', 'hashtag', (state, silent) => {
const match = /^#([a-zA-Z0-9_]+)(?!\w)/.exec(state.src.slice(state.pos));
if (!match) return false;
if (silent) return true;
const tag = match[1];
state.pos += match[0].length;
const token = state.push('hashtag', '', 0);
token.content = tag;
token.markup = '#';
return true;
});
md.renderer.rules.hashtag = (tokens, idx) => {
const tag = tokens[idx].content;
return `<span class="text-secondary">#${tag}</span>`;
};
// Override the default link renderer to handle Nostr identifiers and special URLs
const defaultRender = md.renderer.rules.link_open || function(tokens: any[], idx: number, options: any, env: any, self: any): string {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.link_open = function(tokens: any[], idx: number, options: any, env: any, self: any): string {
const token = tokens[idx];
const hrefIndex = token.attrIndex('href');
if (hrefIndex >= 0) {
const href = token.attrs![hrefIndex][1];
const cleanedHref = cleanUrl(href);
// Handle Nostr identifiers
if ((NOSTR_PROFILE_REGEX.test(cleanedHref) || NOSTR_NOTE_REGEX.test(cleanedHref)) && !cleanedHref.startsWith('nostr:')) {
token.attrs![hrefIndex][1] = `nostr:${cleanedHref}`;
}
// Handle WebSocket URLs
else if (WSS_URL.test(cleanedHref)) {
const cleanUrl = cleanedHref.slice(6).replace(/\/+$/, '');
token.attrs![hrefIndex][1] = `https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F`;
}
// Handle media URLs
else if (YOUTUBE_URL_REGEX.test(cleanedHref)) {
const videoId = cleanedHref.match(YOUTUBE_URL_REGEX)?.[1];
if (videoId) {
return `<div class="videoblock"><div class="content"><iframe src="https://www.youtube-nocookie.com/embed/${videoId}" title="YouTube video" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe></div></div>`;
}
}
else if (VIDEO_URL_REGEX.test(cleanedHref)) {
return `<div class="videoblock"><div class="content"><video controls preload="none" playsinline><source src="${cleanedHref}">Your browser does not support the video tag.</video></div></div>`;
}
else if (AUDIO_URL_REGEX.test(cleanedHref)) {
return `<div class="audioblock"><div class="content"><audio controls preload="none"><source src="${cleanedHref}">Your browser does not support the audio tag.</audio></div></div>`;
}
else if (IMAGE_URL_REGEX.test(cleanedHref)) {
return `<div class="imageblock"><div class="content"><img src="${cleanedHref}" alt="Embedded media" loading="lazy" decoding="async"></div></div>`;
}
else {
// Update the href with cleaned URL
token.attrs![hrefIndex][1] = cleanedHref;
}
}
return defaultRender(tokens, idx, options, env, self);
};
// Override image renderer to handle media URLs
const defaultImageRender = md.renderer.rules.image || function(tokens: any[], idx: number, options: any, env: any, self: any): string {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.image = function(tokens: any[], idx: number, options: any, env: any, self: any): string {
const token = tokens[idx];
const srcIndex = token.attrIndex('src');
if (srcIndex >= 0) {
const src = token.attrs![srcIndex][1];
const cleanedSrc = cleanUrl(src);
const alt = token.attrs![token.attrIndex('alt')]?.[1] || '';
if (YOUTUBE_URL_REGEX.test(cleanedSrc)) {
const videoId = cleanedSrc.match(YOUTUBE_URL_REGEX)?.[1];
if (videoId) {
return `<div class="videoblock"><div class="content"><iframe src="https://www.youtube-nocookie.com/embed/${videoId}" title="${alt || 'YouTube video'}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe></div></div>`;
}
}
if (VIDEO_URL_REGEX.test(cleanedSrc)) {
return `<div class="videoblock"><div class="content"><video controls preload="none" playsinline><source src="${cleanedSrc}">${alt || 'Video'}</video></div></div>`;
}
if (AUDIO_URL_REGEX.test(cleanedSrc)) {
return `<div class="audioblock"><div class="content"><audio controls preload="none"><source src="${cleanedSrc}">${alt || 'Audio'}</audio></div></div>`;
}
// Update the src with cleaned URL
token.attrs![srcIndex][1] = cleanedSrc;
}
return defaultImageRender(tokens, idx, options, env, self);
};
// Add custom rule for alternate heading style
md.block.ruler.before('heading', 'alternate_heading', (state, startLine, endLine, silent) => {
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
const content = state.src.slice(start, max).trim();
// Check if this line is followed by = or - underline
if (startLine + 1 >= endLine) return false;
const nextStart = state.bMarks[startLine + 1] + state.tShift[startLine + 1];
const nextMax = state.eMarks[startLine + 1];
const nextContent = state.src.slice(nextStart, nextMax).trim();
// Check if next line is all = or -
if (!/^[=-]+$/.test(nextContent)) return false;
// Determine heading level (h1 for =, h2 for -)
const level = nextContent[0] === '=' ? 1 : 2;
if (silent) return true;
// Create heading token
state.line = startLine + 2;
const openToken = state.push('heading_open', 'h' + level, 1);
openToken.markup = '#'.repeat(level);
const inlineToken = state.push('inline', '', 0);
inlineToken.content = content;
inlineToken.map = [startLine, startLine + 2];
const closeToken = state.push('heading_close', 'h' + level, -1);
closeToken.markup = '#'.repeat(level);
return true;
});
// Override the default code inline rule to only support single backticks
md.inline.ruler.after('backticks', 'code_inline', (state, silent) => {
let start = state.pos;
let max = state.posMax;
let marker = state.src.charCodeAt(start);
// Check for single backtick
if (marker !== 0x60/* ` */) return false;
// Find the end of the code span
let pos = start + 1;
// Find the closing backtick
while (pos < max) {
if (state.src.charCodeAt(pos) === 0x60/* ` */) {
pos++;
break;
}
pos++;
}
if (pos >= max) return false;
const content = state.src.slice(start + 1, pos - 1);
if (!content) return false;
if (silent) return true;
state.pos = pos;
const token = state.push('code_inline', 'code', 0);
token.content = content;
token.markup = '`';
return true;
});
/**
* Replace emoji shortcodes in text with Unicode wrapped in <span class="emoji-muted">...</span>
*/
export function replaceEmojisWithUnicode(text: string): string {
return text.replace(/(:[a-z0-9_\-]+:)/gi, (match) => {
const unicode = getUnicodeEmoji(match);
if (unicode) {
return `<span class=\"emoji-muted\">${unicode}</span>`;
}
return match;
});
}
/**
* Parse markdown text with markdown-it and custom processing
*/
export async function parseMarkdown(text: string): Promise<string> {
if (!text) return '';
try {
// First pass: Process with markdown-it
let processedText = md.render(text);
// Second pass: Process Nostr identifiers
processedText = await processNostrIdentifiers(processedText);
// Third pass: Replace emoji shortcodes with Unicode
processedText = replaceEmojisWithUnicode(processedText);
// Add custom classes to elements
processedText = processedText
// Add classes to headings
.replace(/<h1>/g, '<h1 class="h1-leather">')
.replace(/<h2>/g, '<h2 class="h2-leather">')
.replace(/<h3>/g, '<h3 class="h3-leather">')
.replace(/<h4>/g, '<h4 class="h4-leather">')
.replace(/<h5>/g, '<h5 class="h5-leather">')
.replace(/<h6>/g, '<h6 class="h6-leather">')
// Add classes to paragraphs
.replace(/<p>/g, '<p class="text-primary">')
// Add classes to blockquotes
.replace(/<blockquote>/g, '<blockquote class="quoteblock">')
// Add classes to code blocks
.replace(/<pre>/g, '<pre class="listingblock">')
// Add classes to inline code
.replace(/<code>/g, '<code class="literalblock">')
// Add classes to links
.replace(/<a href=/g, '<a class="link" href=')
// Add classes to lists
.replace(/<ul>/g, '<ul class="ulist">')
.replace(/<ol>/g, '<ol class="olist arabic">')
// Add classes to list items
.replace(/<li>/g, '<li class="text-primary">')
// Add classes to horizontal rules
.replace(/<hr>/g, '<hr class="border-b border-gray-300 dark:border-gray-600">')
// Add classes to footnotes
.replace(/<div class="footnotes">/g, '<div class="footnotes mt-4 pt-4 border-t border-gray-300 dark:border-gray-600">')
.replace(/<a href="#fnref/g, '<a class="link" href="#fnref')
.replace(/<a href="#fn-/g, '<a class="link" href="#fn-')
.replace(/<sup id="fnref/g, '<sup id="fnref" class="text-sm-secondary">')
.replace(/<li id="fn-/g, '<li id="fn-" class="text-sm-secondary">')
// Add classes to images
.replace(/<img/g, '<img class="imageblock"')
// Add classes to tables
.replace(/<table>/g, '<table class="tableblock">')
.replace(/<thead>/g, '<thead class="text-primary">')
.replace(/<tbody>/g, '<tbody class="text-primary">')
.replace(/<th>/g, '<th class="text-primary font-semibold">')
.replace(/<td>/g, '<td class="text-primary">');
return processedText;
} catch (error) {
console.error('Error in parseMarkdown:', error);
return `<div class="text-red-500">Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}</div>`;
}
}

4
src/lib/utils/markdownTestfile.md → src/lib/utils/markdown/markdownTestfile.md

@ -5,7 +5,9 @@ This is a test
It is _only_ a test, for __sure__. I just wanted to see if the markdown renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1] It is _only_ a test, for __sure__. I just wanted to see if the markdown renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1]
This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markdown parser. This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markdown parser.
Try out some emojisface with smiling :facesmile:, call-me hand :call-me-hand:, and trophy :trophy:.
npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz. npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.

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

@ -4,11 +4,11 @@
import { Button, P } from 'flowbite-svelte'; import { Button, P } from 'flowbite-svelte';
</script> </script>
<div class="leather flex flex-col items-center justify-center min-h-screen text-center px-4"> <div class="not-found-container">
<h1 class="h-leather mb-4">404 - Page Not Found</h1> <h1 class="heading-1">404 - Page Not Found</h1>
<P class="note-leather mb-6">The page you are looking for does not exist or has been moved.</P> <P class="text-secondary mb-6">The page you are looking for does not exist or has been moved.</P>
<div class="flex space-x-4"> <div class="not-found-actions">
<Button class="btn-leather !w-fit" on:click={() => goto('/')}>Return to Home</Button> <Button class="btn-base" on:click={() => goto('/')}>Return to Home</Button>
<Button class="btn-leather !w-fit" outline on:click={() => window.history.back()}>Go Back</Button> <Button class="btn-secondary" on:click={() => window.history.back()}>Go Back</Button>
</div> </div>
</div> </div>

123
src/routes/about/+page.svelte

@ -6,206 +6,223 @@
const isVersionKnown = appVersion !== "development"; const isVersionKnown = appVersion !== "development";
</script> </script>
<div class="w-full flex justify-center"> <div class="about-container">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4"> <main class="about-main">
<div class="flex justify-between items-center"> <div class="about-header">
<Heading tag="h1" class="h-leather mb-2" <Heading tag="h1" class="heading-1 mb-2">About the Library of Alexandria</Heading>
>About the Library of Alexandria</Heading
>
{#if isVersionKnown} {#if isVersionKnown}
<span <span class="about-version">Version: {appVersion}</span>
class="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-nowrap"
>Version: {appVersion}</span
>
{/if} {/if}
</div> </div>
<Img src="/screenshots/old_books.jpg" alt="Alexandria icon" /> <Img src="/screenshots/old_books.jpg" alt="Alexandria icon" class="image-bordered" />
<P class="mb-3"> <P class="text-secondary 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?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1"
class="link"
>curated publications</A >curated publications</A
> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form > (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form
articles (Markdown). It is produced by the <A articles (Markdown). It is produced by the <A
href="/publication?d=gitcitadel-project-documentation-gitcitadel-project-1-by-stella-v-1" href="/publication?d=gitcitadel-project-documentation-gitcitadel-project-1-by-stella-v-1"
class="link"
>GitCitadel project team</A >GitCitadel project team</A
>. >.
</P> </P>
<P class="mb-3"> <P class="text-secondary mb-3">
Please submit support issues on the <A Please submit support issues on the <A
href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues"
target="_blank">Alexandria repo page</A target="_blank"
class="link"
>Alexandria repo page</A
> and follow us on <A > and follow us on <A
href="https://github.com/ShadowySupercode/gitcitadel" href="https://github.com/ShadowySupercode/gitcitadel"
target="_blank">GitHub</A target="_blank"
> and <A href="https://geyser.fund/project/gitcitadel" target="_blank" class="link"
>GitHub</A
> and <A
href="https://geyser.fund/project/gitcitadel"
target="_blank"
class="link"
>Geyserfund</A >Geyserfund</A
>. >.
</P> </P>
<P> <P class="text-secondary">
We are easiest to contact over our Nostr address <A We are easiest to contact over our Nostr address <A
href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg"
title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz"
target="_blank">npub1s3h…75wz</A target="_blank"
class="link"
>npub1s3h…75wz</A
>. >.
</P> </P>
<Heading tag="h2" class="h-leather mt-4 mb-2">Overview</Heading> <Heading tag="h2" class="heading-2 mt-4 mb-2">Overview</Heading>
<P class="mb-4"> <P class="text-secondary mb-4">
Alexandria opens up to the <A href="./">landing page</A>, where the user Alexandria opens up to the <A href="./" class="link">landing page</A>, where the user
can: login (top-right), select whether to only view the publications can: login (top-right), select whether to only view the publications
hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank" hosted on the <A
href="https://thecitadel.nostr1.com/"
target="_blank"
class="link"
>thecitadel document relay</A >thecitadel document relay</A
> or add in their own relays, and scroll/search the publications. > or add in their own relays, and scroll/search the publications.
</P> </P>
<div class="flex flex-col items-center space-y-4 my-4"> <div class="about-image-container">
<Img <Img
src="/screenshots/LandingPage.png" src="/screenshots/LandingPage.png"
alt="Landing page" alt="Landing page"
class="image-border rounded-lg" class="about-image"
width="400" width="400"
/> />
<Img <Img
src="/screenshots/YourRelays.png" src="/screenshots/YourRelays.png"
alt="Relay selection" alt="Relay selection"
class="image-border rounded-lg" class="about-image"
width="400" width="400"
/> />
</div> </div>
<P class="mb-3"> <P class="text-secondary mb-3">
There is also the ability to view the publications as a diagram, if you 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 click on "Visualize", and to publish an e-book or other document (coming
soon). soon).
</P> </P>
<P class="mb-3"> <P class="text-secondary 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
of the content events (30041s and 30818s for wiki pages), in the order in of the content events (30041s and 30818s for wiki pages), in the order in
which they are indexed, and displays them as a single document. which they are indexed, and displays them as a single document.
</P> </P>
<P class="mb-3"> <P class="text-secondary mb-3">
Each content section (30041 or 30818) is also a level in the table of Each content section (30041 or 30818) is also a level in the table of
contents, which can be accessed from the floating icon top-left in the contents, which can be accessed from the floating icon top-left in the
reading view. This allows for navigation within the publication. (This reading view. This allows for navigation within the publication. (This
functionality has been temporarily disabled.) functionality has been temporarily disabled.)
</P> </P>
<div class="flex flex-col items-center space-y-4 my-4"> <div class="about-image-container">
<Img <Img
src="/screenshots/ToC_icon.png" src="/screenshots/ToC_icon.png"
alt="ToC icon" alt="ToC icon"
class="image-border rounded-lg" class="about-image"
width="400" width="400"
/> />
<Img <Img
src="/screenshots/TableOfContents.png" src="/screenshots/TableOfContents.png"
alt="Table of contents example" alt="Table of contents example"
class="image-border rounded-lg" class="about-image"
width="400" width="400"
/> />
</div> </div>
<Heading tag="h2" class="h-leather mt-4 mb-2">Typical use cases</Heading> <Heading tag="h2" class="heading-2 mt-4 mb-2">Typical use cases</Heading>
<Heading tag="h3" class="h-leather mb-3">For e-books</Heading> <Heading tag="h3" class="heading-3 mb-3">For e-books</Heading>
<P class="mb-3"> <P class="text-secondary mb-3">
The most common use for Alexandria is for e-books: both those users have The most common use for Alexandria is for e-books: both those users have
written themselves and those uploaded to Nostr from other sources. The written themselves and those uploaded to Nostr from other sources. The
first minor version of the app, Gutenberg, is focused on displaying and first minor version of the app, Gutenberg, is focused on displaying and
producing these publications. producing these publications.
</P> </P>
<P class="mb-3"> <P class="text-secondary mb-3">
An example of a book is <A An example of a book is <A
href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition" href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition"
class="link"
>Jane Eyre</A >Jane Eyre</A
> >
</P> </P>
<div class="flex justify-center my-4"> <div class="image-container">
<Img <Img
src="/screenshots/JaneEyre.png" src="/screenshots/JaneEyre.png"
alt="Jane Eyre, by Charlotte Brontë" alt="Jane Eyre, by Charlotte Brontë"
class="image-border rounded-lg" class="about-image"
width="400" width="400"
/> />
</div> </div>
<Heading tag="h3" class="h-leather mb-3">For scientific papers</Heading> <Heading tag="h3" class="heading-3 mb-3">For scientific papers</Heading>
<P class="mb-3"> <P class="text-secondary mb-3">
Alexandria will also display research papers with Asciimath and LaTeX Alexandria will also display 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"> <P class="text-secondary mb-3">
Correctly displaying such papers, integrating citations, and allowing them Correctly displaying such papers, integrating citations, and allowing them
to be reviewed (with kind 1111 comments), and annotated (with highlights) to be reviewed (with kind 1111 comments), and annotated (with highlights)
by users, is the focus of the second minor version, Euler. by users, is the focus of the second minor version, Euler.
</P> </P>
<P class="mb-3"> <P class="text-secondary mb-3">
Euler will also pioneer the HTTP-based (rather than websocket-based) Euler will also pioneer the HTTP-based (rather than websocket-based)
e-paper compatible version of the web app. e-paper compatible version of the web app.
</P> </P>
<P class="mb-3"> <P class="text-secondary 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"
class="link"
>Less Partnering, Less Children, or Both?</A >Less Partnering, Less Children, or Both?</A
> >
</P> </P>
<div class="flex justify-center my-4"> <div class="image-container">
<Img <Img
src="/screenshots/ResearchPaper.png" src="/screenshots/ResearchPaper.png"
alt="Research paper" alt="Research paper"
class="image-border rounded-lg" class="about-image"
width="400" width="400"
/> />
</div> </div>
<Heading tag="h3" class="h-leather mb-3">For documentation</Heading> <Heading tag="h3" class="heading-3 mb-3">For documentation</Heading>
<P class="mb-3"> <P class="text-secondary mb-3">
Our own team uses Alexandria to document the app, to display our <A Our own team uses Alexandria to document the app, to display our <A
href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A href="/publication?d=the-gitcitadel-blog-by-stella-v-1"
class="link"
>blog entries</A
>, as well as to store copies of our most interesting <A >, as well as to store copies of our most interesting <A
href="/publication?d=gitcitadel-project-documentation-by-stella-v-1" href="/publication?d=gitcitadel-project-documentation-by-stella-v-1"
class="link"
>technical specifications</A >technical specifications</A
>. >.
</P> </P>
<div class="flex justify-center my-4"> <div class="image-container">
<Img <Img
src="/screenshots/Documentation.png" src="/screenshots/Documentation.png"
alt="Documentation" alt="Documentation"
class="image-border rounded-lg" class="about-image"
width="400" width="400"
/> />
</div> </div>
<Heading tag="h3" class="h-leather mb-3">For wiki pages</Heading> <Heading tag="h3" class="heading-3 mb-3">For wiki pages</Heading>
<P class="mb-3"> <P class="text-secondary mb-3">
Alexandria now supports wiki pages (kind 30818), allowing for Alexandria now supports wiki pages (kind 30818), allowing for
collaborative knowledge bases and documentation. Wiki pages, such as this collaborative knowledge bases and documentation. Wiki pages, such as this
one about the <A href="/publication?d=sybil">Sybil utility</A> use the same one about the <A
href="/publication?d=sybil"
class="link"
>Sybil utility</A> use the same
Asciidoc format as other publications but are specifically designed for interconnected, Asciidoc format as other publications but are specifically designed for interconnected,
evolving content. evolving content.
</P> </P>
<P class="mb-3"> <P class="text-secondary mb-3">
Wiki pages can be linked to from other publications and can contain links Wiki pages can be linked to from other publications and can contain links
to other wiki pages, creating a web of knowledge that can be navigated and to other wiki pages, creating a web of knowledge that can be navigated and
explored. explored.

391
src/routes/contact/+page.svelte

@ -6,9 +6,10 @@
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk';
// @ts-ignore - Workaround for Svelte component import issue // @ts-ignore - Workaround for Svelte component import issue
import LoginModal from '$lib/components/LoginModal.svelte'; import LoginModal from '$lib/components/LoginModal.svelte';
import { parseAdvancedMarkdown } from '$lib/utils/advancedMarkdownParser'; import { parseAdvancedMarkdown } from '$lib/utils/markdown/advancedMarkdownParser';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { getMimeTags } from '$lib/utils/mime'; import { getMimeTags } from '$lib/utils/mime';
import MarkdownForm from '$lib/components/MarkdownForm.svelte';
// Function to close the success message // Function to close the success message
function closeSuccessMessage() { function closeSuccessMessage() {
@ -68,29 +69,16 @@
isExpanded = !isExpanded; isExpanded = !isExpanded;
} }
async function handleSubmit(e: Event) { function handleIssueSubmit(subject: string, content: string) {
// Prevent form submission // Set the local state for subject/content if needed
e.preventDefault(); // subject = subject;
// content = content;
// Call the original handleSubmit logic, but without the event
if (!subject || !content) { if (!subject || !content) {
submissionError = 'Please fill in all fields'; submissionError = 'Please fill in all fields';
return; return;
} }
// Show confirmation dialog or proceed with submission as before
// Check if user is logged in
if (!$ndkSignedIn) {
// Save form data
savedFormData = {
subject,
content
};
// Show login modal
showLoginModal = true;
return;
}
// Show confirmation dialog
showConfirmDialog = true; showConfirmDialog = true;
} }
@ -268,8 +256,8 @@
} }
</script> </script>
<div class='w-full flex justify-center'> <div class='contact-form-container'>
<main class='main-leather flex flex-col space-y-6 max-w-3xl w-full my-6 px-6 sm:px-4'> <main class='contact-form-main'>
<Heading tag='h1' class='h-leather mb-2'>Contact GitCitadel</Heading> <Heading tag='h1' class='h-leather mb-2'>Contact GitCitadel</Heading>
<P class="mb-3"> <P class="mb-3">
@ -286,193 +274,92 @@
If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page. If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page.
</P> </P>
<form class="space-y-4 mt-6" on:submit|preventDefault={handleSubmit}> <MarkdownForm
<div> labelSubject="Subject"
<Label for="subject" class="mb-2">Subject</Label> labelContent="Description"
<Input id="subject" class="w-full" placeholder="Issue subject" bind:value={subject} required autofocus /> submitLabel="Submit Issue"
</div> showSubject={true}
on:submit={({ detail }) => handleIssueSubmit(detail.subject, detail.content)}
<div class="relative"> />
<Label for="content" class="mb-2">Description</Label>
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded ? 'h-[800px]' : 'h-[200px]'} transition-all duration-200 sm:w-[95vw] md:w-full"> <div class="contact-form-actions">
<div class="h-full flex flex-col"> <Button type="button" color="alternative" onclick={clearForm}>
<div class="border-b border-gray-300 dark:border-gray-600"> Clear Form
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" role="tablist"> </Button>
<li class="mr-2" role="presentation"> <Button type="submit" tabindex={0}>
<button {#if isSubmitting}
type="button" Submitting...
class="inline-block p-4 rounded-t-lg {activeTab === 'write' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}" {:else}
on:click={() => activeTab = 'write'} Submit Issue
role="tab" {/if}
> </Button>
Write </div>
</button>
</li> {#if submissionSuccess && submittedEvent}
<li role="presentation"> <div class="contact-form-success" role="alert">
<button <button
type="button" class="contact-form-success-close"
class="inline-block p-4 rounded-t-lg {activeTab === 'preview' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}" onclick={closeSuccessMessage}
on:click={() => activeTab = 'preview'} aria-label="Close"
role="tab" >
> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
Preview <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</button> </svg>
</li> </button>
</ul>
</div> <div class="flex items-center mb-3">
<svg class="contact-form-success-icon" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<div class="flex-1 min-h-0 relative"> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
{#if activeTab === 'write'} </svg>
<div class="absolute inset-0"> <span class="contact-form-success-title">Issue submitted successfully!</span>
<Textarea
id="content"
class="w-full h-full resize-none border-0 focus:ring-0 bg-white dark:bg-gray-800 p-4 description-textarea"
bind:value={content}
required
placeholder="Describe your issue in detail...
The following Markdown is supported:
# Headers (1-6 levels)
*Bold* or **bold**
_Italic_ or __italic__ text
~Strikethrough~ or ~~strikethrough~~ text
> Blockquotes
Lists, including nested:
* Bullets/unordered lists
1. Numbered/ordered lists
[Links](url)
![Images](url)
`Inline code`
```language
Code blocks with syntax highlighting for over 100 languages
```
| Tables | With or without headers |
|--------|------|
| Multiple | Rows |
Footnotes[^1] and [^1]: footnote content
Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. With or without the nostr: prefix."
/>
</div>
{:else}
<div class="absolute inset-0 p-4 prose dark:prose-invert max-w-none bg-white dark:bg-gray-800 prose-content">
{#key content}
{#await parseAdvancedMarkdown(content)}
<p>Loading preview...</p>
{:then html}
{@html html || '<p class="text-gray-500">Nothing to preview</p>'}
{:catch error}
<p class="text-red-500">Error rendering preview: {error.message}</p>
{/await}
{/key}
</div>
{/if}
</div>
</div>
<Button
type="button"
size="xs"
class="absolute bottom-2 right-2 z-10 opacity-60 hover:opacity-100"
color="light"
on:click={toggleSize}
>
{isExpanded ? '⌃' : '⌄'}
</Button>
</div> </div>
</div>
<div class="contact-form-success-content">
<div class="flex justify-end space-x-4"> <div class="mb-2">
<Button type="button" color="alternative" on:click={clearForm}> <span class="font-semibold">Subject:</span>
Clear Form <span>{submittedEvent.tags.find(t => t[0] === 'subject')?.[1] || 'No subject'}</span>
</Button>
<Button type="submit" tabindex={0}>
{#if isSubmitting}
Submitting...
{:else}
Submit Issue
{/if}
</Button>
</div>
{#if submissionSuccess && submittedEvent}
<div class="p-6 mb-4 text-sm bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 rounded-lg relative" role="alert">
<!-- Close button -->
<button
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-100"
on:click={closeSuccessMessage}
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<div class="flex items-center mb-3">
<svg class="w-5 h-5 mr-2 text-success-700 dark:text-success-300" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
<span class="font-medium text-success-800 dark:text-success-200">Issue submitted successfully!</span>
</div>
<div class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600">
<div class="mb-2">
<span class="font-semibold">Subject:</span>
<span>{submittedEvent.tags.find(t => t[0] === 'subject')?.[1] || 'No subject'}</span>
</div>
<div>
<span class="font-semibold">Description:</span>
<div class="mt-1 note-leather max-h-[400px] overflow-y-auto">
{#await parseAdvancedMarkdown(submittedEvent.content)}
<p>Loading...</p>
{:then html}
{@html html}
{:catch error}
<p class="text-red-500">Error rendering markdown: {error.message}</p>
{/await}
</div>
</div>
</div> </div>
<div>
<div class="mb-3"> <span class="font-semibold">Description:</span>
<span class="font-semibold">View your issue:</span> <div class="contact-form-success-description">
<div class="mt-1"> {#await parseAdvancedMarkdown(submittedEvent.content)}
<A href={issueLink} target="_blank" class="hover:underline text-primary-600 dark:text-primary-500 break-all"> <p>Loading...</p>
{issueLink} {:then html}
</A> {@html html}
{:catch error}
<p class="text-red-500">Error rendering markdown: {error.message}</p>
{/await}
</div> </div>
</div> </div>
</div>
<!-- Display successful relays -->
<div class="text-sm"> <div class="mb-3">
<span class="font-semibold">Successfully published to relays:</span> <span class="font-semibold">View your issue:</span>
<ul class="list-disc list-inside mt-1"> <div class="mt-1">
{#each successfulRelays as relay} <A href={issueLink} target="_blank" class="hover:underline text-primary-600 dark:text-primary-500 break-all">
<li class="text-success-700 dark:text-success-300">{relay}</li> {issueLink}
{/each} </A>
</ul>
</div> </div>
</div> </div>
{/if}
<!-- Display successful relays -->
{#if submissionError} <div class="text-sm">
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert"> <span class="font-semibold">Successfully published to relays:</span>
{submissionError} <ul class="list-disc list-inside mt-1">
{#each successfulRelays as relay}
<li class="text-success-700 dark:text-success-300">{relay}</li>
{/each}
</ul>
</div> </div>
{/if} </div>
</form> {/if}
</main> {#if submissionError}
<div class="contact-form-error" role="alert">
{submissionError}
</div>
{/if}
</main>
</div> </div>
<!-- Confirmation Dialog --> <!-- Confirmation Dialog -->
@ -482,15 +369,15 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
autoclose={false} autoclose={false}
class="w-full" class="w-full"
> >
<div class="text-center"> <div class="contact-form-confirm-dialog">
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400"> <h3 class="contact-form-confirm-title">
Would you like to submit the issue? Would you like to submit the issue?
</h3> </h3>
<div class="flex justify-center gap-4"> <div class="contact-form-confirm-actions">
<Button color="alternative" on:click={cancelSubmit}> <Button color="alternative" onclick={cancelSubmit}>
Cancel Cancel
</Button> </Button>
<Button color="primary" on:click={confirmSubmit}> <Button color="primary" onclick={confirmSubmit}>
Submit Submit
</Button> </Button>
</div> </div>
@ -510,95 +397,3 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
submitIssue(); submitIssue();
}} }}
/> />
<style>
:global(.footnote-ref) {
text-decoration: none;
color: var(--color-primary);
}
:global(.footnotes) {
margin-top: 2rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}
:global(.footnotes hr) {
margin: 1rem 0;
border-top: 1px solid var(--color-border);
}
:global(.footnotes ol) {
padding-left: 1rem;
}
:global(.footnotes li) {
margin-bottom: 0.5rem;
}
:global(.footnote-backref) {
text-decoration: none;
margin-left: 0.5rem;
color: var(--color-primary);
}
:global(.note-leather) :global(.footnote-ref),
:global(.note-leather) :global(.footnote-backref) {
color: var(--color-leather-primary);
}
:global(.description-textarea) {
overflow-y: scroll !important;
scrollbar-width: thin !important;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important;
min-height: 100% !important;
}
:global(.description-textarea::-webkit-scrollbar) {
width: 8px !important;
display: block !important;
}
:global(.description-textarea::-webkit-scrollbar-track) {
background: transparent !important;
}
:global(.description-textarea::-webkit-scrollbar-thumb) {
background-color: rgba(156, 163, 175, 0.5) !important;
border-radius: 4px !important;
}
:global(.description-textarea::-webkit-scrollbar-thumb:hover) {
background-color: rgba(156, 163, 175, 0.7) !important;
}
:global(.prose-content) {
overflow-y: scroll !important;
scrollbar-width: thin !important;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important;
}
:global(.prose-content::-webkit-scrollbar) {
width: 8px !important;
display: block !important;
}
:global(.prose-content::-webkit-scrollbar-track) {
background: transparent !important;
}
:global(.prose-content::-webkit-scrollbar-thumb) {
background-color: rgba(156, 163, 175, 0.5) !important;
border-radius: 4px !important;
}
:global(.prose-content::-webkit-scrollbar-thumb:hover) {
background-color: rgba(156, 163, 175, 0.7) !important;
}
:global(.tab-content) {
position: relative;
display: flex;
flex-direction: column;
}
</style>

125
src/routes/contact/ContactForm.svelte

@ -0,0 +1,125 @@
<script lang='ts'>
import { P, Button, Label, Textarea, Input } from 'flowbite-svelte';
import { parseAdvancedMarkdown } from '$lib/utils/markdown/advancedMarkdownParser';
import { createEventDispatcher } from 'svelte';
// Props for initial state
export let initialSubject = '';
export let initialContent = '';
// State
let subject = initialSubject;
let content = initialContent;
let isSubmitting = false;
let isExpanded = false;
let activeTab = 'write';
let submissionError = '';
const dispatch = createEventDispatcher();
function clearForm() {
subject = '';
content = '';
submissionError = '';
isExpanded = false;
activeTab = 'write';
}
function toggleSize() {
isExpanded = !isExpanded;
}
function handleSubmit(e: Event) {
e.preventDefault();
if (!subject || !content) {
submissionError = 'Please fill in all fields';
return;
}
dispatch('submit', { subject, content });
}
</script>
<form class="contact-form" onsubmit={handleSubmit}>
<div>
<Label for="subject" class="mb-2">Subject</Label>
<Input id="subject" class="contact-form-input" placeholder="Issue subject" bind:value={subject} required autofocus />
</div>
<div class="relative">
<Label for="content" class="mb-2">Description</Label>
<div class="contact-form-textarea-container {isExpanded ? 'expanded' : 'collapsed'}">
<div class="h-full flex flex-col">
<div class="border-b border-gray-300 dark:border-gray-600">
<ul class="contact-form-tabs" role="tablist">
<li class="mr-2" role="presentation">
<button
type="button"
class="contact-form-tab {activeTab === 'write' ? 'active' : 'inactive'}"
onclick={() => activeTab = 'write'}
role="tab"
>
Write
</button>
</li>
<li role="presentation">
<button
type="button"
class="contact-form-tab {activeTab === 'preview' ? 'active' : 'inactive'}"
onclick={() => activeTab = 'preview'}
role="tab"
>
Preview
</button>
</li>
</ul>
</div>
<div class="flex-1 min-h-0 relative">
{#if activeTab === 'write'}
<div class="contact-form-tab-content">
<Textarea
id="content"
class="contact-form-textarea"
bind:value={content}
required
placeholder="Describe your issue in detail..."
/>
</div>
{:else}
<div class="contact-form-preview">
{#key content}
{#await parseAdvancedMarkdown(content)}
<p>Loading preview...</p>
{:then html}
{@html html || '<p class="text-gray-500">Nothing to preview</p>'}
{:catch error}
<p class="text-red-500">Error rendering preview: {error.message}</p>
{/await}
{/key}
</div>
{/if}
</div>
</div>
<Button
type="button"
size="xs"
class="contact-form-toggle"
color="light"
onclick={toggleSize}
>
{isExpanded ? '⌃' : '⌄'}
</Button>
</div>
</div>
<div class="contact-form-actions">
<Button type="button" color="alternative" onclick={clearForm}>
Clear Form
</Button>
<Button type="submit" tabindex={0}>
Submit Issue
</Button>
</div>
{#if submissionError}
<div class="contact-form-error" role="alert">
{submissionError}
</div>
{/if}
</form>

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

@ -47,40 +47,40 @@
} }
</script> </script>
<div class='w-full flex justify-center'> <div class="edit-container">
<main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'> <main class="edit-main">
<Heading tag='h1' class='h-leather mb-2'>Edit</Heading> <Heading tag="h1" class="heading-1 mb-2">Edit</Heading>
{#if isEditing} {#if isEditing}
<form> <form class="form-base">
<Textarea <Textarea
id='article-content' id="article-content"
class='textarea-leather' class="form-textarea"
rows={8} rows={8}
placeholder='Write AsciiDoc content' placeholder="Write AsciiDoc content"
bind:value={editorText} bind:value={editorText}
> >
<Toolbar slot='header' embedded> <Toolbar slot="header" embedded>
<ToolbarButton name='Preview' on:click={showPreview}> <ToolbarButton name="Preview" on:click={showPreview}>
<EyeSolid class='w-6 h-6' /> <EyeSolid class="w-6 h-6" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton name='Review' slot='end' on:click={prepareReview}> <ToolbarButton name="Review" slot="end" on:click={prepareReview}>
<PaperPlaneOutline class='w=6 h-6 rotate-90' /> <PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton> </ToolbarButton>
</Toolbar> </Toolbar>
</Textarea> </Textarea>
</form> </form>
{:else} {:else}
<form class='border border-gray-400 dark:border-gray-600 rounded-lg flex flex-col space-y-2 h-fit'> <form class="edit-preview-form">
<Toolbar class='toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800'> <Toolbar class="edit-preview-toolbar">
<ToolbarButton name='Edit' on:click={hidePreview}> <ToolbarButton name="Edit" on:click={hidePreview}>
<CodeOutline class='w-6 h-6' /> <CodeOutline class="w-6 h-6" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton name='Review' slot='end' on:click={prepareReview}> <ToolbarButton name="Review" slot="end" on:click={prepareReview}>
<PaperPlaneOutline class='w=6 h-6 rotate-90' /> <PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton> </ToolbarButton>
</Toolbar> </Toolbar>
{#if rootIndexId} {#if rootIndexId}
<Preview sectionClass='m-2' rootId={rootIndexId} /> <Preview sectionClass="m-2" rootId={rootIndexId} />
{/if} {/if}
</form> </form>
{/if} {/if}

19
src/routes/visualize/+page.svelte

@ -115,14 +115,14 @@
}); });
</script> </script>
<div class="leather w-full p-4 relative"> <div class="visualize-container">
<!-- Header with title and settings button --> <!-- Header with title and settings button -->
<div class="flex items-center mb-4"> <div class="visualize-header">
<h1 class="h-leather">Publication Network</h1> <h1 class="heading-1">Publication Network</h1>
</div> </div>
<!-- Loading spinner --> <!-- Loading spinner -->
{#if loading} {#if loading}
<div class="flex justify-center items-center h-64"> <div class="visualize-loading">
<div role="status"> <div role="status">
<svg <svg
aria-hidden="true" aria-hidden="true"
@ -145,15 +145,12 @@
</div> </div>
<!-- Error message --> <!-- Error message -->
{:else if error} {:else if error}
<div <div class="visualize-error" role="alert">
class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" <p class="heading-3">Error loading network:</p>
role="alert" <p class="text-secondary mb-3">{error}</p>
>
<p class="font-bold mb-2">Error loading network:</p>
<p class="mb-3">{error}</p>
<button <button
type="button" type="button"
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 mt-2 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800" class="btn-base text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800"
on:click={fetchEvents} on:click={fetchEvents}
> >
Retry Retry

Loading…
Cancel
Save