initial version (alpha)
This commit is contained in:
commit
0c965b54ed
56 changed files with 10437 additions and 0 deletions
21
.githooks/pre-commit
Executable file
21
.githooks/pre-commit
Executable file
|
@ -0,0 +1,21 @@
|
|||
#!/bin/sh
|
||||
|
||||
clear
|
||||
|
||||
echo "Running Biome format..."
|
||||
bunx @biomejs/biome format --write
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Biome format failed. Commit rejected."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running Biome check on staged files..."
|
||||
bunx @biomejs/biome check --staged --error-on-warnings --no-errors-on-unmatched
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ All checks passed. Proceeding with commit."
|
||||
exit 0
|
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
|
@ -0,0 +1,34 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
7
.zed/settings.json
Normal file
7
.zed/settings.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
// Folder-specific settings
|
||||
//
|
||||
// For a full list of overridable settings, and general information on folder-specific settings,
|
||||
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
|
||||
{
|
||||
"format_on_save": "on"
|
||||
}
|
113
README.md
Normal file
113
README.md
Normal file
|
@ -0,0 +1,113 @@
|
|||
# 🕊️ Eve Lite
|
||||
|
||||
> A lightweight, encrypted Nostr relay that puts your community's privacy first
|
||||
|
||||
Eve Lite is your personal gateway to secure, decentralized communities It creates encrypted "Closed Community Networks" (CCNs) where your messages stay truly private, from everyone.
|
||||
|
||||
## ✨ What makes Eve Lite special?
|
||||
|
||||
**Privacy by Design**: Unlike traditional Nostr relays where your messages are visible to everyone, Eve Lite encrypts everything. If someone gains access to the data, they'll only see encrypted gibberish.
|
||||
|
||||
**Extensible with Arxlets**: Think of Arxlets as mini-apps that run within your CCN. Want custom functionality? Write an Arxlet. Need to share functionality? Publish it for others to use.
|
||||
|
||||
**Dead Simple Setup**: No complex configurations or database management. Eve Lite handles the heavy lifting so you can focus on communicating.
|
||||
|
||||
**Invite-Only Networks**: Create closed communities with your friends, family, or team. Share invite codes to bring people into your private space.
|
||||
|
||||
## 🏗️ How it works
|
||||
|
||||
### Closed Community Networks (CCNs)
|
||||
|
||||
A CCN is like your own private island in the Nostr ocean. Each CCN has:
|
||||
|
||||
- **Unique encryption keys** - Messages are encrypted with network-specific keys
|
||||
- **Local relay instance** - Each member's own strfry relay runs locally on port 6942
|
||||
- **Persistent storage** - All messages are stored locally on each member's device and encrypted
|
||||
|
||||
### The Magic Behind the Scenes
|
||||
|
||||
1. **Encrypted Message Flow**: When you send a message, Eve Lite encrypts it before sending to remote relays
|
||||
2. **Smart Decryption**: Incoming encrypted messages are automatically decrypted and stored locally
|
||||
3. **Dual Relay System**: Remote encrypted storage + local decrypted access = best of both worlds
|
||||
4. **Event Deduplication**: Smart caching prevents processing the same event twice
|
||||
|
||||
### Arxlets: Your Extensibility Layer
|
||||
|
||||
Arxlets are JavaScript-based apps that add functionality to your CCN:
|
||||
|
||||
- Stored as Nostr events (kind 30420)
|
||||
- Compressed with Brotli for efficiency
|
||||
- Can be shared across networks
|
||||
- Perfect for custom UI components, utilities, or integrations
|
||||
|
||||
## 🔧 API Reference
|
||||
|
||||
Eve Lite exposes a RESTful API for building applications:
|
||||
|
||||
### CCN Management
|
||||
|
||||
- `GET /api/ccns` - List all CCNs
|
||||
- `POST /api/ccns/new` - Create a new CCN
|
||||
- `POST /api/ccns/join` - Join an existing CCN
|
||||
- `GET /api/ccns/active` - Get the active CCN
|
||||
- `POST /api/ccns/active` - Switch active CCN
|
||||
|
||||
### Events & Messages
|
||||
|
||||
- `POST /api/events` - Query events with filters
|
||||
- `PUT /api/events` - Publish a new event
|
||||
- `GET /api/events/:id` - Get specific event
|
||||
- `POST /api/sign` - Sign an event with your key
|
||||
|
||||
### Profiles & Identity
|
||||
|
||||
- `GET /api/avatars/:pubkey` - Get user avatar
|
||||
- `GET /api/profile/:pubkey` - Get user profile
|
||||
- `GET /api/pubkey` - Get your public key
|
||||
|
||||
### Arxlets
|
||||
|
||||
- `GET /api/arxlets` - List installed Arxlets
|
||||
- `GET /api/arxlets/:id` - Get specific Arxlet
|
||||
- `GET /api/arxlets-available` - Browse available Arxlets
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
bun test
|
||||
bun test --watch # Watch mode
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
This project uses Biome for linting and formatting:
|
||||
|
||||
```bash
|
||||
bunx @biomejs/biome check
|
||||
bunx @biomejs/biome format --write
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Found a bug? Have a feature idea? Contributions are welcome!
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: `git checkout -b my-cool-feature`
|
||||
3. Make your changes and add tests
|
||||
4. Submit a pull request
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project is private and proprietary. Please respect the license terms.
|
||||
|
||||
## 🙋♀️ Questions?
|
||||
|
||||
- Check out the [Arxlet documentation](/docs/arxlets) for extending functionality
|
||||
- Browse the source code - it's well-documented and approachable
|
||||
- Open an issue if you find bugs or have suggestions
|
||||
|
||||
---
|
||||
|
||||
_Built with ❤️ for privacy-conscious humans who believe communication should be truly private._
|
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 737 KiB |
38
biome.json
Normal file
38
biome.json
Normal file
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!src/pages/home/home.css", "!src/pages/**/highlight"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"formatWithErrors": true,
|
||||
"lineWidth": 120,
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"noSvgWithoutTitle": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
}
|
||||
}
|
164
bun.lock
Normal file
164
bun.lock
Normal file
|
@ -0,0 +1,164 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "eve-lite",
|
||||
"dependencies": {
|
||||
"@dicebear/collection": "^9.2.4",
|
||||
"@dicebear/core": "^9.2.4",
|
||||
"@noble/ciphers": "^1.3.0",
|
||||
"@scure/base": "^2.0.0",
|
||||
"bun-plugin-tailwind": "^0.0.15",
|
||||
"daisyui": "^5.1.10",
|
||||
"nostr-tools": "^2.16.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.2.4",
|
||||
"@types/bun": "^1.2.21",
|
||||
"preact": "^10.27.1",
|
||||
"prism-svelte": "^0.5.0",
|
||||
"prismjs": "^1.30.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@biomejs/biome": ["@biomejs/biome@2.2.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.4", "@biomejs/cli-darwin-x64": "2.2.4", "@biomejs/cli-linux-arm64": "2.2.4", "@biomejs/cli-linux-arm64-musl": "2.2.4", "@biomejs/cli-linux-x64": "2.2.4", "@biomejs/cli-linux-x64-musl": "2.2.4", "@biomejs/cli-win32-arm64": "2.2.4", "@biomejs/cli-win32-x64": "2.2.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="],
|
||||
|
||||
"@dicebear/adventurer": ["@dicebear/adventurer@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-Xvboay3VH1qe7lH17T+bA3qPawf5EjccssDiyhCX/VT0P21c65JyjTIUJV36Nsv08HKeyDscyP0kgt9nPTRKvA=="],
|
||||
|
||||
"@dicebear/adventurer-neutral": ["@dicebear/adventurer-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-I9IrB4ZYbUHSOUpWoUbfX3vG8FrjcW8htoQ4bEOR7TYOKKE11Mo1nrGMuHZ7GPfwN0CQeK1YVJhWqLTmtYn7Pg=="],
|
||||
|
||||
"@dicebear/avataaars": ["@dicebear/avataaars@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-QKNBtA/1QGEzR+JjS4XQyrFHYGbzdOp0oa6gjhGhUDrMegDFS8uyjdRfDQsFTebVkyLWjgBQKZEiDqKqHptB6A=="],
|
||||
|
||||
"@dicebear/avataaars-neutral": ["@dicebear/avataaars-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-HtBvA7elRv50QTOOsBdtYB1GVimCpGEDlDgWsu1snL5Z3d1+3dIESoXQd3mXVvKTVT8Z9ciA4TEaF09WfxDjAA=="],
|
||||
|
||||
"@dicebear/big-ears": ["@dicebear/big-ears@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-U33tbh7Io6wG6ViUMN5fkWPER7hPKMaPPaYgafaYQlCT4E7QPKF2u8X1XGag3jCKm0uf4SLXfuZ8v+YONcHmNQ=="],
|
||||
|
||||
"@dicebear/big-ears-neutral": ["@dicebear/big-ears-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-pPjYu80zMFl43A9sa5+tAKPkhp4n9nd7eN878IOrA1HAowh/XePh5JN8PTkNFS9eM+rnN9m8WX08XYFe30kLYw=="],
|
||||
|
||||
"@dicebear/big-smile": ["@dicebear/big-smile@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-zeEfXOOXy7j9tfkPLzfQdLBPyQsctBetTdEfKRArc1k3RUliNPxfJG9j88+cXQC6GXrVW2pcT2X50NSPtugCFQ=="],
|
||||
|
||||
"@dicebear/bottts": ["@dicebear/bottts@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-4CTqrnVg+NQm6lZ4UuCJish8gGWe8EqSJrzvHQRO5TEyAKjYxbTdVqejpkycG1xkawha4FfxsYgtlSx7UwoVMw=="],
|
||||
|
||||
"@dicebear/bottts-neutral": ["@dicebear/bottts-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-eMVdofdD/udHsKIaeWEXShDRtiwk7vp4FjY7l0f79vIzfhkIsXKEhPcnvHKOl/yoArlDVS3Uhgjj0crWTO9RJA=="],
|
||||
|
||||
"@dicebear/collection": ["@dicebear/collection@9.2.4", "", { "dependencies": { "@dicebear/adventurer": "9.2.4", "@dicebear/adventurer-neutral": "9.2.4", "@dicebear/avataaars": "9.2.4", "@dicebear/avataaars-neutral": "9.2.4", "@dicebear/big-ears": "9.2.4", "@dicebear/big-ears-neutral": "9.2.4", "@dicebear/big-smile": "9.2.4", "@dicebear/bottts": "9.2.4", "@dicebear/bottts-neutral": "9.2.4", "@dicebear/croodles": "9.2.4", "@dicebear/croodles-neutral": "9.2.4", "@dicebear/dylan": "9.2.4", "@dicebear/fun-emoji": "9.2.4", "@dicebear/glass": "9.2.4", "@dicebear/icons": "9.2.4", "@dicebear/identicon": "9.2.4", "@dicebear/initials": "9.2.4", "@dicebear/lorelei": "9.2.4", "@dicebear/lorelei-neutral": "9.2.4", "@dicebear/micah": "9.2.4", "@dicebear/miniavs": "9.2.4", "@dicebear/notionists": "9.2.4", "@dicebear/notionists-neutral": "9.2.4", "@dicebear/open-peeps": "9.2.4", "@dicebear/personas": "9.2.4", "@dicebear/pixel-art": "9.2.4", "@dicebear/pixel-art-neutral": "9.2.4", "@dicebear/rings": "9.2.4", "@dicebear/shapes": "9.2.4", "@dicebear/thumbs": "9.2.4" }, "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-I1wCUp0yu5qSIeMQHmDYXQIXKkKjcja/SYBxppPkYFXpR2alxb0k9/swFDdMbkY6a1c9AT1kI1y+Pg6ywQ2rTA=="],
|
||||
|
||||
"@dicebear/core": ["@dicebear/core@9.2.4", "", { "dependencies": { "@types/json-schema": "^7.0.11" } }, "sha512-hz6zArEcUwkZzGOSJkWICrvqnEZY7BKeiq9rqKzVJIc1tRVv0MkR0FGvIxSvXiK9TTIgKwu656xCWAGAl6oh+w=="],
|
||||
|
||||
"@dicebear/croodles": ["@dicebear/croodles@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-CqT0NgVfm+5kd+VnjGY4WECNFeOrj5p7GCPTSEA7tCuN72dMQOX47P9KioD3wbExXYrIlJgOcxNrQeb/FMGc3A=="],
|
||||
|
||||
"@dicebear/croodles-neutral": ["@dicebear/croodles-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-8vAS9lIEKffSUVx256GSRAlisB8oMX38UcPWw72venO/nitLVsyZ6hZ3V7eBdII0Onrjqw1RDndslQODbVcpTw=="],
|
||||
|
||||
"@dicebear/dylan": ["@dicebear/dylan@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-tiih1358djAq0jDDzmW3N3S4C3ynC2yn4hhlTAq/MaUAQtAi47QxdHdFGdxH0HBMZKqA4ThLdVk3yVgN4xsukg=="],
|
||||
|
||||
"@dicebear/fun-emoji": ["@dicebear/fun-emoji@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-Od729skczse1HvHekgEFv+mSuJKMC4sl5hENGi/izYNe6DZDqJrrD0trkGT/IVh/SLXUFbq1ZFY9I2LoUGzFZg=="],
|
||||
|
||||
"@dicebear/glass": ["@dicebear/glass@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-5lxbJode1t99eoIIgW0iwZMoZU4jNMJv/6vbsgYUhAslYFX5zP0jVRscksFuo89TTtS7YKqRqZAL3eNhz4bTDw=="],
|
||||
|
||||
"@dicebear/icons": ["@dicebear/icons@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-bRsK1qj8u9Z76xs8XhXlgVr/oHh68tsHTJ/1xtkX9DeTQTSamo2tS26+r231IHu+oW3mePtFnwzdG9LqEPRd4A=="],
|
||||
|
||||
"@dicebear/identicon": ["@dicebear/identicon@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-R9nw/E8fbu9HltHOqI9iL/o9i7zM+2QauXWMreQyERc39oGR9qXiwgBxsfYGcIS4C85xPyuL5B3I2RXrLBlJPg=="],
|
||||
|
||||
"@dicebear/initials": ["@dicebear/initials@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-4SzHG5WoQZl1TGcpEZR4bdsSkUVqwNQCOwWSPAoBJa3BNxbVsvL08LF7I97BMgrCoknWZjQHUYt05amwTPTKtg=="],
|
||||
|
||||
"@dicebear/lorelei": ["@dicebear/lorelei@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-eS4mPYUgDpo89HvyFAx/kgqSSKh8W4zlUA8QJeIUCWTB0WpQmeqkSgIyUJjGDYSrIujWi+zEhhckksM5EwW0Dg=="],
|
||||
|
||||
"@dicebear/lorelei-neutral": ["@dicebear/lorelei-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-bWq2/GonbcJULtT+B/MGcM2UnA7kBQoH+INw8/oW83WI3GNTZ6qEwe3/W4QnCgtSOhUsuwuiSULguAFyvtkOZQ=="],
|
||||
|
||||
"@dicebear/micah": ["@dicebear/micah@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-XNWJ8Mx+pncIV8Ye0XYc/VkMiax8kTxcP3hLTC5vmELQyMSLXzg/9SdpI+W/tCQghtPZRYTT3JdY9oU9IUlP2g=="],
|
||||
|
||||
"@dicebear/miniavs": ["@dicebear/miniavs@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-k7IYTAHE/4jSO6boMBRrNlqPT3bh7PLFM1atfe0nOeCDwmz/qJUBP3HdONajbf3fmo8f2IZYhELrNWTOE7Ox3Q=="],
|
||||
|
||||
"@dicebear/notionists": ["@dicebear/notionists@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-zcvpAJ93EfC0xQffaPZQuJPShwPhnu9aTcoPsaYGmw0oEDLcv2XYmDhUUdX84QYCn6LtCZH053rHLVazRW+OGw=="],
|
||||
|
||||
"@dicebear/notionists-neutral": ["@dicebear/notionists-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-fskWzBVxQzJhCKqY24DGZbYHSBaauoRa1DgXM7+7xBuksH7mfbTmZTvnUAsAqJYBkla8IPb4ERKduDWtlWYYjQ=="],
|
||||
|
||||
"@dicebear/open-peeps": ["@dicebear/open-peeps@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-s6nwdjXFsplqEI7imlsel4Gt6kFVJm6YIgtZSpry0UdwDoxUUudei5bn957j9lXwVpVUcRjJW+TuEKztYjXkKQ=="],
|
||||
|
||||
"@dicebear/personas": ["@dicebear/personas@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-JNim8RfZYwb0MfxW6DLVfvreCFIevQg+V225Xe5tDfbFgbcYEp4OU/KaiqqO2476OBjCw7i7/8USbv2acBhjwA=="],
|
||||
|
||||
"@dicebear/pixel-art": ["@dicebear/pixel-art@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-4Ao45asieswUdlCTBZqcoF/0zHR3OWUWB0Mvhlu9b1Fbc6IlPBiOfx2vsp6bnVGVnMag58tJLecx2omeXdECBQ=="],
|
||||
|
||||
"@dicebear/pixel-art-neutral": ["@dicebear/pixel-art-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-ZITPLD1cPN4GjKkhWi80s7e5dcbXy34ijWlvmxbc4eb/V7fZSsyRa9EDUW3QStpo+xrCJLcLR+3RBE5iz0PC/A=="],
|
||||
|
||||
"@dicebear/rings": ["@dicebear/rings@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-teZxELYyV2ogzgb5Mvtn/rHptT0HXo9SjUGS4A52mOwhIdHSGGU71MqA1YUzfae9yJThsw6K7Z9kzuY2LlZZHA=="],
|
||||
|
||||
"@dicebear/shapes": ["@dicebear/shapes@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-MhK9ZdFm1wUnH4zWeKPRMZ98UyApolf5OLzhCywfu38tRN6RVbwtBRHc/42ZwoN1JU1JgXr7hzjYucMqISHtbA=="],
|
||||
|
||||
"@dicebear/thumbs": ["@dicebear/thumbs@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-EL4sMqv9p2+1Xy3d8e8UxyeKZV2+cgt3X2x2RTRzEOIIhobtkL8u6lJxmJbiGbpVtVALmrt5e7gjmwqpryYDpg=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||
|
||||
"@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="],
|
||||
|
||||
"@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="],
|
||||
|
||||
"@scure/bip32": ["@scure/bip32@1.3.1", "", { "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", "@scure/base": "~1.1.0" } }, "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A=="],
|
||||
|
||||
"@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.3.2", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-6L8PkB+m1SSb2kaGGFk3iXENxl8lrs7cyVl7AXH6pgdMfulDfM6yUrVdjtxdnGrLrGzzuav8fFnZMY+rcscqcA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
|
||||
|
||||
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.0.15", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-qtAXMNGG4R0UGGI8zWrqm2B7BdXqx48vunJXBPzfDOHPA5WkRUZdTSbE7TFwO4jLhYqSE23YMWsM9NhE6ovobw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"daisyui": ["daisyui@5.1.25", "", {}, "sha512-LYOGVIzTCCucEFkKmdj0fxbHHPZ83fpkYD7jXYF3/7UwrUu68TtXkIdGtEXadzeqUT361hCe6cj5tBB/7mvszw=="],
|
||||
|
||||
"nostr-tools": ["nostr-tools@2.17.0", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-lrvHM7cSaGhz7F0YuBvgHMoU2s8/KuThihDoOYk8w5gpVHTy0DeUCAgCN8uLGeuSl5MAWekJr9Dkfo5HClqO9w=="],
|
||||
|
||||
"nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="],
|
||||
|
||||
"preact": ["preact@10.27.2", "", {}, "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg=="],
|
||||
|
||||
"prism-svelte": ["prism-svelte@0.5.0", "", {}, "sha512-db91Bf3pRGKDPz1lAqLFSJXeW13mulUJxhycysFpfXV5MIK7RgWWK2E5aPAa71s8TCzQUXxF5JOV42/iOs6QkA=="],
|
||||
|
||||
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
|
||||
|
||||
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
|
||||
|
||||
"@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
|
||||
|
||||
"@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="],
|
||||
|
||||
"@scure/bip32/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
|
||||
|
||||
"@scure/bip39/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
|
||||
|
||||
"nostr-tools/@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="],
|
||||
|
||||
"nostr-tools/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
|
||||
}
|
||||
}
|
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
|
@ -0,0 +1,2 @@
|
|||
[serve.static]
|
||||
plugins = ["bun-plugin-tailwind"]
|
418
index.ts
Normal file
418
index.ts
Normal file
|
@ -0,0 +1,418 @@
|
|||
import { adventurerNeutral } from "@dicebear/collection";
|
||||
import { createAvatar } from "@dicebear/core";
|
||||
import { $ } from "bun";
|
||||
import { finalizeEvent, getPublicKey, type NostrEvent } from "nostr-tools";
|
||||
import type { SubCloser } from "nostr-tools/abstract-pool";
|
||||
import { fetchRemoteArxlets } from "./src/arxlets";
|
||||
import { CCN } from "./src/ccns";
|
||||
import arxletDocs from "./src/pages/docs/arxlets/arxlet-docs.html";
|
||||
import homePage from "./src/pages/home/home.html";
|
||||
import { getColorFromPubkey } from "./src/utils/color";
|
||||
import { decryptEvent } from "./src/utils/encryption";
|
||||
import { loadSeenEvents, saveSeenEvent } from "./src/utils/files";
|
||||
import {
|
||||
queryRemoteEvent,
|
||||
queryRemoteRelays,
|
||||
sendUnencryptedEventToLocalRelay,
|
||||
} from "./src/utils/general";
|
||||
import { DEFAULT_PERIOD_MINUTES, RollingIndex } from "./src/rollingIndex";
|
||||
|
||||
let currentActiveSub: SubCloser | undefined;
|
||||
let currentSubInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
async function restartCCN() {
|
||||
currentActiveSub?.close();
|
||||
let ccn = await CCN.getActive();
|
||||
if (!ccn) {
|
||||
const allCCNs = await CCN.list();
|
||||
if (allCCNs.length > 0) {
|
||||
await allCCNs[0]!.setActive();
|
||||
ccn = allCCNs[0]!;
|
||||
} else return;
|
||||
}
|
||||
|
||||
async function handleNewEvent(original: NostrEvent) {
|
||||
if (!ccn) return process.exit(1);
|
||||
const seenEvents = await loadSeenEvents();
|
||||
if (seenEvents.includes(original.id)) return;
|
||||
await saveSeenEvent(original);
|
||||
const keyAtTime = ccn.getPrivateKeyAt(
|
||||
RollingIndex.at(original.created_at * 1000),
|
||||
);
|
||||
const decrypted = await decryptEvent(original, keyAtTime);
|
||||
if (seenEvents.includes(decrypted.id)) return;
|
||||
await saveSeenEvent(decrypted);
|
||||
await sendUnencryptedEventToLocalRelay(decrypted);
|
||||
}
|
||||
|
||||
await $`killall -9 strfry`.nothrow().quiet();
|
||||
await ccn.writeStrfryConfig();
|
||||
const strfry = Bun.spawn([
|
||||
"strfry",
|
||||
"--config",
|
||||
ccn.strfryConfigPath,
|
||||
"relay",
|
||||
]);
|
||||
process.on("exit", () => strfry.kill());
|
||||
const allKeysForCCN = ccn.allPubkeys;
|
||||
function resetActiveSub() {
|
||||
console.log(`Setting new subscription for ${allKeysForCCN.join(", ")}`);
|
||||
currentActiveSub?.close();
|
||||
currentActiveSub = queryRemoteRelays(
|
||||
{ kinds: [1060], "#p": allKeysForCCN },
|
||||
handleNewEvent,
|
||||
);
|
||||
}
|
||||
resetActiveSub();
|
||||
currentSubInterval = setInterval(
|
||||
() => {
|
||||
resetActiveSub();
|
||||
},
|
||||
DEFAULT_PERIOD_MINUTES * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
restartCCN();
|
||||
|
||||
class CorsResponse extends Response {
|
||||
constructor(body?: BodyInit, init?: ResponseInit) {
|
||||
super(body, init);
|
||||
this.headers.set("Access-Control-Allow-Origin", "*");
|
||||
this.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT");
|
||||
this.headers.set("Access-Control-Allow-Headers", "Content-Type");
|
||||
}
|
||||
|
||||
static override json(json: unknown, init?: ResponseInit) {
|
||||
const res = Response.json(json, init);
|
||||
res.headers.set("Access-Control-Allow-Origin", "*");
|
||||
res.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT");
|
||||
res.headers.set("Access-Control-Allow-Headers", "Content-Type");
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
const invalidRequest = CorsResponse.json(
|
||||
{ error: "Invalid Request" },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
const httpServer = Bun.serve({
|
||||
routes: {
|
||||
"/": homePage,
|
||||
"/docs/arxlets": arxletDocs,
|
||||
"/api/ccns": {
|
||||
GET: async () => {
|
||||
const ccns = await CCN.list();
|
||||
return CorsResponse.json(ccns.map((x) => x.toPublicJson()));
|
||||
},
|
||||
},
|
||||
"/api/ccns/active": {
|
||||
GET: async () => {
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn)
|
||||
return CorsResponse.json({ error: "Not found" }, { status: 404 });
|
||||
return CorsResponse.json(ccn.toPublicJson());
|
||||
},
|
||||
POST: async (req) => {
|
||||
if (!req.body) return invalidRequest;
|
||||
const body = await req.body.json();
|
||||
if (!body.pubkey) return invalidRequest;
|
||||
const ccn = await CCN.fromPublicKey(body.pubkey);
|
||||
if (!ccn) return invalidRequest;
|
||||
await ccn.setActive();
|
||||
restartCCN();
|
||||
return CorsResponse.json(ccn.toPublicJson());
|
||||
},
|
||||
},
|
||||
"/api/ccns/active/invite": {
|
||||
GET: async () => {
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) return invalidRequest;
|
||||
const invite = await ccn.generateInvite();
|
||||
return CorsResponse.json({
|
||||
invite,
|
||||
});
|
||||
},
|
||||
},
|
||||
"/api/ccns/new": {
|
||||
POST: async (req) => {
|
||||
if (!req.body) return invalidRequest;
|
||||
const body = await req.body.json();
|
||||
if (!body.name || !body.description) return invalidRequest;
|
||||
const ccn = await CCN.create(body.name, body.description);
|
||||
const activeCCN = await CCN.getActive();
|
||||
if (!activeCCN) {
|
||||
await ccn.setActive();
|
||||
restartCCN();
|
||||
}
|
||||
return CorsResponse.json(ccn.toPublicJson());
|
||||
},
|
||||
},
|
||||
"/api/ccns/join": {
|
||||
POST: async (req) => {
|
||||
if (!req.body) return invalidRequest;
|
||||
const body = await req.body.json();
|
||||
if (!body.name || !body.description || !body.key) return invalidRequest;
|
||||
const version = body.version ? body.version : 1;
|
||||
const startIndex = body.startIndex
|
||||
? RollingIndex.fromHex(body.startIndex)
|
||||
: RollingIndex.get();
|
||||
const ccn = await CCN.join(
|
||||
version,
|
||||
startIndex,
|
||||
body.name,
|
||||
body.description,
|
||||
new Uint8Array(body.key),
|
||||
);
|
||||
return CorsResponse.json(ccn.toPublicJson());
|
||||
},
|
||||
},
|
||||
"/api/ccns/:pubkey": async (req) => {
|
||||
const ccns = await CCN.list();
|
||||
const ccnWithPubkey = ccns.find((x) => x.publicKey === req.params.pubkey);
|
||||
if (!ccnWithPubkey)
|
||||
return CorsResponse.json({ error: "Not Found" }, { status: 404 });
|
||||
return CorsResponse.json(ccnWithPubkey.toPublicJson());
|
||||
},
|
||||
"/api/ccns/icon/:pubkey": async (req) => {
|
||||
const pubkey = req.params.pubkey;
|
||||
if (!pubkey) return invalidRequest;
|
||||
const ccn = await CCN.fromPublicKey(pubkey);
|
||||
if (!ccn) return invalidRequest;
|
||||
const avatar = ccn.getCommunityIcon();
|
||||
return new CorsResponse(avatar, {
|
||||
headers: { "Content-Type": "image/svg+xml" },
|
||||
});
|
||||
},
|
||||
"/api/ccns/name/:pubkey": async (req) => {
|
||||
const pubkey = req.params.pubkey;
|
||||
if (!pubkey) return invalidRequest;
|
||||
const ccn = await CCN.fromPublicKey(pubkey);
|
||||
if (!ccn) return invalidRequest;
|
||||
const profile = await ccn.getProfile();
|
||||
return new CorsResponse(profile.name || ccn.name);
|
||||
},
|
||||
"/api/ccns/avatar/:pubkey": async (req) => {
|
||||
const pubkey = req.params.pubkey;
|
||||
if (!pubkey) return invalidRequest;
|
||||
const ccn = await CCN.fromPublicKey(pubkey);
|
||||
if (!ccn) return invalidRequest;
|
||||
const profile = await ccn.getProfile();
|
||||
if (profile.picture) return CorsResponse.redirect(profile.picture);
|
||||
const avatar = ccn.getCommunityIcon();
|
||||
return new CorsResponse(avatar, {
|
||||
headers: { "Content-Type": "image/svg+xml" },
|
||||
});
|
||||
},
|
||||
"/api/profile/:pubkey": async (req) => {
|
||||
const pubkey = req.params.pubkey;
|
||||
if (!pubkey) return invalidRequest;
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) return invalidRequest;
|
||||
|
||||
const profileEvent = await ccn.getFirstEvent({
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
});
|
||||
try {
|
||||
if (!profileEvent) throw "No profile";
|
||||
return new CorsResponse(profileEvent.content, {
|
||||
headers: { "Content-Type": "text/json" },
|
||||
});
|
||||
} catch {
|
||||
return CorsResponse.json(
|
||||
{ error: "profile not found" },
|
||||
{ headers: { "Content-Type": "text/json" }, status: 404 },
|
||||
);
|
||||
}
|
||||
},
|
||||
"/api/avatars/:pubkey": async (req) => {
|
||||
const pubkey = req.params.pubkey;
|
||||
|
||||
if (!pubkey) return invalidRequest;
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) return invalidRequest;
|
||||
const profileEvent = await ccn.getFirstEvent({
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
});
|
||||
try {
|
||||
if (!profileEvent) throw "No profile";
|
||||
const content = JSON.parse(profileEvent.content);
|
||||
if (!content.picture) throw "No picture";
|
||||
return CorsResponse.redirect(content.picture);
|
||||
} catch {
|
||||
const avatar = createAvatar(adventurerNeutral, {
|
||||
seed: pubkey,
|
||||
backgroundColor: [getColorFromPubkey(pubkey)],
|
||||
});
|
||||
return new CorsResponse(avatar.toString(), {
|
||||
headers: { "Content-Type": "image/svg+xml" },
|
||||
});
|
||||
}
|
||||
},
|
||||
"/api/events": {
|
||||
POST: async (req) => {
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) return invalidRequest;
|
||||
if (!req.body) return invalidRequest;
|
||||
const { search, ...query } = await req.body.json();
|
||||
let events = await ccn.getEvents(query);
|
||||
if (search)
|
||||
events = events.filter(
|
||||
(e) =>
|
||||
e.content.includes(search) ||
|
||||
e.tags.some((t) => t[1]?.includes(search)),
|
||||
);
|
||||
return CorsResponse.json(events);
|
||||
},
|
||||
PUT: async (req) => {
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) return invalidRequest;
|
||||
if (!req.body) return invalidRequest;
|
||||
const event = await req.body.json();
|
||||
try {
|
||||
await ccn.publish(event);
|
||||
return CorsResponse.json({
|
||||
success: true,
|
||||
message: "Event published successfully",
|
||||
});
|
||||
} catch {
|
||||
return CorsResponse.json({
|
||||
success: false,
|
||||
message: "Failed to publish event",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
"/api/events/:id": async (req) => {
|
||||
const id = req.params.id;
|
||||
if (!id) return invalidRequest;
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) return invalidRequest;
|
||||
const event = await ccn.getFirstEvent({ ids: [id] });
|
||||
if (!event)
|
||||
return CorsResponse.json({ error: "Event Not Found" }, { status: 404 });
|
||||
return CorsResponse.json(event);
|
||||
},
|
||||
"/api/sign": {
|
||||
POST: async (req) => {
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) return invalidRequest;
|
||||
const userKey = await ccn.getUserKey();
|
||||
if (!req.body) return invalidRequest;
|
||||
const event = await req.body.json();
|
||||
const signedEvent = finalizeEvent(event, userKey);
|
||||
return CorsResponse.json(signedEvent);
|
||||
},
|
||||
},
|
||||
"/api/pubkey": async () => {
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) return invalidRequest;
|
||||
const userKey = await ccn.getUserKey();
|
||||
return CorsResponse.json({ pubkey: getPublicKey(userKey) });
|
||||
},
|
||||
"/api/arxlets": async () => {
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) return invalidRequest;
|
||||
const arxlets = await ccn.getArxlets();
|
||||
return CorsResponse.json(arxlets);
|
||||
},
|
||||
"/api/arxlets/:id": async (req) => {
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) return invalidRequest;
|
||||
const arxlet = await ccn.getArxletById(req.params.id);
|
||||
if (!arxlet)
|
||||
return CorsResponse.json(
|
||||
{ error: "Arxlet not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
return CorsResponse.json(arxlet);
|
||||
},
|
||||
"/api/arxlets-available": async () => {
|
||||
const remoteArxlets = await fetchRemoteArxlets();
|
||||
return CorsResponse.json(remoteArxlets);
|
||||
},
|
||||
"/api/clone-remote-event/:id": async (req) => {
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) return invalidRequest;
|
||||
const remoteEvent = await queryRemoteEvent(req.params.id);
|
||||
if (!remoteEvent)
|
||||
return CorsResponse.json({ error: "Event not found" }, { status: 404 });
|
||||
await ccn.publish(remoteEvent);
|
||||
return CorsResponse.json(remoteEvent);
|
||||
},
|
||||
"/api/reputation/:user": async (req) => {
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) return invalidRequest;
|
||||
const reputation = await ccn.getReputation(req.params.user);
|
||||
return CorsResponse.json({ reputation });
|
||||
},
|
||||
"/systemapi/timezone": {
|
||||
GET: async () => {
|
||||
const timezone = (
|
||||
await $`timedatectl show --property=Timezone --value`.text()
|
||||
).trim();
|
||||
return CorsResponse.json({ timezone });
|
||||
},
|
||||
POST: async (req) => {
|
||||
if (!req.body) return invalidRequest;
|
||||
const { timezone } = await req.body.json();
|
||||
await $`sudo timedatectl set-timezone ${timezone}`; // this is fine, bun escapes it
|
||||
return CorsResponse.json({ timezone });
|
||||
},
|
||||
},
|
||||
"/systemapi/wifi": {
|
||||
GET: async () => {
|
||||
const nmcliLines = (
|
||||
await $`nmcli -f ssid,bssid,mode,freq,chan,rate,signal,security,active -t dev wifi`.text()
|
||||
)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => l.split(/(?<!\\):/));
|
||||
|
||||
const wifi = nmcliLines
|
||||
.map(
|
||||
([
|
||||
ssid,
|
||||
bssid,
|
||||
mode,
|
||||
freq,
|
||||
chan,
|
||||
rate,
|
||||
signal,
|
||||
security,
|
||||
active,
|
||||
]) => ({
|
||||
ssid,
|
||||
bssid: bssid!.replace(/\\:/g, ":"),
|
||||
mode,
|
||||
freq: parseInt(freq!, 10),
|
||||
chan: parseInt(chan!, 10),
|
||||
rate,
|
||||
signal: parseInt(signal!, 10),
|
||||
security,
|
||||
active: active === "yes",
|
||||
}),
|
||||
)
|
||||
.filter((network) => network.ssid !== "") // filter out hidden networks
|
||||
.filter((network) => network.security !== "") // filter out open networks
|
||||
.sort((a, b) => (a.active ? -1 : b.active ? 1 : b.signal - a.signal));
|
||||
|
||||
return CorsResponse.json(wifi);
|
||||
},
|
||||
POST: async (req) => {
|
||||
if (!req.body) return invalidRequest;
|
||||
const { ssid, password } = await req.body.json();
|
||||
await $`nmcli device wifi connect ${ssid} password ${password}`; // this is fine, bun escapes it
|
||||
return CorsResponse.json({ ssid });
|
||||
},
|
||||
},
|
||||
},
|
||||
fetch() {
|
||||
return new CorsResponse("Eve Lite v0.0.1");
|
||||
},
|
||||
port: 4269,
|
||||
});
|
||||
|
||||
console.log(`Listening on ${httpServer.url.host}`);
|
30
package.json
Normal file
30
package.json
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "eve-lite",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"configure-hooks": "git config core.hooksPath .githooks",
|
||||
"test": "bun test",
|
||||
"test:watch": "bun test --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.2.4",
|
||||
"@types/bun": "^1.2.23",
|
||||
"preact": "^10.27.2",
|
||||
"prism-svelte": "^0.5.0",
|
||||
"prismjs": "^1.30.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dicebear/collection": "^9.2.4",
|
||||
"@dicebear/core": "^9.2.4",
|
||||
"@noble/ciphers": "^1.3.0",
|
||||
"@scure/base": "^2.0.0",
|
||||
"bun-plugin-tailwind": "^0.0.15",
|
||||
"daisyui": "^5.1.25",
|
||||
"nostr-tools": "^2.17.0"
|
||||
}
|
||||
}
|
59
src/arxlets.ts
Normal file
59
src/arxlets.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { brotliDecompressSync } from "node:zlib";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import { hexToBytes } from "nostr-tools/utils";
|
||||
import { CCN } from "./ccns";
|
||||
import { queryRemoteRelaysSync } from "./utils/general";
|
||||
|
||||
export interface Arxlet {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
script: string;
|
||||
version?: string;
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
export const parseArxletFromEvent = (ccn: CCN) => (event: NostrEvent) => {
|
||||
if (
|
||||
[
|
||||
"d45d6ee247d6a323b2e081fdd5dd91c015b34284f3fef857afb28ce508844137", // old version of howl used during dev, now gets signed with different key
|
||||
].includes(event.id)
|
||||
)
|
||||
return; // filter out known bad arxlets
|
||||
if (event.pubkey === ccn.publicKey) return;
|
||||
if (event.kind !== 30420) return;
|
||||
if (!event.tags.some((t) => t[0] === "d")) return;
|
||||
if (!event.tags.some((t) => t[0] === "name")) return;
|
||||
if (!event.tags.some((t) => t[0] === "script")) return;
|
||||
if (event.tags.some((t) => t[0] === "disabled" && t[1] === "true")) return;
|
||||
return {
|
||||
id: event.tags.find((t) => t[0] === "d")![1]!,
|
||||
author: event.pubkey,
|
||||
name: event.tags.find((t) => t[0] === "name")![1]!,
|
||||
description: event.tags.find((t) => t[0] === "description")?.[1],
|
||||
script: brotliDecompressSync(
|
||||
hexToBytes(event.tags.find((t) => t[0] === "script")![1]!),
|
||||
).toString(),
|
||||
icon: event.tags.find((t) => t[0] === "icon")?.[1],
|
||||
iconColor: event.tags.find((t) => t[0] === "icon")?.[2],
|
||||
iconForegroundColor: event.tags.find((t) => t[0] === "icon")?.[3],
|
||||
iconStrokeColor: event.tags.find((t) => t[0] === "icon")?.[4],
|
||||
version: event.tags.find((t) => t[0] === "version")?.[1],
|
||||
versionDate: event.created_at,
|
||||
eventId: event.id,
|
||||
};
|
||||
};
|
||||
|
||||
export async function fetchRemoteArxlets() {
|
||||
const activeCCN = await CCN.getActive();
|
||||
if (!activeCCN) throw new Error("No active CCN found");
|
||||
const events = await queryRemoteRelaysSync({
|
||||
kinds: [30420],
|
||||
limit: 10_000,
|
||||
});
|
||||
return events
|
||||
.map((x) => parseArxletFromEvent(activeCCN)(x))
|
||||
.filter((x) => x !== undefined);
|
||||
}
|
408
src/ccns.ts
Normal file
408
src/ccns.ts
Normal file
|
@ -0,0 +1,408 @@
|
|||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { avataaars } from "@dicebear/collection";
|
||||
import { createAvatar } from "@dicebear/core";
|
||||
import { bech32m } from "@scure/base";
|
||||
import {
|
||||
type Filter,
|
||||
generateSecretKey,
|
||||
getPublicKey,
|
||||
type NostrEvent,
|
||||
nip19,
|
||||
SimplePool,
|
||||
} from "nostr-tools";
|
||||
import { bytesToHex } from "nostr-tools/utils";
|
||||
import { type Arxlet, parseArxletFromEvent } from "./arxlets";
|
||||
import { getColorFromPubkey } from "./utils/color";
|
||||
import { getDataDir } from "./utils/files";
|
||||
import { getSvgGroup, pool, splitIntoParts } from "./utils/general";
|
||||
import { write_string, write_varint } from "./utils/Uint8Array";
|
||||
import { ReputationManager } from "./ccns/reputation";
|
||||
import { RollingIndex } from "./rollingIndex";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const LATEST_CCN_VERSION = 2;
|
||||
|
||||
interface ConfigValue {
|
||||
[key: string]: string | number | boolean | ConfigValue;
|
||||
}
|
||||
|
||||
function stringifyConfig(obj: ConfigValue, indent: number = 0): string {
|
||||
let result = "";
|
||||
const spaces = " ".repeat(indent);
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === "object") {
|
||||
result += `${spaces}${key} {\n`;
|
||||
result += stringifyConfig(value, indent + 1);
|
||||
result += `${spaces}}\n`;
|
||||
} else
|
||||
result += `${spaces}${key} = ${typeof value === "string" ? `"${value}"` : value}\n`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class CCN {
|
||||
private reputationManager: ReputationManager;
|
||||
|
||||
constructor(
|
||||
public version: number,
|
||||
public startIndex: Uint8Array,
|
||||
public name: string,
|
||||
public description: string,
|
||||
public key: Uint8Array,
|
||||
) {
|
||||
this.reputationManager = new ReputationManager(this.getEvents.bind(this));
|
||||
}
|
||||
|
||||
static fromJsonString(json: string) {
|
||||
const data = JSON.parse(json);
|
||||
return new CCN(
|
||||
data.version || 1,
|
||||
data.startIndex
|
||||
? RollingIndex.fromHex(data.startIndex)
|
||||
: RollingIndex.get(),
|
||||
data.name,
|
||||
data.description,
|
||||
new Uint8Array(data.privateKey),
|
||||
);
|
||||
}
|
||||
|
||||
static async getActive() {
|
||||
const activeCCNPubKey = await Bun.secrets.get({
|
||||
name: "active-ccn",
|
||||
service: "eve-lite",
|
||||
});
|
||||
|
||||
if (activeCCNPubKey) {
|
||||
const ccn = await CCN.fromPublicKey(activeCCNPubKey);
|
||||
if (ccn) return ccn;
|
||||
}
|
||||
}
|
||||
|
||||
static async list(): Promise<CCN[]> {
|
||||
const indexKey = { service: "eve-lite", name: "ccn-index" };
|
||||
const indexString = await Bun.secrets.get(indexKey);
|
||||
if (!indexString) return [];
|
||||
|
||||
const index: string[] = JSON.parse(indexString);
|
||||
const ccns = await Promise.all(
|
||||
index.map(async (pubkey: string) => {
|
||||
const ccnString = await Bun.secrets.get({
|
||||
service: "eve-lite/ccn",
|
||||
name: pubkey,
|
||||
});
|
||||
if (!ccnString) return null;
|
||||
return CCN.fromJsonString(ccnString);
|
||||
}),
|
||||
);
|
||||
return ccns.filter((ccn): ccn is CCN => ccn !== null);
|
||||
}
|
||||
|
||||
static async fromPublicKey(publicKey: string) {
|
||||
const ccnString = await Bun.secrets.get({
|
||||
service: "eve-lite/ccn",
|
||||
name: publicKey,
|
||||
});
|
||||
if (!ccnString) return null;
|
||||
return CCN.fromJsonString(ccnString);
|
||||
}
|
||||
|
||||
getCommunityIcon() {
|
||||
const positions = [
|
||||
[30, 0],
|
||||
[-60, 70],
|
||||
[120, 70],
|
||||
];
|
||||
const avatars = splitIntoParts(this.publicKey, 3)
|
||||
.map((part) => createAvatar(avataaars, { seed: part }).toString())
|
||||
.map((avatar, index) =>
|
||||
getSvgGroup(
|
||||
avatar,
|
||||
`translate(${positions[index]![0]}, ${positions[index]![1]}) scale(0.8)`,
|
||||
),
|
||||
)
|
||||
.join("");
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 280" fill="none" shape-rendering="auto">
|
||||
<rect width="280" height="280" fill="#${getColorFromPubkey(this.publicKey)}"/>
|
||||
${avatars}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
getPrivateKeyAt(index: Uint8Array) {
|
||||
if (this.version === 1) return this.key;
|
||||
const hmac = crypto.createHmac("sha256", this.key);
|
||||
hmac.update(index);
|
||||
return hmac.digest();
|
||||
}
|
||||
|
||||
getPublicKeyAt(index: Uint8Array) {
|
||||
if (this.version === 1) return this.publicKey;
|
||||
return getPublicKey(this.getPrivateKeyAt(index));
|
||||
}
|
||||
|
||||
get publicKey() {
|
||||
return getPublicKey(this.key);
|
||||
}
|
||||
|
||||
get currentPrivateKey() {
|
||||
if (this.version === 1) return this.key;
|
||||
return this.getPrivateKeyAt(RollingIndex.get());
|
||||
}
|
||||
|
||||
get currentPublicKey() {
|
||||
if (this.version === 1) return this.publicKey;
|
||||
return getPublicKey(this.currentPrivateKey);
|
||||
}
|
||||
|
||||
get allPubkeys() {
|
||||
if (this.version === 1) return [this.publicKey];
|
||||
const allPeriods = RollingIndex.diff(RollingIndex.get(), this.startIndex);
|
||||
return allPeriods.map((index) => this.getPublicKeyAt(index));
|
||||
}
|
||||
|
||||
private get json() {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
privateKey: [...this.key],
|
||||
version: this.version,
|
||||
startIndex: RollingIndex.toHex(this.startIndex),
|
||||
};
|
||||
}
|
||||
|
||||
toPublicJson() {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
publicKey: this.publicKey,
|
||||
icon: this.icon,
|
||||
version: this.version,
|
||||
startIndex: this.startIndex,
|
||||
};
|
||||
}
|
||||
|
||||
async setActive() {
|
||||
await Bun.secrets.set({
|
||||
name: "active-ccn",
|
||||
service: "eve-lite",
|
||||
value: this.publicKey,
|
||||
});
|
||||
}
|
||||
|
||||
static async create(name: string, description: string) {
|
||||
return CCN.join(
|
||||
LATEST_CCN_VERSION,
|
||||
RollingIndex.get(),
|
||||
name,
|
||||
description,
|
||||
generateSecretKey(),
|
||||
);
|
||||
}
|
||||
|
||||
static async join(
|
||||
ccn_version: number,
|
||||
startIndex: Uint8Array,
|
||||
name: string,
|
||||
description: string,
|
||||
privateKey: Uint8Array,
|
||||
) {
|
||||
const ccn = new CCN(ccn_version, startIndex, name, description, privateKey);
|
||||
const indexKey = { service: "eve-lite", name: "ccn-index" };
|
||||
const indexString = await Bun.secrets.get(indexKey);
|
||||
const index: string[] = indexString ? JSON.parse(indexString) : [];
|
||||
|
||||
if (!index.includes(ccn.publicKey)) {
|
||||
await Bun.secrets.set({
|
||||
service: "eve-lite/ccn",
|
||||
name: ccn.publicKey,
|
||||
value: JSON.stringify(ccn.json),
|
||||
});
|
||||
index.push(ccn.publicKey);
|
||||
await Bun.secrets.set({ ...indexKey, value: JSON.stringify(index) });
|
||||
}
|
||||
|
||||
return ccn;
|
||||
}
|
||||
|
||||
get strfryConfigPath() {
|
||||
return join(getDataDir(), "strfry", `${this.publicKey}.conf`);
|
||||
}
|
||||
|
||||
async writeStrfryConfig() {
|
||||
const dbDir = join(getDataDir(), "dbs", this.publicKey);
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
const config = stringifyConfig({
|
||||
db: dbDir,
|
||||
dbParams: {
|
||||
maxreaders: 256,
|
||||
mapsize: 10995116277760,
|
||||
noReadAhead: false,
|
||||
},
|
||||
events: {
|
||||
maxEventSize: 1048576,
|
||||
rejectEventsNewerThanSeconds: 900,
|
||||
rejectEventsOlderThanSeconds: 252460800,
|
||||
rejectEphemeralEventsOlderThanSeconds: 86400,
|
||||
ephemeralEventsLifetimeSeconds: 172800,
|
||||
maxNumTags: 2000,
|
||||
maxTagValSize: 10240,
|
||||
},
|
||||
relay: {
|
||||
bind: "127.0.0.1",
|
||||
port: 6942,
|
||||
nofiles: 0,
|
||||
realIpHeader: "",
|
||||
info: {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
pubkey: this.publicKey,
|
||||
contact: "",
|
||||
icon: "",
|
||||
nips: "",
|
||||
},
|
||||
maxWebsocketPayloadSize: 5 * 1024 * 1024,
|
||||
maxReqFilterSize: 200,
|
||||
autoPingSeconds: 55,
|
||||
enableTcpKeepalive: false,
|
||||
queryTimesliceBudgetMicroseconds: 10000,
|
||||
maxFilterLimit: 100_000,
|
||||
maxSubsPerConnection: 100,
|
||||
writePolicy: {
|
||||
plugin: "/usr/bin/eve-lite-event-plugin",
|
||||
},
|
||||
compression: {
|
||||
enabled: true,
|
||||
slidingWindow: true,
|
||||
},
|
||||
logging: {
|
||||
dumpInAll: false,
|
||||
dumpInEvents: true,
|
||||
dumpInReqs: true,
|
||||
dbScanPerf: true,
|
||||
invalidEvents: true,
|
||||
},
|
||||
numThreads: {
|
||||
ingester: 3,
|
||||
reqWorker: 3,
|
||||
reqMonitor: 3,
|
||||
negentropy: 2,
|
||||
},
|
||||
negentropy: {
|
||||
enabled: false,
|
||||
maxSyncEvents: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const configFile = Bun.file(this.strfryConfigPath);
|
||||
await configFile.write(config);
|
||||
}
|
||||
|
||||
getEvents(filter: Filter) {
|
||||
const pool = new SimplePool();
|
||||
return pool.querySync(["ws://localhost:6942"], filter);
|
||||
}
|
||||
|
||||
getFirstEvent(filter: Filter) {
|
||||
const pool = new SimplePool();
|
||||
return pool.get(["ws://localhost:6942"], filter);
|
||||
}
|
||||
|
||||
async getArxlets(): Promise<Arxlet[]> {
|
||||
const events = await this.getEvents({
|
||||
kinds: [30420],
|
||||
});
|
||||
return events
|
||||
.map((event) => parseArxletFromEvent(this)(event))
|
||||
.filter((arxlet) => arxlet !== undefined);
|
||||
}
|
||||
|
||||
async getArxletById(id: string): Promise<Arxlet | undefined> {
|
||||
const query = {
|
||||
kinds: [30420],
|
||||
"#d": [id],
|
||||
};
|
||||
if (id.includes(":")) {
|
||||
const [dTag, author] = id.split(":");
|
||||
query["#d"] = [dTag];
|
||||
query["authors"] = [author];
|
||||
}
|
||||
const event = await this.getFirstEvent(query);
|
||||
if (!event) return undefined;
|
||||
return parseArxletFromEvent(this)(event);
|
||||
}
|
||||
|
||||
async getUserKey() {
|
||||
const service = "eve-lite-user-key";
|
||||
const name = this.publicKey;
|
||||
const nsec = await Bun.secrets.get({ service, name });
|
||||
if (nsec) {
|
||||
const decoded = nip19.decode(nsec);
|
||||
if (decoded.type !== "nsec") throw "Invalid key";
|
||||
return decoded.data;
|
||||
}
|
||||
|
||||
const key = generateSecretKey();
|
||||
const newNsec = nip19.nsecEncode(key);
|
||||
await Bun.secrets.set({ service, name, value: newNsec });
|
||||
return key;
|
||||
}
|
||||
|
||||
publish(event: NostrEvent): Promise<string[]> {
|
||||
return Promise.all(pool.publish(["ws://localhost:6942"], event));
|
||||
}
|
||||
|
||||
async loadSeenEvents() {
|
||||
const service = "eve-lite-seen";
|
||||
const name = this.publicKey;
|
||||
const seen = await Bun.secrets.get({
|
||||
service,
|
||||
name,
|
||||
});
|
||||
if (!seen) return [];
|
||||
return JSON.parse(seen) as string[];
|
||||
}
|
||||
|
||||
async saveSeenEvent(event: NostrEvent) {
|
||||
const service = "eve-lite-seen";
|
||||
const name = this.publicKey;
|
||||
const seenEvents = await this.loadSeenEvents();
|
||||
seenEvents.push(event.id);
|
||||
await Bun.secrets.set({
|
||||
service,
|
||||
name,
|
||||
value: JSON.stringify(seenEvents),
|
||||
});
|
||||
}
|
||||
|
||||
async getProfile(): Promise<{ name?: string; picture?: string }> {
|
||||
const profileEvent = await this.getFirstEvent({
|
||||
kinds: [0],
|
||||
authors: [this.publicKey],
|
||||
});
|
||||
if (!profileEvent) return {};
|
||||
return JSON.parse(profileEvent.content);
|
||||
}
|
||||
|
||||
async generateInvite() {
|
||||
const INVITE_VERSION = 2;
|
||||
const data: number[] = [];
|
||||
const userKey = await this.getUserKey();
|
||||
const userPubkey = getPublicKey(userKey);
|
||||
const ccnKeyMaterial = bytesToHex(this.key);
|
||||
write_varint(data, INVITE_VERSION);
|
||||
write_string(data, this.name);
|
||||
write_string(data, this.description);
|
||||
write_string(data, userPubkey);
|
||||
write_string(data, ccnKeyMaterial);
|
||||
write_string(data, RollingIndex.toHex(this.startIndex));
|
||||
|
||||
const combinedBytes = bech32m.toWords(new Uint8Array(data));
|
||||
return bech32m.encode("evelite", combinedBytes, false);
|
||||
}
|
||||
|
||||
public async getReputation(userId: string): Promise<number> {
|
||||
return await this.reputationManager.getReputation(userId);
|
||||
}
|
||||
}
|
150
src/ccns/reputation.ts
Normal file
150
src/ccns/reputation.ts
Normal file
|
@ -0,0 +1,150 @@
|
|||
import { type Filter, type NostrEvent } from "nostr-tools";
|
||||
|
||||
interface ReputationInfo {
|
||||
reputation: number;
|
||||
voteCount: number;
|
||||
lastEventTimestamp: number;
|
||||
processedVotes: Set<string>;
|
||||
}
|
||||
|
||||
export class ReputationManager {
|
||||
private reputationCache = new Map<string, ReputationInfo>();
|
||||
private reputationCalculationInProgress = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(private getEvents: (filter: Filter) => Promise<NostrEvent[]>) {}
|
||||
|
||||
private _updateReputation(
|
||||
targetReputation: number,
|
||||
targetVoteCount: number,
|
||||
voteScore: number, // 1 for up, 0 for down
|
||||
voterReputation: number,
|
||||
scaleFactor = 400,
|
||||
): number {
|
||||
const ratingDiff = voterReputation - targetReputation;
|
||||
|
||||
const exponent = ratingDiff / scaleFactor;
|
||||
|
||||
const powerTerm = 10 ** exponent;
|
||||
const denom = 1 + powerTerm;
|
||||
const expectedScore = 1.0 / denom;
|
||||
|
||||
const sqrtVotes = Math.sqrt(targetVoteCount);
|
||||
const denomAdjust = 1 + sqrtVotes;
|
||||
const adjustmentFactor = 32.0 / denomAdjust;
|
||||
|
||||
const scoreDiff = voteScore - expectedScore;
|
||||
return targetReputation + adjustmentFactor * scoreDiff;
|
||||
}
|
||||
|
||||
private async runReputationBatch(initialUser: string) {
|
||||
const usersToFetch = new Set([initialUser]);
|
||||
const allInvolved = new Set([initialUser]);
|
||||
const allNewEvents: NostrEvent[] = [];
|
||||
const processedEventIds = new Set<string>();
|
||||
|
||||
// Phase 1: Discover users (BFS) and fetch new events.
|
||||
while (usersToFetch.size > 0) {
|
||||
const batch = Array.from(usersToFetch);
|
||||
usersToFetch.clear();
|
||||
|
||||
// For any other users discovered in this batch, hook them to the main promise.
|
||||
for (const u of batch) {
|
||||
if (!this.reputationCalculationInProgress.has(u))
|
||||
this.reputationCalculationInProgress.set(
|
||||
u,
|
||||
this.reputationCalculationInProgress.get(initialUser)!,
|
||||
);
|
||||
}
|
||||
|
||||
const eventPromises = batch.map((u) => {
|
||||
const since = this.reputationCache.get(u)?.lastEventTimestamp;
|
||||
return this.getEvents({
|
||||
kinds: [7],
|
||||
"#p": [u],
|
||||
since: since ? since + 1 : 0,
|
||||
});
|
||||
});
|
||||
|
||||
const eventSets = await Promise.all(eventPromises);
|
||||
const newEvents = eventSets.flat();
|
||||
|
||||
for (const event of newEvents) {
|
||||
if (processedEventIds.has(event.id)) continue;
|
||||
processedEventIds.add(event.id);
|
||||
allNewEvents.push(event);
|
||||
|
||||
const voter = event.pubkey;
|
||||
if (!allInvolved.has(voter)) {
|
||||
allInvolved.add(voter);
|
||||
usersToFetch.add(voter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Initialize cache for new users.
|
||||
for (const u of allInvolved) {
|
||||
if (!this.reputationCache.has(u))
|
||||
this.reputationCache.set(u, {
|
||||
reputation: 500,
|
||||
voteCount: 0,
|
||||
lastEventTimestamp: 0,
|
||||
processedVotes: new Set<string>(),
|
||||
});
|
||||
}
|
||||
|
||||
allNewEvents.sort((a, b) => a.created_at - b.created_at);
|
||||
|
||||
for (const event of allNewEvents) {
|
||||
const targetUserTag = event.tags.find((t) => t[0] === "p");
|
||||
if (!targetUserTag?.[1]) continue;
|
||||
const targetUser = targetUserTag[1];
|
||||
|
||||
if (!allInvolved.has(targetUser)) continue;
|
||||
|
||||
const targetUserInfo = this.reputationCache.get(targetUser)!;
|
||||
const voter = event.pubkey;
|
||||
|
||||
if (voter === targetUser) continue;
|
||||
|
||||
const eTag = event.tags.find((t) => t[0] === "e");
|
||||
if (!eTag?.[1]) continue;
|
||||
|
||||
const voteId = `${voter}:${eTag[1]}`;
|
||||
if (targetUserInfo.processedVotes.has(voteId)) continue;
|
||||
|
||||
const voteContent = event.content;
|
||||
if (voteContent !== "+" && voteContent !== "-") continue;
|
||||
|
||||
targetUserInfo.processedVotes.add(voteId);
|
||||
const voteScore = voteContent === "+" ? 1 : 0;
|
||||
targetUserInfo.voteCount++;
|
||||
|
||||
const voterReputation = this.reputationCache.get(voter)!.reputation;
|
||||
|
||||
targetUserInfo.reputation = this._updateReputation(
|
||||
targetUserInfo.reputation,
|
||||
targetUserInfo.voteCount,
|
||||
voteScore,
|
||||
voterReputation,
|
||||
);
|
||||
|
||||
targetUserInfo.lastEventTimestamp = event.created_at;
|
||||
}
|
||||
|
||||
for (const u of allInvolved) {
|
||||
this.reputationCalculationInProgress.delete(u);
|
||||
}
|
||||
}
|
||||
|
||||
async getReputation(user: string): Promise<number> {
|
||||
if (this.reputationCalculationInProgress.has(user))
|
||||
await this.reputationCalculationInProgress.get(user);
|
||||
else {
|
||||
const batchPromise = this.runReputationBatch(user);
|
||||
this.reputationCalculationInProgress.set(user, batchPromise);
|
||||
await batchPromise;
|
||||
}
|
||||
|
||||
return this.reputationCache.get(user)?.reputation ?? 500;
|
||||
}
|
||||
}
|
4
src/consts.ts
Normal file
4
src/consts.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export const EVENT_PLUGIN_VERSION = "0.0.1";
|
||||
export const POW_TO_ACCEPT = 10;
|
||||
export const POW_TO_MINE = 12;
|
||||
export const CURRENT_VERSION = 0x01;
|
45
src/eventPlugin.ts
Normal file
45
src/eventPlugin.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { CCN } from "./ccns";
|
||||
import { EVENT_PLUGIN_VERSION } from "./consts";
|
||||
import { RollingIndex } from "./rollingIndex";
|
||||
import { createEncryptedEvent } from "./utils/encryption";
|
||||
import { loadSeenEvents, saveSeenEvent } from "./utils/files";
|
||||
import { sendEncryptedEventToRelays } from "./utils/general";
|
||||
|
||||
if (process.argv[process.argv.length - 1] === "--version") {
|
||||
console.log(EVENT_PLUGIN_VERSION);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for await (const line of console) {
|
||||
const req = JSON.parse(line);
|
||||
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) {
|
||||
console.error("No CCN");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (req.type !== "new") {
|
||||
console.error("unexpected request type");
|
||||
continue;
|
||||
}
|
||||
|
||||
const seenEvents = await loadSeenEvents();
|
||||
if (!seenEvents.includes(req.event.id)) {
|
||||
let index = RollingIndex.at(req.event.created_at * 1000);
|
||||
if (RollingIndex.compare(index, ccn.startIndex) < 0) index = ccn.startIndex;
|
||||
|
||||
const keyForEvent = ccn.getPrivateKeyAt(index);
|
||||
const encryptedEvent = await createEncryptedEvent(req.event, keyForEvent);
|
||||
await saveSeenEvent(req.event);
|
||||
await saveSeenEvent(encryptedEvent);
|
||||
await sendEncryptedEventToRelays(encryptedEvent);
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
id: req.event.id,
|
||||
action: "accept",
|
||||
}),
|
||||
);
|
||||
}
|
1825
src/pages/docs/arxlets/arxlet-docs-out.html
Normal file
1825
src/pages/docs/arxlets/arxlet-docs-out.html
Normal file
File diff suppressed because it is too large
Load diff
979
src/pages/docs/arxlets/arxlet-docs.adoc
Normal file
979
src/pages/docs/arxlets/arxlet-docs.adoc
Normal file
|
@ -0,0 +1,979 @@
|
|||
= Arxlets API Context
|
||||
:description: Arxlets are secure, sandboxed JavaScript applications that extend Eve's functionality.
|
||||
:doctype: book
|
||||
:icons: font
|
||||
:source-highlighter: highlight.js
|
||||
:toc: left
|
||||
:toclevels: 2
|
||||
:sectlinks:
|
||||
|
||||
== Installing EveOS
|
||||
|
||||
sudo coreos-installer install /dev/sda --ignition-url https://arx-ccn.com/eveos.ign
|
||||
|
||||
// Overview Section
|
||||
== Overview
|
||||
|
||||
Arxlets are secure, sandboxed JavaScript applications that extend Eve's functionality. They run in isolated iframes and are registered on your CCN (Closed Community Network) for member-only access. Arxlets provide a powerful way to build custom applications that interact with Nostr events and profiles through Eve.
|
||||
|
||||
=== Core Concepts
|
||||
|
||||
What are Arxlets?
|
||||
- **Sandboxed Applications**: Run in isolated iframes for security
|
||||
- **JavaScript-based**: Written in TypeScript/JavaScript with wasm support coming in the future
|
||||
- **CCN Integration**: Registered on your Closed Community Network
|
||||
- **Nostr-native**: Built-in access to Nostr protocol operations
|
||||
- **Real-time**: Support for live event subscriptions and updates
|
||||
|
||||
NOTE: WASM support will be added in future releases for even more powerful applications.
|
||||
|
||||
=== CCN Local-First Architecture
|
||||
|
||||
CCNs (Closed Community Networks) are designed with a local-first approach that ensures data availability and functionality even when offline:
|
||||
|
||||
* **Local Data Storage**: All Nostr events and profiles are stored locally on your device, providing instant access without network dependencies
|
||||
* **Offline Functionality**: Arxlets can read, display, and interact with locally cached data when disconnected from the internet
|
||||
* **Sync When Connected**: When network connectivity is restored, the CCN automatically synchronizes with remote relays to fetch new events and propagate local changes
|
||||
* **Resilient Operation**: Your applications continue to work seamlessly regardless of network conditions, making CCNs ideal for unreliable connectivity scenarios
|
||||
* **Privacy by Design**: Local-first storage means your data remains on your device, reducing exposure to external services and improving privacy
|
||||
|
||||
=== Architecture
|
||||
|
||||
- **Frontend**: TypeScript applications with render functions
|
||||
- **Backend**: Eve relay providing Nostr protocol access
|
||||
- **Communication**: window.eve API or direct WebSocket connections
|
||||
|
||||
// API Reference Section
|
||||
== API Reference
|
||||
|
||||
The primary interface for Arxlets to interact with Eve's Nostr relay. All methods return promises for async operations.
|
||||
|
||||
=== window.eve API
|
||||
|
||||
[source,typescript]
|
||||
----
|
||||
// Using window.eve API for Nostr operations
|
||||
import type { Filter, NostrEvent } from "./types";
|
||||
|
||||
// Publish a new event
|
||||
const event: NostrEvent = {
|
||||
kind: 1,
|
||||
content: "Hello from my Arxlet!",
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: "your-pubkey-here",
|
||||
};
|
||||
|
||||
await window.eve.publish(event);
|
||||
|
||||
// Get a specific event by ID
|
||||
const eventId = "event-id-here";
|
||||
const event = await window.eve.getSingleEventById(eventId);
|
||||
|
||||
// Query events with a filter
|
||||
const filter: Filter = {
|
||||
kinds: [1],
|
||||
authors: ["pubkey-here"],
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
const singleEvent = await window.eve.getSingleEventWithFilter(filter);
|
||||
const allEvents = await window.eve.getAllEventsWithFilter(filter);
|
||||
|
||||
// Real-time subscription with RxJS Observable
|
||||
const subscription = window.eve.subscribeToEvents(filter).subscribe({
|
||||
next: (event) => {
|
||||
console.log("New event received:", event);
|
||||
// Update your UI with the new event
|
||||
},
|
||||
error: (err) => console.error("Subscription error:", err),
|
||||
complete: () => console.log("Subscription completed"),
|
||||
});
|
||||
|
||||
// Subscribe to profile updates for a specific user
|
||||
const profileSubscription = window.eve.subscribeToProfile(pubkey).subscribe({
|
||||
next: (profile) => {
|
||||
console.log("Profile updated:", profile);
|
||||
// Update your UI with the new profile data
|
||||
},
|
||||
error: (err) => console.error("Profile subscription error:", err),
|
||||
});
|
||||
|
||||
// Don't forget to unsubscribe when done
|
||||
// subscription.unsubscribe();
|
||||
// profileSubscription.unsubscribe();
|
||||
|
||||
// Get user profile and avatar
|
||||
const pubkey = "user-pubkey-here";
|
||||
const profile = await window.eve.getProfile(pubkey);
|
||||
const avatarUrl = await window.eve.getAvatar(pubkey);
|
||||
----
|
||||
|
||||
=== Real-time Subscriptions
|
||||
|
||||
[source,typescript]
|
||||
----
|
||||
// Real-time subscription examples
|
||||
import { filter, map, takeUntil } from "rxjs/operators";
|
||||
|
||||
// Basic subscription
|
||||
const subscription = window.eve
|
||||
.subscribeToEvents({
|
||||
kinds: [1], // Text notes
|
||||
limit: 50,
|
||||
})
|
||||
.subscribe((event) => {
|
||||
console.log("New text note:", event.content);
|
||||
});
|
||||
|
||||
// Advanced filtering with RxJS operators
|
||||
const filteredSubscription = window.eve
|
||||
.subscribeToEvents({
|
||||
kinds: [1, 6, 7], // Notes, reposts, reactions
|
||||
authors: ["pubkey1", "pubkey2"],
|
||||
})
|
||||
.pipe(
|
||||
filter((event) => event.content.includes("#arxlet")), // Only events mentioning arxlets
|
||||
map((event) => ({
|
||||
id: event.id,
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: new Date(event.created_at * 1000),
|
||||
})),
|
||||
)
|
||||
.subscribe({
|
||||
next: (processedEvent) => {
|
||||
// Update your UI
|
||||
updateEventsList(processedEvent);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error("Subscription error:", err);
|
||||
showErrorMessage("Failed to receive real-time updates");
|
||||
},
|
||||
});
|
||||
|
||||
// Profile subscription example
|
||||
const profileSubscription = window.eve.subscribeToProfile("user-pubkey-here").subscribe({
|
||||
next: (profile) => {
|
||||
console.log("Profile updated:", profile);
|
||||
updateUserProfile(profile);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error("Profile subscription error:", err);
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up subscriptions when component unmounts
|
||||
// subscription.unsubscribe();
|
||||
// filteredSubscription.unsubscribe();
|
||||
// profileSubscription.unsubscribe();
|
||||
----
|
||||
|
||||
=== WebSocket Alternative
|
||||
|
||||
For advanced use cases, connect directly to Eve's WebSocket relay, or use any nostr library. This is not recommended:
|
||||
|
||||
[source,typescript]
|
||||
----
|
||||
// Alternative: Direct WebSocket connection
|
||||
const ws = new WebSocket("ws://localhost:6942");
|
||||
|
||||
ws.onopen = () => {
|
||||
// Subscribe to events
|
||||
ws.send(JSON.stringify(["REQ", "sub1", { kinds: [1], limit: 10 }]));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const [type, subId, data] = JSON.parse(event.data);
|
||||
if (type === "EVENT") {
|
||||
console.log("Received event:", data);
|
||||
}
|
||||
};
|
||||
|
||||
// Publish an event
|
||||
const signedEvent = await window.nostr.signEvent(unsignedEvent);
|
||||
ws.send(JSON.stringify(["EVENT", signedEvent]));
|
||||
----
|
||||
|
||||
// Type Definitions Section
|
||||
== Type Definitions
|
||||
|
||||
[source,typescript]
|
||||
----
|
||||
import type { Observable } from "rxjs";
|
||||
|
||||
export interface NostrEvent {
|
||||
id?: string;
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
sig?: string;
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
ids?: string[];
|
||||
authors?: string[];
|
||||
kinds?: number[];
|
||||
since?: number;
|
||||
until?: number;
|
||||
limit?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
name?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
nip05?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface WindowEve {
|
||||
publish(event: NostrEvent): Promise<void>;
|
||||
getSingleEventById(id: string): Promise<NostrEvent | null>;
|
||||
getSingleEventWithFilter(filter: Filter): Promise<NostrEvent | null>;
|
||||
getAllEventsWithFilter(filter: Filter): Promise<NostrEvent[]>;
|
||||
subscribeToEvents(filter: Filter): Observable<NostrEvent>;
|
||||
subscribeToProfile(pubkey: string): Observable<Profile>;
|
||||
getProfile(pubkey: string): Promise<Profile | null>;
|
||||
getAvatar(pubkey: string): Promise<string | null>;
|
||||
signEvent(event: NostrEvent): Promise<NostrEvent>;
|
||||
get publicKey(): Promise<string>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
eve: WindowEve;
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
// Registration Section
|
||||
== Registration
|
||||
|
||||
Arxlets are registered using Nostr events with kind `30420`:
|
||||
|
||||
[source,json]
|
||||
----
|
||||
{
|
||||
"kind": 30420,
|
||||
"tags": [
|
||||
["d", "my-calculator"],
|
||||
["name", "Simple Calculator"],
|
||||
["description", "A basic calculator for quick math"],
|
||||
["script", "export function render(el) { /* your code */ }"],
|
||||
["icon", "mdi:calculator", "#3b82f6"]
|
||||
],
|
||||
"content": "",
|
||||
"created_at": 1735171200
|
||||
}
|
||||
----
|
||||
|
||||
=== Required Tags
|
||||
* `d`: Unique identifier (alphanumeric, hyphens, underscores)
|
||||
* `name`: Human-readable display name
|
||||
* `script`: Complete JavaScript code with render export function
|
||||
|
||||
=== Optional Tags
|
||||
* `description`: Brief description of functionality
|
||||
* `icon`: Iconify icon name and hex color
|
||||
|
||||
// Development Patterns Section
|
||||
== Development Patterns
|
||||
|
||||
=== Basic Arxlet Structure
|
||||
|
||||
[source,typescript]
|
||||
----
|
||||
/**
|
||||
* Required export function - Entry point for your Arxlet
|
||||
*/
|
||||
export function render(container: HTMLElement): void {
|
||||
// Initialize your application
|
||||
container.innerHTML = `
|
||||
<div class="p-6">
|
||||
<h1 class="text-3xl font-bold mb-4">My Arxlet</h1>
|
||||
<p class="text-lg">Hello from Eve!</p>
|
||||
<button class="btn btn-primary mt-4" id="myButton">
|
||||
Click me!
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners with proper typing
|
||||
const button = container.querySelector<HTMLButtonElement>("#myButton");
|
||||
button?.addEventListener("click", (): void => {
|
||||
alert("Button clicked!");
|
||||
});
|
||||
|
||||
// Your app logic here...
|
||||
}
|
||||
----
|
||||
|
||||
=== Real-time Updates
|
||||
[source,typescript]
|
||||
----
|
||||
export function render(container: HTMLElement): void {
|
||||
let subscription: Subscription;
|
||||
|
||||
// Set up UI
|
||||
container.innerHTML = `<div id="events"></div>`;
|
||||
const eventsContainer = container.querySelector("#events");
|
||||
|
||||
// Subscribe to real-time events
|
||||
subscription = window.eve
|
||||
.subscribeToEvents({
|
||||
kinds: [1],
|
||||
limit: 50,
|
||||
})
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
// Update UI with new event
|
||||
const eventElement = document.createElement("div");
|
||||
eventElement.textContent = event.content;
|
||||
eventsContainer?.prepend(eventElement);
|
||||
},
|
||||
error: (err) => console.error("Subscription error:", err),
|
||||
});
|
||||
|
||||
// Cleanup when arxlet is destroyed
|
||||
window.addEventListener("beforeunload", () => {
|
||||
subscription?.unsubscribe();
|
||||
});
|
||||
}
|
||||
----
|
||||
|
||||
=== Publishing Events
|
||||
|
||||
[source,typescript]
|
||||
----
|
||||
import type { NostrEvent } from "./type-definitions.ts";
|
||||
|
||||
export async function render(container: HTMLElement): Promise<void> {
|
||||
container.innerHTML = `
|
||||
<div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">📝 Publish a Note</h2>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">What's on your mind?</span>
|
||||
<span class="label-text-alt" id="charCount">0/280</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="textarea textarea-bordered h-32"
|
||||
id="noteContent"
|
||||
placeholder="Share your thoughts with your CCN..."
|
||||
maxlength="280">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-between items-center">
|
||||
<div id="status" class="flex-1"></div>
|
||||
<button class="btn btn-primary" id="publishBtn" disabled>
|
||||
Publish Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("#noteContent")!;
|
||||
const publishBtn = container.querySelector<HTMLButtonElement>("#publishBtn")!;
|
||||
const status = container.querySelector<HTMLDivElement>("#status")!;
|
||||
const charCount = container.querySelector<HTMLSpanElement>("#charCount")!;
|
||||
|
||||
textarea.oninput = (): void => {
|
||||
const length: number = textarea.value.length;
|
||||
charCount.textContent = `${length}/280`;
|
||||
publishBtn.disabled = length === 0 || length > 280;
|
||||
};
|
||||
|
||||
publishBtn.onclick = async (e): Promise<void> => {
|
||||
const content: string = textarea.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
publishBtn.disabled = true;
|
||||
publishBtn.textContent = "Publishing...";
|
||||
status.innerHTML = '<span class="loading loading-spinner loading-sm"></span>';
|
||||
|
||||
try {
|
||||
const unsignedEvent: NostrEvent = {
|
||||
kind: 1, // Text note
|
||||
content: content,
|
||||
tags: [["client", "arxlet-publisher"]],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: await window.eve.publicKey,
|
||||
};
|
||||
|
||||
const signedEvent: NostrEvent = await window.eve.signEvent(unsignedEvent);
|
||||
|
||||
await window.eve.publish(signedEvent);
|
||||
|
||||
status.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
<span>✅ Note published successfully!</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
textarea.value = "";
|
||||
textarea.oninput?.(e);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("Publishing failed:", error);
|
||||
status.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
<span>❌ Failed to publish: ${errorMessage}</span>
|
||||
</div>
|
||||
`;
|
||||
} finally {
|
||||
publishBtn.disabled = false;
|
||||
publishBtn.textContent = "Publish Note";
|
||||
}
|
||||
};
|
||||
}
|
||||
----
|
||||
|
||||
// Best Practices Section
|
||||
== Best Practices
|
||||
|
||||
=== Error Handling
|
||||
- Always wrap API calls in try-catch blocks
|
||||
- Check for null returns from query methods
|
||||
- Provide user feedback for failed operations
|
||||
|
||||
=== Performance
|
||||
- Use specific filters to limit result sets
|
||||
- Cache profile data to avoid repeated lookups
|
||||
- Unsubscribe from observables when done
|
||||
- Debounce rapid API calls
|
||||
- Consider pagination for large datasets
|
||||
|
||||
=== Security
|
||||
- Validate all user inputs
|
||||
- Sanitize content before displaying
|
||||
- Use proper event signing for authenticity
|
||||
- Follow principle of least privilege
|
||||
|
||||
=== Memory Management
|
||||
- Always unsubscribe from RxJS observables
|
||||
- Clean up event listeners on component destruction
|
||||
- Avoid memory leaks in long-running subscriptions
|
||||
- Use weak references where appropriate
|
||||
|
||||
// Common Use Cases Section
|
||||
== Common Use Cases
|
||||
|
||||
=== Social Feed
|
||||
- Subscribe to events from followed users
|
||||
- Display real-time updates
|
||||
- Handle profile information and avatars
|
||||
- Implement engagement features
|
||||
|
||||
=== Publishing Tools
|
||||
- Create and sign events
|
||||
- Validate content before publishing
|
||||
- Handle publishing errors gracefully
|
||||
- Provide user feedback
|
||||
|
||||
=== Data Visualization
|
||||
- Query historical events
|
||||
- Process and aggregate data
|
||||
- Create interactive charts and graphs
|
||||
- Real-time data updates
|
||||
|
||||
=== Communication Apps
|
||||
- Direct messaging interfaces
|
||||
- Group chat functionality
|
||||
- Notification systems
|
||||
- Presence indicators
|
||||
|
||||
// Framework Integration Section
|
||||
== Framework Integration
|
||||
|
||||
Arxlets support various JavaScript frameworks. All frameworks must export a `render` function that accepts a container element:
|
||||
|
||||
=== Vanilla JavaScript
|
||||
|
||||
[source,typescript]
|
||||
----
|
||||
export function render(container: HTMLElement) {
|
||||
let count: number = 0;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="card-title justify-center">Counter App</h2>
|
||||
<div class="text-6xl font-bold text-primary my-4" id="display">
|
||||
${count}
|
||||
</div>
|
||||
<div class="card-actions justify-center gap-4">
|
||||
<button class="btn btn-error" id="decrement">−</button>
|
||||
<button class="btn btn-success" id="increment">+</button>
|
||||
<button class="btn btn-ghost" id="reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const display = container.querySelector<HTMLDivElement>("#display")!;
|
||||
const incrementBtn = container.querySelector<HTMLButtonElement>("#increment")!;
|
||||
const decrementBtn = container.querySelector<HTMLButtonElement>("#decrement")!;
|
||||
const resetBtn = container.querySelector<HTMLButtonElement>("#reset")!;
|
||||
|
||||
const updateDisplay = (): void => {
|
||||
display.textContent = count.toString();
|
||||
display.className = `text-6xl font-bold my-4 ${
|
||||
count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"
|
||||
}`;
|
||||
};
|
||||
|
||||
incrementBtn.onclick = (): void => {
|
||||
count++;
|
||||
updateDisplay();
|
||||
};
|
||||
decrementBtn.onclick = (): void => {
|
||||
count--;
|
||||
updateDisplay();
|
||||
};
|
||||
resetBtn.onclick = (): void => {
|
||||
count = 0;
|
||||
updateDisplay();
|
||||
};
|
||||
}
|
||||
----
|
||||
|
||||
=== Preact/React
|
||||
|
||||
[source,tsx]
|
||||
----
|
||||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import { render as renderPreact } from "preact";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
const CounterApp = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const increment = () => {
|
||||
setCount((prev) => prev + 1);
|
||||
setMessage(`Clicked ${count + 1} times!`);
|
||||
};
|
||||
|
||||
const decrement = () => {
|
||||
setCount((prev) => prev - 1);
|
||||
setMessage(`Count decreased to ${count - 1}`);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setCount(0);
|
||||
setMessage("Counter reset!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="card-title justify-center"> Preact Counter </h2>
|
||||
|
||||
<div
|
||||
class={`text-6xl font-bold my-4 ${count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"}`}
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-center gap-4">
|
||||
<button class="btn btn-error" onClick={decrement}>
|
||||
−
|
||||
</button>
|
||||
<button class="btn btn-success" onClick={increment}>
|
||||
+
|
||||
</button>
|
||||
<button class="btn btn-ghost" onClick={reset}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div class="alert alert-info mt-4">
|
||||
<span>{message} </span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function render(container: HTMLElement): void {
|
||||
renderPreact(<CounterApp />, container);
|
||||
}
|
||||
----
|
||||
|
||||
=== Svelte
|
||||
|
||||
[source,svelte]
|
||||
----
|
||||
<script lang="ts">
|
||||
let count = $state(0);
|
||||
let message = $state("");
|
||||
|
||||
function increment() {
|
||||
count += 1;
|
||||
message = `Clicked ${count} times!`;
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
count -= 1;
|
||||
message = `Count decreased to ${count}`;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
count = 0;
|
||||
message = "Counter reset!";
|
||||
}
|
||||
|
||||
const countColor = $derived(count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary");
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="card-title justify-center">🔥 Svelte Counter</h2>
|
||||
|
||||
<div class="text-6xl font-bold my-4 {countColor}">
|
||||
{count}
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-center gap-4">
|
||||
<button class="btn btn-error" onclick={decrement}> − </button>
|
||||
<button class="btn btn-success" onclick={increment}> + </button>
|
||||
<button class="btn btn-ghost" onclick={reset}> Reset </button>
|
||||
</div>
|
||||
|
||||
{#if message}
|
||||
<div class="alert alert-info mt-4">
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card-title {
|
||||
color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
----
|
||||
|
||||
=== Build Process
|
||||
All frameworks require bundling into a single JavaScript file:
|
||||
|
||||
[source,bash]
|
||||
----
|
||||
# For TypeScript/JavaScript projects
|
||||
bun build index.ts --outfile=build.js --minify --target=browser --production
|
||||
|
||||
# The resulting build.js content goes in your registration event's script tag
|
||||
----
|
||||
|
||||
==== Svelte Build Requirements
|
||||
|
||||
IMPORTANT: The standard build command above will NOT work for Svelte projects. Svelte requires specific Vite configuration to compile properly.
|
||||
|
||||
For Svelte arxlets:
|
||||
|
||||
. Use the https://git.arx-ccn.com/Arx/arxlets-template[arxlets-template] which includes the correct Vite configuration
|
||||
. Run `bun run build` instead of the standard build command
|
||||
. Your compiled file will be available at `dist/bundle.js`
|
||||
|
||||
While the initial setup is more complex, Svelte provides an excellent development experience once configured, with features like:
|
||||
|
||||
- Built-in reactivity with runes (`$state()`, `$derived()`, etc.)
|
||||
- Scoped CSS
|
||||
- Compile-time optimizations
|
||||
- No runtime overhead
|
||||
|
||||
// Debugging and Development Section
|
||||
== Debugging and Development
|
||||
|
||||
=== Console Logging
|
||||
- Use `console.log()` for debugging
|
||||
- Events and errors are logged to browser console
|
||||
|
||||
=== Error Handling
|
||||
- Catch and log API errors
|
||||
- Display user-friendly error messages
|
||||
- Implement retry mechanisms for transient failures
|
||||
|
||||
=== Testing
|
||||
- Test with various event types and filters
|
||||
- Verify subscription cleanup
|
||||
- Test error scenarios and edge cases
|
||||
- Validate event signing and publishing
|
||||
|
||||
// Limitations and Considerations Section
|
||||
== Limitations and Considerations
|
||||
|
||||
=== Sandbox Restrictions
|
||||
- Limited access to browser APIs
|
||||
- No direct file system access
|
||||
- Restricted network access (only to Eve relay)
|
||||
- No access to parent window context
|
||||
|
||||
=== Performance Constraints
|
||||
- Iframe overhead for each arxlet
|
||||
- Memory usage for subscriptions
|
||||
- Event processing limitations
|
||||
|
||||
=== Security Considerations
|
||||
- All events are public on Nostr
|
||||
- Private key management handled by Eve
|
||||
- Content sanitization required
|
||||
- XSS prevention necessary
|
||||
|
||||
// DaisyUI Components Section
|
||||
== DaisyUI Components
|
||||
|
||||
Arxlets have access to DaisyUI 5, a comprehensive CSS component library. Use these pre-built components for consistent, accessible UI:
|
||||
|
||||
=== Essential Components
|
||||
[source,html]
|
||||
----
|
||||
<!-- Cards for content containers -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Card Title</h2>
|
||||
<p>Card content goes here</p>
|
||||
<div class="card-actions justify-end">
|
||||
<button class="btn btn-primary">Action</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons with various styles -->
|
||||
<button class="btn btn-primary">Primary</button>
|
||||
<button class="btn btn-secondary">Secondary</button>
|
||||
<button class="btn btn-success">Success</button>
|
||||
<button class="btn btn-error">Error</button>
|
||||
<button class="btn btn-ghost">Ghost</button>
|
||||
|
||||
<!-- Form controls -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Input Label</span>
|
||||
</label>
|
||||
<input type="text" class="input input-bordered" placeholder="Enter text" />
|
||||
</div>
|
||||
|
||||
<!-- Alerts for feedback -->
|
||||
<div class="alert alert-success">
|
||||
<span>✅ Success message</span>
|
||||
</div>
|
||||
<div class="alert alert-error">
|
||||
<span>❌ Error message</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading states -->
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<button class="btn btn-primary">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Loading...
|
||||
</button>
|
||||
|
||||
<!-- Modals for dialogs -->
|
||||
<dialog class="modal" id="my-modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Modal Title</h3>
|
||||
<p class="py-4">Modal content</p>
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick="document.getElementById('my-modal').close()">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
----
|
||||
|
||||
=== Layout Utilities
|
||||
[source,html]
|
||||
----
|
||||
<!-- Responsive grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="card">Content 1</div>
|
||||
<div class="card">Content 2</div>
|
||||
<div class="card">Content 3</div>
|
||||
</div>
|
||||
|
||||
<!-- Flexbox utilities -->
|
||||
<div class="flex justify-between items-center">
|
||||
<span>Left content</span>
|
||||
<button class="btn">Right button</button>
|
||||
</div>
|
||||
|
||||
<!-- Spacing -->
|
||||
<div class="p-4 m-2 space-y-4">
|
||||
<!-- p-4 = padding, m-2 = margin, space-y-4 = vertical spacing -->
|
||||
</div>
|
||||
----
|
||||
|
||||
=== Color System
|
||||
[source,html]
|
||||
----
|
||||
<!-- Background colors -->
|
||||
<div class="bg-base-100">Default background</div>
|
||||
<div class="bg-base-200">Slightly darker</div>
|
||||
<div class="bg-primary">Primary color</div>
|
||||
<div class="bg-secondary">Secondary color</div>
|
||||
|
||||
<!-- Text colors -->
|
||||
<span class="text-primary">Primary text</span>
|
||||
<span class="text-success">Success text</span>
|
||||
<span class="text-error">Error text</span>
|
||||
<span class="text-base-content">Default text</span>
|
||||
----
|
||||
|
||||
// Complete Example Patterns Section
|
||||
== Complete Example Patterns
|
||||
|
||||
=== Simple Counter Arxlet
|
||||
|
||||
[source,typescript]
|
||||
----
|
||||
export function render(container: HTMLElement) {
|
||||
let count: number = 0;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="card-title justify-center">Counter App</h2>
|
||||
<div class="text-6xl font-bold text-primary my-4" id="display">
|
||||
${count}
|
||||
</div>
|
||||
<div class="card-actions justify-center gap-4">
|
||||
<button class="btn btn-error" id="decrement">−</button>
|
||||
<button class="btn btn-success" id="increment">+</button>
|
||||
<button class="btn btn-ghost" id="reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const display = container.querySelector<HTMLDivElement>("#display")!;
|
||||
const incrementBtn = container.querySelector<HTMLButtonElement>("#increment")!;
|
||||
const decrementBtn = container.querySelector<HTMLButtonElement>("#decrement")!;
|
||||
const resetBtn = container.querySelector<HTMLButtonElement>("#reset")!;
|
||||
|
||||
const updateDisplay = (): void => {
|
||||
display.textContent = count.toString();
|
||||
display.className = `text-6xl font-bold my-4 ${
|
||||
count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"
|
||||
}`;
|
||||
};
|
||||
|
||||
incrementBtn.onclick = (): void => {
|
||||
count++;
|
||||
updateDisplay();
|
||||
};
|
||||
decrementBtn.onclick = (): void => {
|
||||
count--;
|
||||
updateDisplay();
|
||||
};
|
||||
resetBtn.onclick = (): void => {
|
||||
count = 0;
|
||||
updateDisplay();
|
||||
};
|
||||
}
|
||||
----
|
||||
|
||||
=== Nostr Event Publisher
|
||||
|
||||
[source,typescript]
|
||||
----
|
||||
import type { NostrEvent } from "./type-definitions.ts";
|
||||
|
||||
export async function render(container: HTMLElement): Promise<void> {
|
||||
container.innerHTML = `
|
||||
<div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">📝 Publish a Note</h2>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">What's on your mind?</span>
|
||||
<span class="label-text-alt" id="charCount">0/280</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="textarea textarea-bordered h-32"
|
||||
id="noteContent"
|
||||
placeholder="Share your thoughts with your CCN..."
|
||||
maxlength="280">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-between items-center">
|
||||
<div id="status" class="flex-1"></div>
|
||||
<button class="btn btn-primary" id="publishBtn" disabled>
|
||||
Publish Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("#noteContent")!;
|
||||
const publishBtn = container.querySelector<HTMLButtonElement>("#publishBtn")!;
|
||||
const status = container.querySelector<HTMLDivElement>("#status")!;
|
||||
const charCount = container.querySelector<HTMLSpanElement>("#charCount")!;
|
||||
|
||||
textarea.oninput = (): void => {
|
||||
const length: number = textarea.value.length;
|
||||
charCount.textContent = `${length}/280`;
|
||||
publishBtn.disabled = length === 0 || length > 280;
|
||||
};
|
||||
|
||||
publishBtn.onclick = async (e): Promise<void> => {
|
||||
const content: string = textarea.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
publishBtn.disabled = true;
|
||||
publishBtn.textContent = "Publishing...";
|
||||
status.innerHTML = '<span class="loading loading-spinner loading-sm"></span>';
|
||||
|
||||
try {
|
||||
const unsignedEvent: NostrEvent = {
|
||||
kind: 1, // Text note
|
||||
content: content,
|
||||
tags: [["client", "arxlet-publisher"]],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: await window.eve.publicKey,
|
||||
};
|
||||
|
||||
const signedEvent: NostrEvent = await window.eve.signEvent(unsignedEvent);
|
||||
|
||||
await window.eve.publish(signedEvent);
|
||||
|
||||
status.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
<span>✅ Note published successfully!</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
textarea.value = "";
|
||||
textarea.oninput?.(e);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("Publishing failed:", error);
|
||||
status.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
<span>❌ Failed to publish: ${errorMessage}</span>
|
||||
</div>
|
||||
`;
|
||||
} finally {
|
||||
publishBtn.disabled = false;
|
||||
publishBtn.textContent = "Publish Note";
|
||||
}
|
||||
};
|
||||
}
|
||||
----
|
746
src/pages/docs/arxlets/arxlet-docs.css
Normal file
746
src/pages/docs/arxlets/arxlet-docs.css
Normal file
|
@ -0,0 +1,746 @@
|
|||
@import url("https://esm.sh/prismjs/themes/prism-tomorrow.css");
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
* {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
font-family: "Fira Code", "Monaco", "Cascadia Code", "Roboto Mono", monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
kbd {
|
||||
border-radius: 25%;
|
||||
color: white;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.mockup-code pre::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Next-Level Sidebar Styling */
|
||||
.sidebar-container {
|
||||
background: linear-gradient(180deg, hsl(var(--b2)) 0%, hsl(var(--b1)) 100%);
|
||||
border-right: 1px solid hsl(var(--b3));
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-container::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, hsl(var(--p) / 0.5), transparent);
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Global Progress Bar */
|
||||
.global-progress-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: hsl(var(--b3) / 0.1);
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.global-progress-bar {
|
||||
height: 100%;
|
||||
background: cyan;
|
||||
transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow:
|
||||
0 0 12px hsl(var(--p) / 0.5),
|
||||
0 1px 3px hsl(var(--p) / 0.3);
|
||||
position: relative;
|
||||
border-radius: 0 1px 1px 0;
|
||||
}
|
||||
|
||||
.global-progress-bar::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, hsl(var(--p) / 0.8) 0%, hsl(var(--s) / 0.9) 50%, hsl(var(--a) / 0.8) 100%);
|
||||
animation: shimmer-progress 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.global-progress-bar::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -2px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: hsl(var(--pc));
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 6px hsl(var(--pc) / 0.8);
|
||||
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer-progress {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure content doesn't get hidden behind progress bar */
|
||||
body {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* Enhanced Header */
|
||||
.sidebar-header {
|
||||
background: hsl(var(--b1));
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid hsl(var(--b3) / 0.5);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-icon::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(45deg, transparent, hsl(var(--pc) / 0.1), transparent);
|
||||
animation: rotate 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Navigation Container */
|
||||
.navigation-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.navigation-container.scrolling {
|
||||
background: hsl(var(--b2) / 0.95);
|
||||
}
|
||||
|
||||
.drawer-side .menu {
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Navigation Items */
|
||||
.nav-item {
|
||||
animation: slideInLeft 0.6s ease-out;
|
||||
animation-delay: calc(var(--item-index) * 0.1s);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Section Links */
|
||||
.section-link {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.section-link::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, hsl(var(--p) / 0.1), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.section-link:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.section-link:hover {
|
||||
background: hsl(var(--b3) / 0.7);
|
||||
transform: translateX(4px) scale(1.02);
|
||||
box-shadow: 0 8px 25px hsl(var(--b3) / 0.3);
|
||||
}
|
||||
|
||||
.section-link.active {
|
||||
background: linear-gradient(135deg, hsl(var(--p)) 0%, hsl(var(--s)) 100%);
|
||||
color: hsl(var(--pc));
|
||||
font-weight: 700;
|
||||
box-shadow:
|
||||
0 8px 25px hsl(var(--p) / 0.4),
|
||||
0 0 0 1px hsl(var(--p) / 0.2),
|
||||
inset 0 1px 0 hsl(var(--pc) / 0.1);
|
||||
transform: translateX(6px);
|
||||
}
|
||||
|
||||
.section-link.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 70%;
|
||||
background: linear-gradient(180deg, hsl(var(--p)), hsl(var(--s)));
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 10px hsl(var(--p) / 0.5);
|
||||
}
|
||||
|
||||
/* Section Icon Container */
|
||||
.section-icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 1.1rem;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.icon-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle, hsl(var(--p) / 0.2) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.section-link.active .icon-glow {
|
||||
opacity: 1;
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.section-link.active .section-icon {
|
||||
transform: scale(1.1);
|
||||
filter: drop-shadow(0 0 8px hsl(var(--pc) / 0.5));
|
||||
}
|
||||
|
||||
/* Section Text */
|
||||
.section-text {
|
||||
flex: 1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.section-link.active .section-text {
|
||||
text-shadow: 0 0 10px hsl(var(--pc) / 0.3);
|
||||
}
|
||||
|
||||
/* Section Badge */
|
||||
.section-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.subsection-count {
|
||||
background: hsl(var(--b3) / 0.5);
|
||||
color: hsl(var(--bc) / 0.7);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.section-link.active .subsection-count {
|
||||
background: hsl(var(--pc) / 0.2);
|
||||
color: hsl(var(--pc));
|
||||
box-shadow: 0 0 10px hsl(var(--pc) / 0.3);
|
||||
}
|
||||
|
||||
/* Enhanced Subsection Styling */
|
||||
.subsection-menu {
|
||||
margin-left: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
border-left: 2px solid hsl(var(--b3) / 0.3);
|
||||
gap: 0.25rem;
|
||||
position: relative;
|
||||
animation: slideDown 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.subsection-menu::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, hsl(var(--p) / 0.5), transparent);
|
||||
transform: scaleY(0);
|
||||
transform-origin: top;
|
||||
animation: expandLine 0.6s ease-out 0.2s forwards;
|
||||
}
|
||||
|
||||
@keyframes expandLine {
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
.subsection-menu li {
|
||||
animation: slideInRight 0.4s ease-out;
|
||||
animation-delay: calc(var(--sub-index) * 0.05s + 0.1s);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-15px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.subsection-link {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: hsl(var(--bc) / 0.7);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.subsection-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--bc) / 0.3);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.subsection-dot::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: hsl(var(--p));
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.subsection-text {
|
||||
flex: 1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.subsection-link:hover {
|
||||
background: hsl(var(--b3) / 0.4);
|
||||
color: hsl(var(--bc));
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 12px hsl(var(--b3) / 0.2);
|
||||
}
|
||||
|
||||
.subsection-link:hover .subsection-dot {
|
||||
background: hsl(var(--p) / 0.7);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.subsection-link:hover .subsection-dot::after {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.subsection-link.active {
|
||||
background: linear-gradient(135deg, hsl(var(--p) / 0.15), hsl(var(--s) / 0.1));
|
||||
color: hsl(var(--p));
|
||||
font-weight: 600;
|
||||
transform: translateX(6px);
|
||||
box-shadow:
|
||||
0 4px 15px hsl(var(--p) / 0.2),
|
||||
inset 0 1px 0 hsl(var(--p) / 0.1);
|
||||
border-left: 3px solid hsl(var(--p));
|
||||
padding-left: calc(1rem - 3px);
|
||||
}
|
||||
|
||||
.subsection-link.active .subsection-dot {
|
||||
background: hsl(var(--p));
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 0 10px hsl(var(--p) / 0.5);
|
||||
}
|
||||
|
||||
.subsection-link.active .subsection-dot::after {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: hsl(var(--p) / 0.3);
|
||||
animation: ripple 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.subsection-link.active .subsection-text {
|
||||
text-shadow: 0 0 8px hsl(var(--p) / 0.3);
|
||||
}
|
||||
|
||||
/* Smooth animations for section changes */
|
||||
.drawer-side .menu ul {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced scrollbar for sidebar */
|
||||
.drawer-side .menu {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--p) / 0.3) transparent;
|
||||
}
|
||||
|
||||
.drawer-side .menu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.drawer-side .menu::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.drawer-side .menu::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--p) / 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.drawer-side .menu::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--p) / 0.5);
|
||||
}
|
||||
|
||||
/* Enhance
|
||||
d Sidebar Structure */
|
||||
.drawer-side aside {
|
||||
background: hsl(var(--b2));
|
||||
border-right: 1px solid hsl(var(--b3));
|
||||
}
|
||||
|
||||
/* Section Links with Icons */
|
||||
.section-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.8;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.section-link.active .section-icon {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Subsection Menu */
|
||||
.subsection-menu {
|
||||
margin-left: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid hsl(var(--b3));
|
||||
gap: 0.125rem;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
.subsection-link {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
color: hsl(var(--bc) / 0.7);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.subsection-link:hover {
|
||||
background: hsl(var(--b3) / 0.5);
|
||||
color: hsl(var(--bc));
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.subsection-link.active {
|
||||
background: hsl(var(--p) / 0.1);
|
||||
color: hsl(var(--p));
|
||||
font-weight: 600;
|
||||
border-left: 3px solid hsl(var(--p));
|
||||
padding-left: calc(0.75rem - 3px);
|
||||
}
|
||||
|
||||
.subsection-link.active::before {
|
||||
content: "▶";
|
||||
position: absolute;
|
||||
left: -1.25rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.625rem;
|
||||
color: hsl(var(--p));
|
||||
}
|
||||
|
||||
/* Enhanced scrollbar for navigation */
|
||||
.drawer-side nav {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--p) / 0.3) transparent;
|
||||
}
|
||||
|
||||
.drawer-side nav::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.drawer-side nav::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.drawer-side nav::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--p) / 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.drawer-side nav::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--p) / 0.5);
|
||||
}
|
||||
|
||||
/* Enhanced Footer */
|
||||
.sidebar-footer {
|
||||
background: linear-gradient(180deg, hsl(var(--b1)) 0%, hsl(var(--b2)) 100%);
|
||||
border-top: 1px solid hsl(var(--b3) / 0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Progress Dots */
|
||||
.progress-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--bc) / 0.2);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-dot.active {
|
||||
background: hsl(var(--p));
|
||||
box-shadow: 0 0 10px hsl(var(--p) / 0.5);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.progress-dot.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid hsl(var(--p) / 0.3);
|
||||
border-radius: 50%;
|
||||
animation: expand-ring 2s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes expand-ring {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Animations */
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth Transitions */
|
||||
* {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Glassmorphism Effects */
|
||||
.sidebar-header,
|
||||
.sidebar-footer {
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
/* Hover Glow Effects */
|
||||
.section-link:hover,
|
||||
.subsection-link:hover {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.section-link:hover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(45deg, hsl(var(--p) / 0.1), hsl(var(--s) / 0.1));
|
||||
border-radius: inherit;
|
||||
z-index: -1;
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Responsive Enhancements */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-link {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.subsection-link {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Optimizations */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar-container {
|
||||
background: linear-gradient(180deg, hsl(var(--b2)) 0%, hsl(220 13% 9%) 100%);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
box-shadow: 0 0 20px hsl(var(--p) / 0.4);
|
||||
}
|
||||
}
|
26
src/pages/docs/arxlets/arxlet-docs.html
Normal file
26
src/pages/docs/arxlets/arxlet-docs.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Arxlets Documentation - Eve</title>
|
||||
<link rel="stylesheet" href="./arxlet-docs.css" />
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/daisyui@5"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body data-theme="dark" class="bg-base-100 text-base-content">
|
||||
<script src="./arxlet-docs.jsx"></script>
|
||||
</body>
|
||||
</html>
|
343
src/pages/docs/arxlets/arxlet-docs.jsx
Normal file
343
src/pages/docs/arxlets/arxlet-docs.jsx
Normal file
|
@ -0,0 +1,343 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import { render } from "preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import "./arxlet-docs.css";
|
||||
|
||||
import { APISection } from "./components/APISection.jsx";
|
||||
import { BestPracticesSection } from "./components/BestPracticesSection.jsx";
|
||||
import { DevelopmentSection } from "./components/DevelopmentSection.jsx";
|
||||
import { ExamplesSection } from "./components/ExamplesSection.jsx";
|
||||
import { LLMsSection } from "./components/LLMsSection.jsx";
|
||||
import { OverviewSection } from "./components/OverviewSection.jsx";
|
||||
import { RegistrationSection } from "./components/RegistrationSection.jsx";
|
||||
import { useSyntaxHighlighting } from "./hooks/useSyntaxHighlighting.js";
|
||||
|
||||
const SECTIONS = {
|
||||
Overview: {
|
||||
component: <OverviewSection />,
|
||||
subsections: {},
|
||||
},
|
||||
Registration: {
|
||||
component: <RegistrationSection />,
|
||||
subsections: {
|
||||
"nostr-event-structure": "Nostr Event Structure",
|
||||
"tag-reference": "Tag Reference",
|
||||
},
|
||||
},
|
||||
Development: {
|
||||
component: <DevelopmentSection />,
|
||||
subsections: {
|
||||
"understanding-arxlets": "Understanding the Arxlet Environment",
|
||||
"nostr-vs-arxlets": "Nostr Apps vs Arxlets",
|
||||
"available-apis": "Available APIs",
|
||||
"security-restrictions": "Security Restrictions",
|
||||
"typescript-development": "TypeScript Development",
|
||||
"required-export-function": "Required Export Function",
|
||||
},
|
||||
},
|
||||
"Best Practices": {
|
||||
component: <BestPracticesSection />,
|
||||
subsections: {
|
||||
"error-handling": "Error Handling & Reliability",
|
||||
performance: "Performance & Efficiency",
|
||||
subscriptions: "Subscription Management",
|
||||
"user-experience": "User Experience Excellence",
|
||||
security: "Security & Privacy",
|
||||
"production-checklist": "Production Readiness",
|
||||
},
|
||||
},
|
||||
"API Reference": {
|
||||
component: <APISection />,
|
||||
subsections: {
|
||||
"window-eve-api": "window.eve API",
|
||||
"real-time-subscriptions": "Real-time Subscriptions",
|
||||
"websocket-alternative": "WebSocket Alternative",
|
||||
"best-practices": "Best Practices",
|
||||
},
|
||||
},
|
||||
Examples: {
|
||||
component: <ExamplesSection />,
|
||||
subsections: {
|
||||
vanilla: "Vanilla JS",
|
||||
svelte: "Svelte",
|
||||
preact: "Preact + JSX",
|
||||
nostr: "Nostr Publisher",
|
||||
},
|
||||
},
|
||||
LLMs: {
|
||||
component: <LLMsSection />,
|
||||
subsections: {},
|
||||
},
|
||||
};
|
||||
|
||||
const ArxletDocs = () => {
|
||||
useSyntaxHighlighting();
|
||||
const [activeSection, setActiveSection] = useState("Overview");
|
||||
const [activeExample, setActiveExample] = useState("vanilla");
|
||||
const [activeSubsection, setActiveSubsection] = useState("");
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash) {
|
||||
setActiveExample(hash);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
handleHashChange();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", handleHashChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Enhanced Intersection Observer to track which subsection is currently visible
|
||||
useEffect(() => {
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: "-10% 0px -60% 0px", // More responsive triggering
|
||||
threshold: [0, 0.1, 0.5, 1.0], // Multiple thresholds for better detection
|
||||
};
|
||||
|
||||
const visibleSections = new Set();
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
visibleSections.add(entry.target.id);
|
||||
} else {
|
||||
visibleSections.delete(entry.target.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Set the active subsection to the first visible one (topmost)
|
||||
if (visibleSections.size > 0) {
|
||||
const currentSectionData = SECTIONS[activeSection];
|
||||
if (currentSectionData?.subsections) {
|
||||
const subsectionIds = Object.keys(currentSectionData.subsections);
|
||||
const firstVisible = subsectionIds.find((id) => visibleSections.has(id));
|
||||
if (firstVisible) {
|
||||
setActiveSubsection(firstVisible);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, observerOptions);
|
||||
|
||||
// Get all subsection IDs from the current section
|
||||
const currentSectionData = SECTIONS[activeSection];
|
||||
if (currentSectionData?.subsections) {
|
||||
const subsectionIds = Object.keys(currentSectionData.subsections);
|
||||
|
||||
// Clear previous observations
|
||||
visibleSections.clear();
|
||||
|
||||
// Observe elements with these IDs
|
||||
subsectionIds.forEach((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subsectionIds.forEach((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
observer.unobserve(element);
|
||||
}
|
||||
});
|
||||
visibleSections.clear();
|
||||
};
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
// Scroll progress tracking
|
||||
useEffect(() => {
|
||||
let scrollTimeout;
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const progress = Math.min((scrollTop / docHeight) * 100, 100);
|
||||
|
||||
setScrollProgress(progress);
|
||||
setIsScrolling(true);
|
||||
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => {
|
||||
setIsScrolling(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
clearTimeout(scrollTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [activeSection, activeExample]);
|
||||
|
||||
const renderContent = () => {
|
||||
const section = SECTIONS[activeSection];
|
||||
if (!section) return <div>Section not found</div>;
|
||||
|
||||
if (activeSection === "Examples") {
|
||||
return <ExamplesSection activeExample={activeExample} />;
|
||||
}
|
||||
|
||||
return section.component;
|
||||
};
|
||||
|
||||
const handleNavClick = (e, section) => {
|
||||
e.preventDefault();
|
||||
setActiveSection(section);
|
||||
window.location.hash = "";
|
||||
};
|
||||
|
||||
const handleSubNavClick = (e, subsectionId) => {
|
||||
e.preventDefault();
|
||||
if (activeSection === "Examples") {
|
||||
window.location.hash = subsectionId;
|
||||
} else {
|
||||
document.getElementById(subsectionId)?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="drawer drawer-open" data-theme="dark">
|
||||
{/* Global Progress Bar */}
|
||||
<div class="global-progress-container">
|
||||
<div class="global-progress-bar" style={`width: ${scrollProgress}%`}></div>
|
||||
</div>
|
||||
|
||||
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
|
||||
<div class="drawer-content flex flex-col p-8">
|
||||
{/* Header */}
|
||||
<header class="mb-12">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="w-16 h-16 bg-gradient-to-br from-primary to-secondary rounded-xl flex items-center justify-center shadow-lg">
|
||||
<span class="text-2xl">🚀</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-5xl font-bold">Arxlets</h1>
|
||||
<p class="text-xl text-base-content/70 mt-2">Secure Applications for Eve</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
</header>
|
||||
|
||||
{renderContent()}
|
||||
|
||||
{/* Footer */}
|
||||
<footer class="mt-16 pt-8 border-t border-base-300">
|
||||
<div class="text-center text-base-content/60">
|
||||
<p class="text-lg">Arxlets Documentation • Eve</p>
|
||||
<p class="text-sm mt-2">Build secure, sandboxed applications for your CCN</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<div class="drawer-side">
|
||||
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<aside class="sidebar-container w-80 min-h-full bg-base-200 flex flex-col">
|
||||
{/* Sidebar Header */}
|
||||
<div class="sidebar-header p-4 border-b border-base-300">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="header-icon w-8 h-8 bg-gradient-to-br from-primary to-secondary rounded-lg flex items-center justify-center">
|
||||
<span class="text-sm">📚</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-bold text-lg">Documentation</h2>
|
||||
<p class="text-xs text-base-content/60">Navigate sections</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<nav class={`navigation-container flex-1 overflow-y-auto ${isScrolling ? "scrolling" : ""}`}>
|
||||
<ul class="menu p-4 w-full text-base-content">
|
||||
{Object.entries(SECTIONS).map(([section, { subsections }], index) => (
|
||||
<li key={section} class="nav-item" style={`--item-index: ${index}`}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${activeSection === section ? "active" : ""} section-link`}
|
||||
onClick={(e) => handleNavClick(e, section)}
|
||||
>
|
||||
<span class="section-icon-container">
|
||||
<span class="section-icon">
|
||||
{section === "Overview" && "🏠"}
|
||||
{section === "Registration" && "📝"}
|
||||
{section === "Development" && "⚡"}
|
||||
{section === "Best Practices" && "✨"}
|
||||
{section === "API Reference" && "🔧"}
|
||||
{section === "Examples" && "💡"}
|
||||
{section === "LLMs" && "🤖"}
|
||||
</span>
|
||||
<span class="icon-glow"></span>
|
||||
</span>
|
||||
<span class="section-text">{section}</span>
|
||||
<span class="section-badge">
|
||||
{Object.keys(subsections).length > 0 && (
|
||||
<span class="subsection-count">{Object.keys(subsections).length}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{activeSection === section && Object.keys(subsections).length > 0 && (
|
||||
<ul class="subsection-menu">
|
||||
{Object.entries(subsections).map(([subsectionId, subsectionLabel], subIndex) => (
|
||||
<li key={subsectionId} style={`--sub-index: ${subIndex}`}>
|
||||
<a
|
||||
href={`#${subsectionId}`}
|
||||
class={`subsection-link ${
|
||||
activeSection === "Examples"
|
||||
? activeExample === subsectionId
|
||||
? "active"
|
||||
: ""
|
||||
: activeSubsection === subsectionId
|
||||
? "active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={(e) => handleSubNavClick(e, subsectionId)}
|
||||
>
|
||||
<span class="subsection-dot"></span>
|
||||
<span class="subsection-text">{subsectionLabel}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Sidebar Footer */}
|
||||
<div class="sidebar-footer p-4 border-t border-base-300">
|
||||
<div class="text-center space-y-2">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-success animate-pulse"></div>
|
||||
<p class="text-xs text-base-content/50">Arxlets v0.1b</p>
|
||||
</div>
|
||||
<div class="flex justify-center gap-1">
|
||||
{Object.keys(SECTIONS).map((_, index) => (
|
||||
<div
|
||||
class={`progress-dot ${index <= Object.keys(SECTIONS).findIndex(([key]) => key === activeSection) ? "active" : ""}`}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(<ArxletDocs />, document.body);
|
678
src/pages/docs/arxlets/components/APISection.jsx
Normal file
678
src/pages/docs/arxlets/components/APISection.jsx
Normal file
|
@ -0,0 +1,678 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import eveApiExample from "../highlight/eve-api-example.ts" with { type: "text" };
|
||||
import subscriptionExamples from "../highlight/subscription-examples.ts" with { type: "text" };
|
||||
import websocketExample from "../highlight/websocket-example.ts" with { type: "text" };
|
||||
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||
import { CodeBlock } from "./CodeBlock.jsx";
|
||||
|
||||
/**
|
||||
* API Section - Comprehensive guide to available APIs
|
||||
* Covers window.eve API and WebSocket alternatives
|
||||
*/
|
||||
export const APISection = () => {
|
||||
useSyntaxHighlighting();
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-3xl font-bold">API Reference</h2>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-info mb-4">Understanding Arxlet APIs</h3>
|
||||
<div class="space-y-4">
|
||||
<p>
|
||||
Your Arxlet has access to powerful APIs that let you interact with Nostr data, manage user profiles, and
|
||||
create real-time applications. Think of these APIs as your toolkit for building social, decentralized
|
||||
applications within the CCN ecosystem.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Two approaches available:</strong> You can use the convenient <code>window.eve</code> API
|
||||
(recommended for most cases) or connect directly via WebSocket for advanced scenarios. Both give you full
|
||||
access to Nostr events and CCN features.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-primary mb-4">🎯 Which API Should You Use?</h3>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="border-2 border-primary rounded-lg p-4">
|
||||
<h4 class="font-bold text-primary mb-3">✨ window.eve API (Recommended)</h4>
|
||||
<p class="text-sm mb-3">
|
||||
<strong>Best for most Arxlets.</strong> This high-level API handles all the complex Nostr protocol
|
||||
details for you.
|
||||
</p>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>Simple promise-based functions</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>Automatic error handling</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>Built-in RxJS observables for real-time data</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>Profile and avatar helpers</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>Perfect for beginners</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-2 border-accent rounded-lg p-4">
|
||||
<h4 class="font-bold text-accent mb-3">⚡ Direct WebSocket</h4>
|
||||
<p class="text-sm mb-3">
|
||||
<strong>For advanced use cases.</strong> Direct connection to the Nostr relay with full protocol
|
||||
control.
|
||||
</p>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>Maximum performance and control</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>Custom subscription management</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>Raw Nostr protocol access</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-warning">!</span>
|
||||
<span>Requires Nostr protocol knowledge</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-warning">!</span>
|
||||
<span>More complex error handling</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-6">
|
||||
<span>
|
||||
💡 <strong>Our Recommendation:</strong> Start with <code>window.eve</code> for your first Arxlet. You can
|
||||
always switch to WebSocket later if you need more control or performance.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* window.eve API */}
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="window-eve-api" class="card-title text-primary mb-4">
|
||||
🚀 window.eve API - Your Main Toolkit
|
||||
</h3>
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
The <code>window.eve</code> API is your primary interface for working with Nostr data in Arxlets. It
|
||||
provides simple, promise-based functions that handle all the complex protocol details behind the scenes.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>How it works:</strong> Each function communicates with the local Nostr relay, processes the
|
||||
results, and returns clean JavaScript objects. No need to understand Nostr protocol internals - just call
|
||||
the functions and get your data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h4 class="font-bold text-lg mb-4 text-success">📤 Publishing & Writing Data</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="border-l-4 border-success pl-4">
|
||||
<h5 class="font-bold">publish(event)</h5>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
Publishes a Nostr event to the relay. This is how you save data, post messages, or create any
|
||||
content.
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
<strong>Use cases:</strong> Posting messages, saving user preferences, creating notes, updating
|
||||
profiles
|
||||
</p>
|
||||
<div class="badge badge-outline">Promise<void></div>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-success pl-4">
|
||||
<h5 class="font-bold">signEvent(event)</h5>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
Signs an unsigned Nostr event with the user's private key. Required before publishing most events.
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
<strong>Use cases:</strong> Preparing events for publication, authenticating user actions
|
||||
</p>
|
||||
<div class="badge badge-outline">Promise<NostrEvent></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-bold text-lg mb-4 text-info">🔍 Reading & Querying Data</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="border-l-4 border-info pl-4">
|
||||
<h5 class="font-bold">getSingleEventById(id)</h5>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
Retrieves a specific event when you know its exact ID. Perfect for loading specific posts or data.
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
<strong>Use cases:</strong> Loading a specific message, fetching referenced content, getting event
|
||||
details
|
||||
</p>
|
||||
<div class="badge badge-outline">Promise<NostrEvent | null></div>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-info pl-4">
|
||||
<h5 class="font-bold">getSingleEventWithFilter(filter)</h5>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
Gets the first event matching your criteria. Useful when you expect only one result or want the most
|
||||
recent.
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
<strong>Use cases:</strong> Getting a user's latest profile, finding the most recent post, checking
|
||||
if something exists
|
||||
</p>
|
||||
<div class="badge badge-outline">Promise<NostrEvent | null></div>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-info pl-4">
|
||||
<h5 class="font-bold">getAllEventsWithFilter(filter)</h5>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
Gets all events matching your criteria. Use this for lists, feeds, or when you need multiple
|
||||
results.
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
<strong>Use cases:</strong> Building feeds, loading message history, getting all posts by a user
|
||||
</p>
|
||||
<div class="badge badge-outline">Promise<NostrEvent[]></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-bold text-lg mb-4 text-accent">🔄 Real-time Subscriptions</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="border-l-4 border-accent pl-4">
|
||||
<h5 class="font-bold">subscribeToEvents(filter)</h5>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
Creates a live stream of events matching your filter. Your app updates automatically when new events
|
||||
arrive.
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
<strong>Use cases:</strong> Live chat, real-time feeds, notifications, collaborative features
|
||||
</p>
|
||||
<div class="badge badge-outline">Observable<NostrEvent></div>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-accent pl-4">
|
||||
<h5 class="font-bold">subscribeToProfile(pubkey)</h5>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
Watches for profile changes for a specific user. Updates automatically when they change their name,
|
||||
bio, avatar, etc.
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
<strong>Use cases:</strong> User profile displays, contact lists, member directories
|
||||
</p>
|
||||
<div class="badge badge-outline">Observable<Profile></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-bold text-lg mb-4 text-warning">👤 User & Profile Helpers</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="border-l-4 border-warning pl-4">
|
||||
<h5 class="font-bold">getProfile(pubkey)</h5>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
Retrieves user profile information (name, bio, avatar, etc.) for any user by their public key.
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
<strong>Use cases:</strong> Displaying user info, building contact lists, showing message authors
|
||||
</p>
|
||||
<div class="badge badge-outline">Promise<Profile | null></div>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-warning pl-4">
|
||||
<h5 class="font-bold">getAvatar(pubkey)</h5>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
Quick helper to get just the avatar URL from a user's profile. Saves you from parsing the full
|
||||
profile.
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
<strong>Use cases:</strong> Profile pictures, user avatars in lists, message author images
|
||||
</p>
|
||||
<div class="badge badge-outline">Promise<string | null></div>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-warning pl-4">
|
||||
<h5 class="font-bold">publicKey</h5>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
Gets the current user's public key. This identifies the user and is needed for many operations.
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
<strong>Use cases:</strong> Identifying the current user, filtering their content, permission checks
|
||||
</p>
|
||||
<div class="badge badge-outline">Promise<string></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<h4 class="font-semibold mb-3 text-lg">Practical Example:</h4>
|
||||
<p class="mb-3 text-sm">
|
||||
Here's how these functions work together in a real Arxlet. This example shows fetching events, displaying
|
||||
user profiles, and handling real-time updates:
|
||||
</p>
|
||||
<CodeBlock language="typescript" code={eveApiExample} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-time Subscriptions */}
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="real-time-subscriptions" class="card-title text-accent mb-4">
|
||||
🔄 Understanding Real-time Subscriptions
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
<strong>What are subscriptions?</strong> Think of subscriptions as "live feeds" that automatically notify
|
||||
your Arxlet when new data arrives. Instead of repeatedly asking "is there new data?", subscriptions push
|
||||
updates to you instantly.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>How they work:</strong> When you subscribe to events or profiles, you get an RxJS Observable - a
|
||||
stream of data that flows over time. Your Arxlet can "listen" to this stream and update the UI whenever
|
||||
new data arrives.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Why use them?</strong> Subscriptions make your Arxlet feel alive and responsive. Users see new
|
||||
messages instantly, profile changes update immediately, and collaborative features work in real-time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6 mb-6">
|
||||
<div class="border-2 border-accent rounded-lg p-4">
|
||||
<h4 class="font-bold text-accent mb-3">🎯 Event Subscriptions</h4>
|
||||
<p class="text-sm mb-3">
|
||||
<code>subscribeToEvents(filter)</code> gives you a live stream of events matching your criteria.
|
||||
</p>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div>
|
||||
<strong>Perfect for:</strong>
|
||||
</div>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Live chat applications</li>
|
||||
<li>Real-time feeds and timelines</li>
|
||||
<li>Notification systems</li>
|
||||
<li>Collaborative tools</li>
|
||||
<li>Activity monitoring</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-2 border-warning rounded-lg p-4">
|
||||
<h4 class="font-bold text-warning mb-3">👤 Profile Subscriptions</h4>
|
||||
<p class="text-sm mb-3">
|
||||
<code>subscribeToProfile(pubkey)</code> watches for changes to a specific user's profile.
|
||||
</p>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div>
|
||||
<strong>Perfect for:</strong>
|
||||
</div>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li>User profile displays</li>
|
||||
<li>Contact lists that stay current</li>
|
||||
<li>Member directories</li>
|
||||
<li>Avatar/name displays</li>
|
||||
<li>User status indicators</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4 class="font-semibold mb-3 text-lg">How to Use Subscriptions:</h4>
|
||||
<p class="mb-3 text-sm">
|
||||
Here's a complete example showing how to set up subscriptions, handle incoming data, and clean up
|
||||
properly:
|
||||
</p>
|
||||
<CodeBlock language="typescript" code={subscriptionExamples} />
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="alert alert-warning">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">! Memory Management</h4>
|
||||
<div class="text-sm space-y-1">
|
||||
<p>
|
||||
Always call <code>unsubscribe()</code> when:
|
||||
</p>
|
||||
<ul class="list-disc list-inside ml-2">
|
||||
<li>Your component unmounts</li>
|
||||
<li>User navigates away</li>
|
||||
<li>You no longer need the data</li>
|
||||
<li>Your Arxlet is closing</li>
|
||||
</ul>
|
||||
<p class="mt-2">
|
||||
<strong>Why?</strong> Prevents memory leaks and unnecessary disk i/o.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">✨ Pro Tips</h4>
|
||||
<div class="text-sm space-y-1">
|
||||
<ul class="list-disc list-inside">
|
||||
<li>Use specific filters to reduce data volume</li>
|
||||
<li>Debounce rapid updates for better UX</li>
|
||||
<li>Cache data to avoid duplicate processing</li>
|
||||
<li>
|
||||
Handle errors gracefully with <code>catchError</code>
|
||||
</li>
|
||||
<li>
|
||||
Consider using <code>takeUntil</code> for automatic cleanup
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WebSocket Alternative */}
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="websocket-alternative" class="card-title text-accent mb-4">
|
||||
🔌 Direct WebSocket Connection - Advanced Usage
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
<strong>What is the WebSocket approach?</strong> Instead of using the convenient <code>window.eve</code>{" "}
|
||||
API, you can connect directly to the Nostr relay at <code>ws://localhost:6942</code> and speak the raw
|
||||
Nostr protocol.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Why would you use this?</strong> Direct WebSocket gives you maximum control and performance. You
|
||||
can implement custom subscription logic, handle multiple concurrent subscriptions efficiently, or
|
||||
integrate with existing Nostr libraries.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>The trade-off:</strong> You'll need to understand the Nostr protocol, handle JSON message parsing,
|
||||
manage connection states, and implement your own error handling. It's more work but gives you complete
|
||||
flexibility.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4 class="font-semibold mb-3 text-lg">WebSocket Implementation Example:</h4>
|
||||
<p class="mb-3 text-sm">
|
||||
Here's how to establish a WebSocket connection and communicate using standard Nostr protocol messages:
|
||||
</p>
|
||||
<CodeBlock language="typescript" code={websocketExample} />
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6 mb-6">
|
||||
<div class="border-2 border-success rounded-lg p-4">
|
||||
<h4 class="font-bold text-success mb-3">✨ Use window.eve When:</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>Building your first Arxlet</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>You want simple, clean code</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>Standard CRUD operations are enough</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>You prefer promise-based APIs</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>Built-in RxJS observables work for you</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
<span>You don't need custom protocol handling</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-2 border-accent rounded-lg p-4">
|
||||
<h4 class="font-bold text-accent mb-3">⚡ Use WebSocket When:</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-accent">⚡</span>
|
||||
<span>You need maximum performance</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-accent">⚡</span>
|
||||
<span>Custom subscription management required</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-accent">⚡</span>
|
||||
<span>Integrating existing Nostr libraries</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-accent">⚡</span>
|
||||
<span>You understand the Nostr protocol</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-accent">⚡</span>
|
||||
<span>Need fine-grained connection control</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-accent">⚡</span>
|
||||
<span>Building high-frequency applications</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">🎯 Choosing the Right Approach</h4>
|
||||
<div class="text-sm space-y-2">
|
||||
<p>
|
||||
<strong>Start with window.eve:</strong> Even if you think you might need WebSocket later, begin with
|
||||
the high-level API. You can always refactor specific parts to use WebSocket once you understand your
|
||||
performance requirements.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Hybrid approach:</strong> Many successful Arxlets use <code>window.eve</code> for most
|
||||
operations and WebSocket only for specific high-performance features like real-time chat or live
|
||||
collaboration.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Migration path:</strong> The data structures are the same, so you can gradually migrate from{" "}
|
||||
<code>window.eve</code>
|
||||
to WebSocket for specific features without rewriting your entire application.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Best Practices */}
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="best-practices" class="card-title text-warning mb-4">
|
||||
💡 Best Practices for Robust Arxlets
|
||||
</h3>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-l-4 border-error pl-4">
|
||||
<h4 class="font-bold text-lg text-error mb-3">🛡 Error Handling & Reliability</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Always use try-catch blocks:</strong>
|
||||
<p>
|
||||
Network requests can fail, relays can be down, or data might be malformed. Wrap all API calls to
|
||||
prevent crashes.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Check for null/undefined returns:</strong>
|
||||
<p>
|
||||
Query methods return <code>null</code> when no data is found. Always check before using the result.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Provide meaningful user feedback:</strong>
|
||||
<p>
|
||||
Show loading states, error messages, and success confirmations. Users should know what's happening.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Implement retry logic for critical operations:</strong>
|
||||
<p>Publishing events or loading essential data should retry on failure with exponential backoff.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-success pl-4">
|
||||
<h4 class="font-bold text-lg text-success mb-3">⚡ Performance & Efficiency</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Use specific, narrow filters:</strong>
|
||||
<p>
|
||||
Instead of fetching all events and filtering in JavaScript, use precise Nostr filters to reduce data
|
||||
transfer.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Cache frequently accessed data:</strong>
|
||||
<p>Profile information, avatars, and static content should be cached to avoid repeated API calls.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Implement pagination for large datasets:</strong>
|
||||
<p>
|
||||
Don't load thousands of events at once. Use <code>limit</code> and <code>until</code> parameters for
|
||||
pagination.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Debounce rapid user actions:</strong>
|
||||
<p>
|
||||
If users can trigger API calls quickly (like typing in search), debounce to avoid overwhelming the
|
||||
relay.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Unsubscribe from observables:</strong>
|
||||
<p>Always clean up subscriptions to prevent memory leaks and unnecessary network traffic.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-info pl-4">
|
||||
<h4 class="font-bold text-lg text-info mb-3">🎯 User Experience</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Show loading states:</strong>
|
||||
<p>Use spinners, skeletons, or progress indicators while data loads. Empty screens feel broken.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Handle empty states gracefully:</strong>
|
||||
<p>When no data is found, show helpful messages or suggestions rather than blank areas.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Implement optimistic updates:</strong>
|
||||
<p>
|
||||
Update the UI immediately when users take actions, then sync with the server. Makes apps feel
|
||||
faster.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Provide offline indicators:</strong>
|
||||
<p>Let users know when they're disconnected or when operations might not work.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-warning pl-4">
|
||||
<h4 class="font-bold text-lg text-warning mb-3">🔒 Security & Privacy</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Validate all user inputs:</strong>
|
||||
<p>Never trust user input. Validate, sanitize, and escape data before using it in events or UI.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Be mindful of public data:</strong>
|
||||
<p>
|
||||
Remember that events are visible to everyone in your CCN by default. Don't accidentally expose
|
||||
private information.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Handle signing errors gracefully:</strong>
|
||||
<p>Users might reject signing requests. Always have fallbacks and clear error messages.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Respect user privacy preferences:</strong>
|
||||
<p>Some users prefer pseudonymous usage. Don't force real names or personal information.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success mt-6">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">🚀 Quick Checklist for Production Arxlets</h4>
|
||||
<div class="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong>Code Quality:</strong>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>All API calls wrapped in try-catch</li>
|
||||
<li>Null checks before using data</li>
|
||||
<li>Subscriptions properly cleaned up</li>
|
||||
<li>Input validation implemented</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong>User Experience:</strong>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Loading states for all async operations</li>
|
||||
<li>Error messages are user-friendly</li>
|
||||
<li>Empty states handled gracefully</li>
|
||||
<li>Performance tested with large datasets</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
628
src/pages/docs/arxlets/components/BestPracticesSection.jsx
Normal file
628
src/pages/docs/arxlets/components/BestPracticesSection.jsx
Normal file
|
@ -0,0 +1,628 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||
import { CodeBlock } from "./CodeBlock.jsx";
|
||||
|
||||
/**
|
||||
* Best Practices Section - Comprehensive development guidelines for Arxlets
|
||||
*/
|
||||
export const BestPracticesSection = () => {
|
||||
useSyntaxHighlighting();
|
||||
|
||||
const errorHandlingExample = `// Always wrap API calls in try-catch blocks
|
||||
async function loadUserData() {
|
||||
try {
|
||||
const events = await window.eve.getAllEventsWithFilter({
|
||||
kinds: [0], // Profile events
|
||||
limit: 10
|
||||
});
|
||||
|
||||
if (events.length === 0) {
|
||||
showEmptyState("No profiles found");
|
||||
return;
|
||||
}
|
||||
|
||||
displayProfiles(events);
|
||||
} catch (error) {
|
||||
console.error("Failed to load profiles:", error);
|
||||
showErrorMessage("Unable to load profiles. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
// Provide user feedback for all states
|
||||
function showErrorMessage(message) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-error';
|
||||
alert.innerHTML = \`<span>\${message}</span>\`;
|
||||
container.appendChild(alert);
|
||||
}`;
|
||||
|
||||
const performanceExample = `// Use specific filters to reduce data transfer
|
||||
const efficientFilter = {
|
||||
kinds: [1], // Only text notes
|
||||
authors: [userPubkey], // Only from specific user
|
||||
since: Math.floor(Date.now() / 1000) - 86400, // Last 24 hours
|
||||
limit: 20 // Reasonable limit
|
||||
};
|
||||
|
||||
// Cache frequently accessed data
|
||||
const profileCache = new Map();
|
||||
|
||||
async function getCachedProfile(pubkey) {
|
||||
if (profileCache.has(pubkey)) {
|
||||
return profileCache.get(pubkey);
|
||||
}
|
||||
|
||||
const profile = await window.eve.getProfile(pubkey);
|
||||
if (profile) {
|
||||
profileCache.set(pubkey, profile);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
// Debounce rapid user actions
|
||||
let searchTimeout;
|
||||
function handleSearchInput(query) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
performSearch(query);
|
||||
}, 300); // Wait 300ms after user stops typing
|
||||
}`;
|
||||
|
||||
const subscriptionExample = `// Proper subscription management
|
||||
let eventSubscription;
|
||||
|
||||
function startListening() {
|
||||
eventSubscription = window.eve.subscribeToEvents({
|
||||
kinds: [1],
|
||||
limit: 50
|
||||
}).subscribe({
|
||||
next: (event) => {
|
||||
addEventToUI(event);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error("Subscription error:", error);
|
||||
showErrorMessage("Lost connection. Reconnecting...");
|
||||
// Implement retry logic
|
||||
setTimeout(startListening, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// CRITICAL: Always clean up subscriptions
|
||||
function cleanup() {
|
||||
if (eventSubscription) {
|
||||
eventSubscription.unsubscribe();
|
||||
eventSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up when Arxlet is closed or user navigates away
|
||||
window.addEventListener('beforeunload', cleanup);`;
|
||||
|
||||
const uiExample = `// Use DaisyUI components for consistency
|
||||
function createLoadingState() {
|
||||
return \`
|
||||
<div class="flex justify-center items-center p-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<span class="ml-4">Loading profiles...</span>
|
||||
</div>
|
||||
\`;
|
||||
}
|
||||
|
||||
function createEmptyState() {
|
||||
return \`
|
||||
<div class="text-center p-8">
|
||||
<div class="text-6xl mb-4">📭</div>
|
||||
<h3 class="text-lg font-semibold mb-2">No messages yet</h3>
|
||||
<p class="text-base-content/70">Be the first to start a conversation!</p>
|
||||
<button class="btn btn-primary mt-4" onclick="openComposer()">
|
||||
Write a message
|
||||
</button>
|
||||
</div>
|
||||
\`;
|
||||
}
|
||||
|
||||
// Implement optimistic updates for better UX
|
||||
async function publishMessage(content) {
|
||||
// Show message immediately (optimistic)
|
||||
const tempId = 'temp-' + Date.now();
|
||||
addMessageToUI({ id: tempId, content, pending: true });
|
||||
|
||||
try {
|
||||
const event = await window.eve.publish({
|
||||
kind: 1,
|
||||
content: content,
|
||||
tags: []
|
||||
});
|
||||
|
||||
// Replace temp message with real one
|
||||
replaceMessageInUI(tempId, event);
|
||||
} catch (error) {
|
||||
// Remove temp message and show error
|
||||
removeMessageFromUI(tempId);
|
||||
showErrorMessage("Failed to send message");
|
||||
}
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div class="space-y-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-4xl font-bold mb-4">✨ Best Practices</h2>
|
||||
<p class="text-xl text-base-content/70 max-w-3xl mx-auto">
|
||||
Master the art of building production-ready Arxlets with these comprehensive development guidelines
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-info mb-4">Building Production-Ready Arxlets</h3>
|
||||
<div class="space-y-4">
|
||||
<p>
|
||||
Creating a great Arxlet goes beyond just making it work - it needs to be reliable, performant, and provide
|
||||
an excellent user experience. These best practices will help you build Arxlets that users love and that
|
||||
work consistently in the CCN environment.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Why these practices matter:</strong> Arxlets run in a shared environment where performance issues
|
||||
can affect other applications, and users expect the same level of polish they get from native apps.
|
||||
Following these guidelines ensures your Arxlet integrates seamlessly with the CCN ecosystem.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="error-handling" class="card-title text-error mb-4">
|
||||
🛡 Error Handling & Reliability
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
<strong>User experience first:</strong> When something goes wrong, users should know what happened and
|
||||
what they can do about it. Silent failures are frustrating and make your app feel broken.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-l-4 border-error pl-4">
|
||||
<h4 class="font-bold text-lg mb-3">Essential Error Handling Patterns</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Wrap all API calls in try-catch blocks:</strong>
|
||||
<p>
|
||||
Every call to <code>window.eve</code> functions can potentially fail. Always handle exceptions.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Check for null/undefined returns:</strong>
|
||||
<p>
|
||||
Query methods return <code>null</code> when no data is found. Verify results before using them.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Provide meaningful user feedback:</strong>
|
||||
<p>Show specific error messages that help users understand what went wrong and how to fix it.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Implement retry logic for critical operations:</strong>
|
||||
<p>
|
||||
Publishing events or loading essential data should retry automatically with exponential backoff.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h4 class="font-semibold mb-3 text-lg">Practical Error Handling Example:</h4>
|
||||
<CodeBlock language="typescript" code={errorHandlingExample} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4 mt-6">
|
||||
<div class="alert alert-error">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">❌ Common Mistakes</h4>
|
||||
<ul class="text-sm list-disc list-inside space-y-1">
|
||||
<li>Not handling API failures</li>
|
||||
<li>Assuming data will always exist</li>
|
||||
<li>Silent failures with no user feedback</li>
|
||||
<li>Generic "Something went wrong" messages</li>
|
||||
<li>No retry mechanisms for critical operations</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">✅ Best Practices</h4>
|
||||
<ul class="text-sm list-disc list-inside space-y-1">
|
||||
<li>Specific, actionable error messages</li>
|
||||
<li>Graceful degradation when features fail</li>
|
||||
<li>Loading states for all async operations</li>
|
||||
<li>Retry buttons for failed operations</li>
|
||||
<li>Offline indicators when appropriate</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="performance" class="card-title text-success mb-4">
|
||||
⚡ Performance & Efficiency
|
||||
</h3>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-l-4 border-success pl-4">
|
||||
<h4 class="font-bold text-lg mb-3">Performance Optimization Strategies</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Use specific, narrow filters:</strong>
|
||||
<p>
|
||||
Instead of fetching all events and filtering in JavaScript, use precise Nostr filters to reduce data
|
||||
transfer.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Implement intelligent caching:</strong>
|
||||
<p>Cache profile information, avatars, and other static content to avoid repeated API calls.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Paginate large datasets:</strong>
|
||||
<p>
|
||||
Don't load thousands of events at once. Use <code>limit</code> and <code>until</code> parameters for
|
||||
pagination.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Debounce rapid user actions:</strong>
|
||||
<p>
|
||||
If users can trigger API calls quickly (like typing in search), debounce to avoid overwhelming the
|
||||
relay.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h4 class="font-semibold mb-3 text-lg">Performance Optimization Example:</h4>
|
||||
<CodeBlock language="typescript" code={performanceExample} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mt-6">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">! Performance Pitfalls to Avoid</h4>
|
||||
<div class="text-sm space-y-2">
|
||||
<p>
|
||||
<strong>Overly broad filters:</strong> Fetching all events and filtering client-side wastes bandwidth.
|
||||
</p>
|
||||
<p>
|
||||
<strong>No pagination:</strong> Loading thousands of items at once can freeze the interface.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Repeated API calls:</strong> Fetching the same profile data multiple times is inefficient.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Unthrottled user input:</strong> Search-as-you-type without debouncing can overwhelm the
|
||||
relay.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="subscriptions" class="card-title text-accent mb-4">
|
||||
🔄 Subscription Management
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
<strong>Subscriptions power real-time features.</strong> They make your Arxlet feel alive by automatically
|
||||
updating when new data arrives. However, they need careful management to prevent memory leaks.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Clean up is critical.</strong> Forgetting to unsubscribe from observables can cause memory leaks,
|
||||
unnecessary disk i/o, and performance degradation over time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-l-4 border-accent pl-4">
|
||||
<h4 class="font-bold text-lg mb-3">Subscription Best Practices</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Always store subscription references:</strong>
|
||||
<p>Keep references to all subscriptions so you can unsubscribe when needed.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Implement proper cleanup:</strong>
|
||||
<p>Unsubscribe when components unmount, users navigate away, or the Arxlet closes.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Use specific filters:</strong>
|
||||
<p>Narrow subscription filters reduce unnecessary data and improve performance.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h4 class="font-semibold mb-3 text-lg">Proper Subscription Management:</h4>
|
||||
<CodeBlock language="typescript" code={subscriptionExample} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-error mt-6">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">🚨 Memory Leak Prevention</h4>
|
||||
<div class="text-sm space-y-2">
|
||||
<p>
|
||||
<strong>Always unsubscribe:</strong> Every <code>subscribe()</code> call must have a corresponding{" "}
|
||||
<code>unsubscribe()</code>.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Clean up on navigation:</strong> Users might navigate away without properly closing your
|
||||
Arxlet.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Handle page refresh:</strong> Use <code>beforeunload</code> event to clean up subscriptions.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Monitor subscription count:</strong> Too many active subscriptions can impact performance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="user-experience" class="card-title text-info mb-4">
|
||||
🎯 User Experience Excellence
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
<strong>Great UX makes the difference.</strong> Users expect responsive, intuitive interfaces that provide
|
||||
clear feedback. Small details like loading states and empty state messages significantly impact user
|
||||
satisfaction.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Consistency with CCN design.</strong> Using DaisyUI components ensures your Arxlet feels
|
||||
integrated with the rest of the platform while saving you development time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-l-4 border-info pl-4">
|
||||
<h4 class="font-bold text-lg mb-3">UX Best Practices</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Show loading states for all async operations:</strong>
|
||||
<p>Users should never see blank screens or wonder if something is happening.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Handle empty states gracefully:</strong>
|
||||
<p>When no data is available, provide helpful messages or suggestions for next steps.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Implement optimistic updates:</strong>
|
||||
<p>Update the UI immediately when users take actions, then sync with the server.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Use consistent DaisyUI components:</strong>
|
||||
<p>Leverage the pre-built component library for consistent styling and behavior.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h4 class="font-semibold mb-3 text-lg">UI/UX Implementation Examples:</h4>
|
||||
<CodeBlock language="typescript" code={uiExample} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4 mt-6">
|
||||
<div class="border-2 border-success rounded-lg p-4">
|
||||
<h4 class="font-bold text-success mb-3">✨ Excellent UX Includes</h4>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>Loading spinners for async operations</li>
|
||||
<li>Helpful empty state messages</li>
|
||||
<li>Immediate feedback for user actions</li>
|
||||
<li>Clear error messages with solutions</li>
|
||||
<li>Consistent visual design</li>
|
||||
<li>Accessible keyboard navigation</li>
|
||||
<li>Responsive layout for different screen sizes</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-2 border-warning rounded-lg p-4">
|
||||
<h4 class="font-bold text-warning mb-3">! UX Anti-patterns</h4>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>Blank screens during loading</li>
|
||||
<li>No feedback for user actions</li>
|
||||
<li>Generic or confusing error messages</li>
|
||||
<li>Inconsistent styling with CCN</li>
|
||||
<li>Broken layouts on mobile devices</li>
|
||||
<li>Inaccessible interface elements</li>
|
||||
<li>Slow or unresponsive interactions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="security" class="card-title text-warning mb-4">
|
||||
🔒 Security & Privacy Considerations
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
<strong>Security is everyone's responsibility.</strong> Even though Arxlets run in a sandboxed
|
||||
environment, you still need to validate inputs, handle user data responsibly, and follow security best
|
||||
practices.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Privacy by design.</strong> Remember that Nostr events are public by default. Be mindful of what
|
||||
data you're storing and how you're handling user information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-l-4 border-warning pl-4">
|
||||
<h4 class="font-bold text-lg mb-3">Security Best Practices</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Validate all user inputs:</strong>
|
||||
<p>Never trust user input. Validate, sanitize, and escape data before using it in events or UI.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Be mindful of public data:</strong>
|
||||
<p>Nostr events are public by default. Don't accidentally expose private information.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Handle signing errors gracefully:</strong>
|
||||
<p>Users might reject signing requests. Always have fallbacks and clear error messages.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Respect user privacy preferences:</strong>
|
||||
<p>Some users prefer pseudonymous usage. Don't force real names or personal information.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Sanitize HTML content:</strong>
|
||||
<p>If displaying user-generated content, sanitize it to prevent XSS attacks.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-error mt-6">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">🚨 Security Checklist</h4>
|
||||
<div class="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong>Input Validation:</strong>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Validate all form inputs</li>
|
||||
<li>Sanitize user-generated content</li>
|
||||
<li>Check data types and ranges</li>
|
||||
<li>Escape HTML when displaying content</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Privacy Protection:</strong>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Don't store sensitive data in events</li>
|
||||
<li>Respect user anonymity preferences</li>
|
||||
<li>Handle signing rejections gracefully</li>
|
||||
<li>Be transparent about data usage</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="production-checklist" class="card-title text-success mb-4">
|
||||
🚀 Production Readiness Checklist
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
Before publishing your Arxlet, run through this comprehensive checklist to ensure it meets production
|
||||
quality standards. A well-tested Arxlet provides a better user experience and reflects positively on the
|
||||
entire CCN ecosystem.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<div class="border-l-4 border-success pl-4">
|
||||
<h4 class="font-bold text-success mb-3">✅ Code Quality</h4>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>All API calls wrapped in try-catch blocks</li>
|
||||
<li>Null/undefined checks before using data</li>
|
||||
<li>Subscriptions properly cleaned up</li>
|
||||
<li>Input validation implemented</li>
|
||||
<li>Error handling with user feedback</li>
|
||||
<li>Performance optimizations applied</li>
|
||||
<li>Code is well-commented and organized</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-info pl-4">
|
||||
<h4 class="font-bold text-info mb-3">🎯 User Experience</h4>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>Loading states for all async operations</li>
|
||||
<li>Error messages are user-friendly</li>
|
||||
<li>Empty states handled gracefully</li>
|
||||
<li>Consistent DaisyUI styling</li>
|
||||
<li>Responsive design for mobile</li>
|
||||
<li>Keyboard navigation works</li>
|
||||
<li>Accessibility features implemented</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="border-l-4 border-warning pl-4">
|
||||
<h4 class="font-bold text-warning mb-3">🔒 Security & Privacy</h4>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>User inputs are validated and sanitized</li>
|
||||
<li>No sensitive data in public events</li>
|
||||
<li>Signing errors handled gracefully</li>
|
||||
<li>Privacy preferences respected</li>
|
||||
<li>HTML content properly escaped</li>
|
||||
<li>No hardcoded secrets or keys</li>
|
||||
<li>Data usage is transparent</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-accent pl-4">
|
||||
<h4 class="font-bold text-accent mb-3">⚡ Performance</h4>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>Efficient Nostr filters used</li>
|
||||
<li>Data caching implemented</li>
|
||||
<li>Pagination for large datasets</li>
|
||||
<li>User actions are debounced</li>
|
||||
<li>Memory leaks prevented</li>
|
||||
<li>Bundle size optimized</li>
|
||||
<li>Performance tested with large datasets</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success mt-6">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">🎉 Ready for Production!</h4>
|
||||
<p class="text-sm">
|
||||
Once you've checked off all these items, your Arxlet is ready to provide an excellent experience for CCN
|
||||
users. Remember that you can always iterate and improve based on user feedback and changing
|
||||
requirements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
53
src/pages/docs/arxlets/components/CodeBlock.jsx
Normal file
53
src/pages/docs/arxlets/components/CodeBlock.jsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
/**
|
||||
* Reusable code block component with syntax highlighting and a copy button.
|
||||
*/
|
||||
export const CodeBlock = ({ language = "javascript", code }) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code.trim());
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="mockup-code relative">
|
||||
<button
|
||||
class="absolute top-2 right-2 btn btn-ghost btn-sm"
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{isCopied ? (
|
||||
<span class="text-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<pre>
|
||||
<code class={`language-${language}`}>{code?.trim()}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
471
src/pages/docs/arxlets/components/DevelopmentSection.jsx
Normal file
471
src/pages/docs/arxlets/components/DevelopmentSection.jsx
Normal file
|
@ -0,0 +1,471 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import buildCommand from "../highlight/build-command.sh" with { type: "text" };
|
||||
import renderFunction from "../highlight/render-function.ts" with { type: "text" };
|
||||
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||
import { CodeBlock } from "./CodeBlock.jsx";
|
||||
|
||||
/**
|
||||
* Development Section - Guide for building Arxlets
|
||||
* Covers APIs, restrictions, and the required render function
|
||||
*/
|
||||
export const DevelopmentSection = () => {
|
||||
useSyntaxHighlighting();
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-3xl font-bold">Development Guide</h2>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="understanding-arxlets" class="card-title text-info mb-4">
|
||||
Understanding the Arxlet Environment
|
||||
</h3>
|
||||
<p class="mb-6">
|
||||
When you build an Arxlet, you're creating a web application that runs inside the CCN. Think of it like
|
||||
building a mini-website that has access to special CCN features and Nostr data. Here's what you have
|
||||
available and what the limitations are:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="nostr-vs-arxlets" class="card-title text-purple-600 mb-4">
|
||||
🔄 Nostr Apps vs Arxlets: What's the Difference?
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
If you're coming from the broader Nostr ecosystem, you might be wondering how Arxlets relate to regular
|
||||
Nostr applications. Here's the key relationship to understand:
|
||||
</p>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="border-2 border-success rounded-lg p-4">
|
||||
<h4 class="font-bold text-success mb-3">✅ Nostr App → Arxlet</h4>
|
||||
<p class="text-sm mb-3">
|
||||
<strong>Most Nostr apps CAN become Arxlets</strong> with some modifications:
|
||||
</p>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
Replace external API calls with <code>window.eve</code> or local relay
|
||||
</li>
|
||||
<li>Adapt the UI to work within a container element</li>
|
||||
<li>Remove routing if it conflicts with CCN navigation</li>
|
||||
<li>
|
||||
Use the provided <code>window.nostr</code> for signing
|
||||
</li>
|
||||
<li>Bundle everything into a single JavaScript file</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-2 border-warning rounded-lg p-4">
|
||||
<h4 class="font-bold text-warning mb-3">! Arxlet → Nostr App</h4>
|
||||
<p class="text-sm mb-3">
|
||||
<strong>Not every Arxlet works as a standalone Nostr app</strong> because:
|
||||
</p>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
May depend on CCN-specific APIs (<code>window.eve</code>)
|
||||
</li>
|
||||
<li>Designed for the sandboxed environment</li>
|
||||
<li>Might rely on CCN member data or community features</li>
|
||||
<li>UI optimized for container-based rendering</li>
|
||||
<li>No independent relay connections</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">💡 Practical Examples:</h4>
|
||||
<div class="text-sm space-y-2">
|
||||
<p>
|
||||
<strong>Easy to Port:</strong> A simple note-taking app, image gallery, or profile viewer can usually
|
||||
be adapted to work as both.
|
||||
</p>
|
||||
<p>
|
||||
<strong>CCN-Specific:</strong> A CCN member directory, community chat, or collaborative workspace
|
||||
might only make sense as an Arxlet.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Hybrid Approach:</strong> Many developers create a core library that works in both
|
||||
environments, then build different interfaces for standalone vs Arxlet deployment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="available-apis" class="card-title text-success mb-4">
|
||||
✅ What You Can Use
|
||||
</h3>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-l-4 border-success pl-4">
|
||||
<h4 class="font-bold text-lg">window.eve - Your CCN Toolkit</h4>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
This is your main interface to the CCN. It provides functions to read and write Nostr events, manage
|
||||
data, and interact with other CCN members.
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
<strong>What it does:</strong> Lets you fetch events, publish new ones, manage user data, and access
|
||||
CCN-specific features like member directories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-success pl-4">
|
||||
<h4 class="font-bold text-lg">window.nostr - Cryptographic Signing (NIP-07)</h4>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
This is the standard Nostr extension API that lets your Arxlet create and sign events using the user's
|
||||
private key.
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
<strong>What it does:</strong> Allows your app to publish events on behalf of the user, like posting
|
||||
messages, creating profiles, or any other Nostr activity that requires authentication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-success pl-4">
|
||||
<h4 class="font-bold text-lg">DaisyUI 5 - Pre-built UI Components</h4>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
A complete CSS framework with beautiful, accessible components already loaded and ready to use.
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
<strong>What it does:</strong> Provides buttons, cards, modals, forms, and dozens of other UI components
|
||||
with consistent styling. No need to write CSS from scratch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-success pl-4">
|
||||
<h4 class="font-bold text-lg">Local Relay Connection</h4>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
Direct WebSocket connection to <code>ws://localhost:6942</code> for real-time Nostr event streaming.
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
<strong>What it does:</strong> Lets you subscribe to live event feeds, get real-time updates, and
|
||||
implement features like live chat or notifications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-success pl-4">
|
||||
<h4 class="font-bold text-lg">CCN Member Events</h4>
|
||||
<p class="text-sm opacity-80 mb-2">Access to events from other members of your current CCN community.</p>
|
||||
<p class="text-sm">
|
||||
<strong>What it does:</strong> Enables community features like member directories, shared content, group
|
||||
discussions, and collaborative tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-success pl-4">
|
||||
<h4 class="font-bold text-lg">Standard Web APIs</h4>
|
||||
<p class="text-sm opacity-80 mb-2">
|
||||
Full access to modern browser APIs like localStorage, fetch (for local requests), DOM manipulation, and
|
||||
more.
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
<strong>What it does:</strong> Everything you'd expect in a web app - store data locally, manipulate the
|
||||
page, handle user interactions, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="security-restrictions" class="card-title text-warning mb-4">
|
||||
🔒 Security & Limitations
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="border-l-4 border-warning pl-4">
|
||||
<h4 class="font-bold">No External Network Access</h4>
|
||||
<p class="text-sm">
|
||||
You can't make HTTP requests to external websites or APIs. All data must come through the CCN. This
|
||||
prevents data leaks and ensures all communication goes through Nostr protocols.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-warning pl-4">
|
||||
<h4 class="font-bold">CCN-Scoped Data</h4>
|
||||
<p class="text-sm">
|
||||
You only have access to events and data from your current CCN community. You can't see events from other
|
||||
CCNs or the broader Nostr network unless they're specifically shared with your community.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-6">
|
||||
<span>
|
||||
💡 <strong>Why These Restrictions?</strong> These limitations ensure your Arxlet is secure, respects user
|
||||
privacy, and works reliably within the CCN ecosystem. They also make your app more predictable and easier
|
||||
to debug.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<span>
|
||||
📚 <strong>Need More Details?</strong> Check the{" "}
|
||||
<button
|
||||
type="button"
|
||||
class="link link-primary font-semibold underline"
|
||||
onClick={() => {
|
||||
// Find and click the API Reference tab
|
||||
const tabs = document.querySelectorAll(".menu > li > a");
|
||||
const apiTab = Array.from(tabs).find((tab) => tab.textContent.trim() === "API Reference");
|
||||
if (apiTab) {
|
||||
apiTab.click();
|
||||
// Scroll to top after tab switch
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
>
|
||||
API Reference
|
||||
</button>{" "}
|
||||
tab for comprehensive documentation of the <code>window.eve</code> API, code examples, and WebSocket usage
|
||||
patterns.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="typescript-development" class="card-title text-info mb-4">
|
||||
🚀 Building Your Arxlet with TypeScript
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
<strong>What is TypeScript?</strong> TypeScript is JavaScript with type checking. It helps catch errors
|
||||
before your code runs and provides better autocomplete in your editor. While you can write Arxlets in
|
||||
plain JavaScript, TypeScript makes development much smoother.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Why use it for Arxlets?</strong> Eve provides TypeScript definitions for all APIs, so you'll get
|
||||
autocomplete for <code>window.eve</code> functions, proper error checking, and better documentation right
|
||||
in your editor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4 class="font-semibold mb-3 text-lg">Building Your Code</h4>
|
||||
<p class="mb-3">
|
||||
Since Arxlets need to be a single JavaScript file, you'll use <strong>Bun</strong> (a fast JavaScript
|
||||
runtime and bundler) to compile your TypeScript code. Here's the command that does everything:
|
||||
</p>
|
||||
<CodeBlock language="bash" code={buildCommand} />
|
||||
|
||||
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mt-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-amber-700">
|
||||
<strong>⚠️ Svelte Exception:</strong> The above build command will NOT work for Svelte projects.
|
||||
Svelte requires specific Vite configuration to compile properly. Instead, use our{" "}
|
||||
<a
|
||||
href="https://git.arx-ccn.com/Arx/arxlets-template"
|
||||
class="link link-primary"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
arxlets-template
|
||||
</a>{" "}
|
||||
and simply run <code class="bg-amber-100 px-1 rounded">bun run build</code>. Your compiled file will
|
||||
be available at <code class="bg-amber-100 px-1 rounded">dist/bundle.js</code> once built.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6 mt-6">
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold text-lg">What Each Build Option Does:</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="border-l-4 border-info pl-3">
|
||||
<code class="font-bold">--minify</code>
|
||||
<p class="text-sm">
|
||||
Removes whitespace and shortens variable names to make your file smaller. Smaller files load faster.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-l-4 border-info pl-3">
|
||||
<code class="font-bold">--target=browser</code>
|
||||
<p class="text-sm">Tells Bun to optimize the code for web browsers instead of server environments.</p>
|
||||
</div>
|
||||
<div class="border-l-4 border-info pl-3">
|
||||
<code class="font-bold">--production</code>
|
||||
<p class="text-sm">
|
||||
Enables all optimizations and removes development-only code for better performance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold text-lg">Why TypeScript Helps:</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="border-l-4 border-success pl-3">
|
||||
<strong>Catch Errors Early</strong>
|
||||
<p class="text-sm">
|
||||
TypeScript finds mistakes like typos in function names or wrong parameter types before you run your
|
||||
code.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-l-4 border-success pl-3">
|
||||
<strong>Better Autocomplete</strong>
|
||||
<p class="text-sm">
|
||||
Your editor will suggest available functions and show you what parameters they expect.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-l-4 border-success pl-3">
|
||||
<strong>Easier Refactoring</strong>
|
||||
<p class="text-sm">
|
||||
When you rename functions or change interfaces, TypeScript helps update all the places that use
|
||||
them.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-l-4 border-success pl-3">
|
||||
<strong>Self-Documenting Code</strong>
|
||||
<p class="text-sm">
|
||||
Type annotations serve as inline documentation, making your code easier to understand later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success mt-6">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">Recommended Development Workflow:</h4>
|
||||
<ol class="list-decimal list-inside space-y-1 text-sm">
|
||||
<li>
|
||||
Create your main file as <kbd class="kbd">index.ts</kbd> (TypeScript)
|
||||
</li>
|
||||
<li>Write your Arxlet code with full TypeScript features</li>
|
||||
<li>
|
||||
Run the build command to create <kbd class="kbd">build.js</kbd>
|
||||
</li>
|
||||
<li>
|
||||
Copy the contents of <kbd class="kbd">build.js</kbd> into your Nostr registration event
|
||||
</li>
|
||||
<li>Repeat steps 2-4 as you develop and test</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="required-export-function" class="card-title mb-4">
|
||||
The Heart of Your Arxlet: The Render Function
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
<strong>What is the render function?</strong> This is the main entry point of your Arxlet - think of it as
|
||||
the <code>main()</code>
|
||||
function in other programming languages. When someone opens your Arxlet, Eve calls this function and gives
|
||||
it a container element where your app should display itself.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>How it works:</strong> The platform creates an empty <code><div></code> element and passes
|
||||
it to your render function. Your job is to fill that container with your app's interface - buttons, forms,
|
||||
content, whatever your Arxlet does.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Why this pattern?</strong> This approach gives you complete control over your app's interface
|
||||
while keeping it isolated from other Arxlets and the main CCN interface.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h4 class="font-semibold mb-3 text-lg">Basic Structure:</h4>
|
||||
<CodeBlock language="typescript" code={renderFunction} />
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<h4 class="font-semibold text-lg">Breaking Down the Example:</h4>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="border-l-4 border-primary pl-4">
|
||||
<code class="font-bold">export function render(container: HTMLElement)</code>
|
||||
<p class="text-sm mt-1">
|
||||
This declares your main function. The <code>container</code> parameter is the DOM element where your
|
||||
app will live. The <code>export</code> keyword makes it available to the CCN.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-primary pl-4">
|
||||
<code class="font-bold">container.innerHTML = '...'</code>
|
||||
<p class="text-sm mt-1">
|
||||
This is the simplest way to add content - just set the HTML directly. For simple Arxlets, this might
|
||||
be all you need.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-primary pl-4">
|
||||
<code class="font-bold">const button = container.querySelector('button')</code>
|
||||
<p class="text-sm mt-1">
|
||||
After adding HTML, you can find elements and attach event listeners to make your app interactive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-primary pl-4">
|
||||
<code class="font-bold">window.eve.getEvents(...)</code>
|
||||
<p class="text-sm mt-1">
|
||||
This shows how to use the CCN API to fetch Nostr events. Most Arxlets will interact with Nostr data in
|
||||
some way.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success mt-6">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">Development Tips:</h4>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>
|
||||
<strong>Start Simple:</strong> Begin with basic HTML and gradually add interactivity
|
||||
</li>
|
||||
<li>
|
||||
<strong>Use Modern JavaScript:</strong> async/await, destructuring, arrow functions - it all works
|
||||
</li>
|
||||
<li>
|
||||
<strong>Leverage DaisyUI:</strong> Use pre-built components instead of writing CSS from scratch
|
||||
</li>
|
||||
<li>
|
||||
<strong>Handle Errors:</strong> Wrap API calls in try/catch blocks for better user experience
|
||||
</li>
|
||||
<li>
|
||||
<strong>Think Reactive:</strong> Update the UI when data changes, don't just set it once
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-4">
|
||||
<span>
|
||||
💡 <strong>Advanced Patterns:</strong> You can use any frontend framework (React, Vue, Svelte) by
|
||||
rendering it into the container, or build complex apps with routing, state management, and real-time
|
||||
updates. The render function is just your starting point!
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
60
src/pages/docs/arxlets/components/ExamplesSection.jsx
Normal file
60
src/pages/docs/arxlets/components/ExamplesSection.jsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import { CounterExample } from "../examples/CounterExample.jsx";
|
||||
import { NostrPublisherExample } from "../examples/NostrPublisherExample.jsx";
|
||||
import { PreactCounterExample } from "../examples/PreactCounterExample.jsx";
|
||||
import { SvelteCounterExample } from "../examples/SvelteCounterExample.jsx";
|
||||
|
||||
const examples = {
|
||||
vanilla: <CounterExample />,
|
||||
preact: <PreactCounterExample />,
|
||||
svelte: <SvelteCounterExample />,
|
||||
nostr: <NostrPublisherExample />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Examples Section - Practical Arxlet implementations
|
||||
* Shows real-world examples with detailed explanations
|
||||
*/
|
||||
export const ExamplesSection = ({ activeExample }) => {
|
||||
const ActiveComponent = examples[activeExample] || <div>Example not found</div>;
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-3xl font-bold">Example Arxlets</h2>
|
||||
|
||||
<div class="bg-info border-l-4 border-info/50 p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3 text-info-content">
|
||||
<p class="text-sm text-info-content">
|
||||
<strong>Framework Freedom:</strong> These examples show basic implementations, but you're not limited to
|
||||
vanilla JavaScript! You can use any framework you prefer - React with JSX, Preact, Vue, Svelte, or any
|
||||
other modern framework. Bun's powerful plugin system supports transpilation and bundling for virtually any
|
||||
JavaScript ecosystem.
|
||||
</p>
|
||||
<p class="text-sm t-2">
|
||||
Check out{" "}
|
||||
<a
|
||||
href="https://bun.com/docs/runtime/plugins"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-blue-900"
|
||||
>
|
||||
Bun's plugin documentation
|
||||
</a>{" "}
|
||||
to see how you can integrate your preferred tools and frameworks.
|
||||
</p>
|
||||
<p class="text-sm mt-2">
|
||||
If you have any questions or run into problems, feel free to reach out to the team @ Arx (builders of Eve)
|
||||
on Nostr: <kbd class="kbd">npub1ven4zk8xxw873876gx8y9g9l9fazkye9qnwnglcptgvfwxmygscqsxddfhif</kbd>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div class="mt-6">{ActiveComponent}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
15
src/pages/docs/arxlets/components/LLMsSection.jsx
Normal file
15
src/pages/docs/arxlets/components/LLMsSection.jsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import contextMd from "../highlight/context.md" with { type: "text" };
|
||||
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||
import { CodeBlock } from "./CodeBlock.jsx";
|
||||
|
||||
export const LLMsSection = () => {
|
||||
useSyntaxHighlighting();
|
||||
return (
|
||||
<section id="llms" className="arxlet-docs-section">
|
||||
<CodeBlock code={contextMd} />
|
||||
</section>
|
||||
);
|
||||
};
|
50
src/pages/docs/arxlets/components/OverviewSection.jsx
Normal file
50
src/pages/docs/arxlets/components/OverviewSection.jsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import typeDefinitions from "../highlight/type-definitions.ts" with { type: "text" };
|
||||
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||
import { CodeBlock } from "./CodeBlock.jsx";
|
||||
|
||||
/**
|
||||
* Overview Section - Introduction to Arxlets
|
||||
* Explains what Arxlets are and their key features
|
||||
*/
|
||||
export const OverviewSection = () => {
|
||||
useSyntaxHighlighting();
|
||||
return (
|
||||
<div class="space-y-8">
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">What are Arxlets?</h2>
|
||||
<p class="text-lg mb-4">
|
||||
Arxlets are secure, sandboxed JavaScript applications that extend Eve's functionality. They run inside Eve
|
||||
and are registered on your CCN for member-only access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<span>
|
||||
<strong>Coming Soon:</strong> WASM support will be added in future releases for even more powerful
|
||||
applications.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-secondary">📝 TypeScript Definitions</h3>
|
||||
<p class="mb-4">Use these type definitions for full TypeScript support in your Arxlets:</p>
|
||||
|
||||
<CodeBlock language="typescript" code={typeDefinitions} />
|
||||
|
||||
<div class="alert alert-info mt-4">
|
||||
<span>
|
||||
<strong>Pro Tip:</strong> Save these types in a <code>types.ts</code> file and import them throughout your
|
||||
Arxlet for better development experience and type safety.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
147
src/pages/docs/arxlets/components/RegistrationSection.jsx
Normal file
147
src/pages/docs/arxlets/components/RegistrationSection.jsx
Normal file
|
@ -0,0 +1,147 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import registrationEvent from "../highlight/registration-event.json" with { type: "text" };
|
||||
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||
import { CodeBlock } from "./CodeBlock.jsx";
|
||||
|
||||
/**
|
||||
* Registration Section - How to register Arxlets on CCN
|
||||
* Covers the Nostr event structure and required fields
|
||||
*/
|
||||
export const RegistrationSection = () => {
|
||||
useSyntaxHighlighting();
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-3xl font-bold">Arxlet Registration</h2>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-info">What are Nostr Events?</h3>
|
||||
<div class="space-y-3">
|
||||
<p>
|
||||
<strong>Nostr</strong> (Notes and Other Stuff Transmitted by Relays) is a decentralized protocol where all
|
||||
data is stored as <strong>events</strong>. Think of events as structured messages that contain information
|
||||
and are cryptographically signed by their authors.
|
||||
</p>
|
||||
<p>Each event has:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||
<li>
|
||||
<strong>Kind:</strong> A number that defines what type of data the event contains (like a category)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Content:</strong> The main data or message
|
||||
</li>
|
||||
<li>
|
||||
<strong>Tags:</strong> Additional metadata organized as key-value pairs
|
||||
</li>
|
||||
<li>
|
||||
<strong>Signature:</strong> Cryptographic proof that the author created this event
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
To register your Arxlet, you create a special event (kind 30420) that tells the CCN about your
|
||||
application. This event acts like a "business card" for your Arxlet, containing its code, name, and other
|
||||
details. If you publish this event publicly outside your CCN, this will be available in the arxlet store,
|
||||
and any CCN will be able to install it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="nostr-event-structure" class="card-title">
|
||||
Nostr Event Structure
|
||||
</h3>
|
||||
<p class="mb-4">
|
||||
Register your Arxlet using a replaceable Nostr event with kind{" "}
|
||||
<code class="badge badge-primary">30420</code>:
|
||||
</p>
|
||||
|
||||
<CodeBlock language="json" code={registrationEvent} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tag-reference" class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">Tag Reference</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th>Required</th>
|
||||
<th>Description</th>
|
||||
<th>Example</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<code>d</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-error">Required</span>
|
||||
</td>
|
||||
<td>Unique identifier (alphanumeric, hyphens, underscores)</td>
|
||||
<td>
|
||||
<code>my-todo-app</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>name</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-error">Required</span>
|
||||
</td>
|
||||
<td>Human-readable display name</td>
|
||||
<td>
|
||||
<code>Todo Manager</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>description</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-warning">Optional</span>
|
||||
</td>
|
||||
<td>Brief description of functionality</td>
|
||||
<td>
|
||||
<code>Manage your tasks</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>script</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-error">Required</span>
|
||||
</td>
|
||||
<td>Complete JavaScript code with render export</td>
|
||||
<td>
|
||||
<code>export function render...</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>icon</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-warning">Optional</span>
|
||||
</td>
|
||||
<td>Iconify icon name and hex color</td>
|
||||
<td>
|
||||
<code>mdi:check-circle, #10b981</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
20
src/pages/docs/arxlets/examples/CounterExample.jsx
Normal file
20
src/pages/docs/arxlets/examples/CounterExample.jsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import { CodeBlock } from "../components/CodeBlock.jsx";
|
||||
import code from "../highlight/counter.ts" with { type: "text" };
|
||||
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||
|
||||
export const CounterExample = () => {
|
||||
useSyntaxHighlighting();
|
||||
return (
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">🔢 Interactive Counter</h3>
|
||||
<p class="mb-4">A simple counter demonstrating state management and event handling:</p>
|
||||
|
||||
<CodeBlock language="typescript" code={code} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
623
src/pages/docs/arxlets/examples/DevelopmentTips.jsx
Normal file
623
src/pages/docs/arxlets/examples/DevelopmentTips.jsx
Normal file
|
@ -0,0 +1,623 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import { CodeBlock } from "../components/CodeBlock.jsx";
|
||||
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||
|
||||
/**
|
||||
* Development Tips Component - Comprehensive best practices for Arxlet development
|
||||
*/
|
||||
export const DevelopmentTips = () => {
|
||||
useSyntaxHighlighting();
|
||||
|
||||
const errorHandlingExample = `// Always wrap API calls in try-catch blocks
|
||||
async function loadUserData() {
|
||||
try {
|
||||
const events = await window.eve.getAllEventsWithFilter({
|
||||
kinds: [0], // Profile events
|
||||
limit: 10
|
||||
});
|
||||
|
||||
if (events.length === 0) {
|
||||
showEmptyState("No profiles found");
|
||||
return;
|
||||
}
|
||||
|
||||
displayProfiles(events);
|
||||
} catch (error) {
|
||||
console.error("Failed to load profiles:", error);
|
||||
showErrorMessage("Unable to load profiles. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
// Provide user feedback for all states
|
||||
function showErrorMessage(message) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-error';
|
||||
alert.innerHTML = \`<span>\${message}</span>\`;
|
||||
container.appendChild(alert);
|
||||
}`;
|
||||
|
||||
const performanceExample = `// Use specific filters to reduce data transfer
|
||||
const efficientFilter = {
|
||||
kinds: [1], // Only text notes
|
||||
authors: [userPubkey], // Only from specific user
|
||||
since: Math.floor(Date.now() / 1000) - 86400, // Last 24 hours
|
||||
limit: 20 // Reasonable limit
|
||||
};
|
||||
|
||||
// Cache frequently accessed data
|
||||
const profileCache = new Map();
|
||||
|
||||
async function getCachedProfile(pubkey) {
|
||||
if (profileCache.has(pubkey)) {
|
||||
return profileCache.get(pubkey);
|
||||
}
|
||||
|
||||
const profile = await window.eve.getProfile(pubkey);
|
||||
if (profile) {
|
||||
profileCache.set(pubkey, profile);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
// Debounce rapid user actions
|
||||
let searchTimeout;
|
||||
function handleSearchInput(query) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
performSearch(query);
|
||||
}, 300); // Wait 300ms after user stops typing
|
||||
}`;
|
||||
|
||||
const subscriptionExample = `// Proper subscription management
|
||||
let eventSubscription;
|
||||
|
||||
function startListening() {
|
||||
eventSubscription = window.eve.subscribeToEvents({
|
||||
kinds: [1],
|
||||
limit: 50
|
||||
}).subscribe({
|
||||
next: (event) => {
|
||||
addEventToUI(event);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error("Subscription error:", error);
|
||||
showErrorMessage("Lost connection. Reconnecting...");
|
||||
// Implement retry logic
|
||||
setTimeout(startListening, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// CRITICAL: Always clean up subscriptions
|
||||
function cleanup() {
|
||||
if (eventSubscription) {
|
||||
eventSubscription.unsubscribe();
|
||||
eventSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up when Arxlet is closed or user navigates away
|
||||
window.addEventListener('beforeunload', cleanup);`;
|
||||
|
||||
const uiExample = `// Use DaisyUI components for consistency
|
||||
function createLoadingState() {
|
||||
return \`
|
||||
<div class="flex justify-center items-center p-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<span class="ml-4">Loading profiles...</span>
|
||||
</div>
|
||||
\`;
|
||||
}
|
||||
|
||||
function createEmptyState() {
|
||||
return \`
|
||||
<div class="text-center p-8">
|
||||
<div class="text-6xl mb-4">📭</div>
|
||||
<h3 class="text-lg font-semibold mb-2">No messages yet</h3>
|
||||
<p class="text-base-content/70">Be the first to start a conversation!</p>
|
||||
<button class="btn btn-primary mt-4" onclick="openComposer()">
|
||||
Write a message
|
||||
</button>
|
||||
</div>
|
||||
\`;
|
||||
}
|
||||
|
||||
// Implement optimistic updates for better UX
|
||||
async function publishMessage(content) {
|
||||
// Show message immediately (optimistic)
|
||||
const tempId = 'temp-' + Date.now();
|
||||
addMessageToUI({ id: tempId, content, pending: true });
|
||||
|
||||
try {
|
||||
const event = await window.eve.publish({
|
||||
kind: 1,
|
||||
content: content,
|
||||
tags: []
|
||||
});
|
||||
|
||||
// Replace temp message with real one
|
||||
replaceMessageInUI(tempId, event);
|
||||
} catch (error) {
|
||||
// Remove temp message and show error
|
||||
removeMessageFromUI(tempId);
|
||||
showErrorMessage("Failed to send message");
|
||||
}
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-3xl font-bold">💡 Development Best Practices</h2>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-info mb-4">Building Production-Ready Arxlets</h3>
|
||||
<div class="space-y-4">
|
||||
<p>
|
||||
Creating a great Arxlet goes beyond just making it work - it needs to be reliable, performant, and provide
|
||||
an excellent user experience. These best practices will help you build Arxlets that users love and that
|
||||
work consistently in the CCN environment.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Why these practices matter:</strong> Arxlets run in a shared environment where performance issues
|
||||
can affect other applications, and users expect the same level of polish they get from native apps.
|
||||
Following these guidelines ensures your Arxlet integrates seamlessly with the CCN ecosystem.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="error-handling" class="card-title text-error mb-4">
|
||||
🛡 Error Handling & Reliability
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
<strong>User experience first:</strong> When something goes wrong, users should know what happened and
|
||||
what they can do about it. Silent failures are frustrating and make your app feel broken.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-l-4 border-error pl-4">
|
||||
<h4 class="font-bold text-lg mb-3">Essential Error Handling Patterns</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Wrap all API calls in try-catch blocks:</strong>
|
||||
<p>
|
||||
Every call to <code>window.eve</code> functions can potentially fail. Always handle exceptions.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Check for null/undefined returns:</strong>
|
||||
<p>
|
||||
Query methods return <code>null</code> when no data is found. Verify results before using them.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Provide meaningful user feedback:</strong>
|
||||
<p>Show specific error messages that help users understand what went wrong and how to fix it.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Implement retry logic for critical operations:</strong>
|
||||
<p>
|
||||
Publishing events or loading essential data should retry automatically with exponential backoff.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h4 class="font-semibold mb-3 text-lg">Practical Error Handling Example:</h4>
|
||||
<CodeBlock language="typescript" code={errorHandlingExample} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4 mt-6">
|
||||
<div class="alert alert-error">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">❌ Common Mistakes</h4>
|
||||
<ul class="text-sm list-disc list-inside space-y-1">
|
||||
<li>Not handling API failures</li>
|
||||
<li>Assuming data will always exist</li>
|
||||
<li>Silent failures with no user feedback</li>
|
||||
<li>Generic "Something went wrong" messages</li>
|
||||
<li>No retry mechanisms for critical operations</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">✅ Best Practices</h4>
|
||||
<ul class="text-sm list-disc list-inside space-y-1">
|
||||
<li>Specific, actionable error messages</li>
|
||||
<li>Graceful degradation when features fail</li>
|
||||
<li>Loading states for all async operations</li>
|
||||
<li>Retry buttons for failed operations</li>
|
||||
<li>Offline indicators when appropriate</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="performance" class="card-title text-success mb-4">
|
||||
⚡ Performance & Efficiency
|
||||
</h3>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-l-4 border-success pl-4">
|
||||
<h4 class="font-bold text-lg mb-3">Performance Optimization Strategies</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Use specific, narrow filters:</strong>
|
||||
<p>
|
||||
Instead of fetching all events and filtering in JavaScript, use precise Nostr filters to reduce data
|
||||
transfer.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Implement intelligent caching:</strong>
|
||||
<p>Cache profile information, avatars, and other static content to avoid repeated API calls.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Paginate large datasets:</strong>
|
||||
<p>
|
||||
Don't load thousands of events at once. Use <code>limit</code> and <code>until</code> parameters for
|
||||
pagination.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Debounce rapid user actions:</strong>
|
||||
<p>
|
||||
If users can trigger API calls quickly (like typing in search), debounce to avoid overwhelming the
|
||||
relay.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h4 class="font-semibold mb-3 text-lg">Performance Optimization Example:</h4>
|
||||
<CodeBlock language="typescript" code={performanceExample} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mt-6">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">! Performance Pitfalls to Avoid</h4>
|
||||
<div class="text-sm space-y-2">
|
||||
<p>
|
||||
<strong>Overly broad filters:</strong> Fetching all events and filtering client-side wastes bandwidth.
|
||||
</p>
|
||||
<p>
|
||||
<strong>No pagination:</strong> Loading thousands of items at once can freeze the interface.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Repeated API calls:</strong> Fetching the same profile data multiple times is inefficient.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Unthrottled user input:</strong> Search-as-you-type without debouncing can overwhelm the
|
||||
relay.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="subscriptions" class="card-title text-accent mb-4">
|
||||
🔄 Subscription Management
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
<strong>Subscriptions power real-time features.</strong> They make your Arxlet feel alive by automatically
|
||||
updating when new data arrives. However, they need careful management to prevent memory leaks.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Clean up is critical.</strong> Forgetting to unsubscribe from observables can cause memory leaks,
|
||||
unnecessary i/o usage, and performance degradation over time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-l-4 border-accent pl-4">
|
||||
<h4 class="font-bold text-lg mb-3">Subscription Best Practices</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Always store subscription references:</strong>
|
||||
<p>Keep references to all subscriptions so you can unsubscribe when needed.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Implement proper cleanup:</strong>
|
||||
<p>Unsubscribe when components unmount, users navigate away, or the Arxlet closes.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Use specific filters:</strong>
|
||||
<p>Narrow subscription filters reduce unnecessary data and improve performance.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h4 class="font-semibold mb-3 text-lg">Proper Subscription Management:</h4>
|
||||
<CodeBlock language="typescript" code={subscriptionExample} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-error mt-6">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">🚨 Memory Leak Prevention</h4>
|
||||
<div class="text-sm space-y-2">
|
||||
<p>
|
||||
<strong>Always unsubscribe:</strong> Every <code>subscribe()</code> call must have a corresponding{" "}
|
||||
<code>unsubscribe()</code>.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Clean up on navigation:</strong> Users might navigate away without properly closing your
|
||||
Arxlet.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Handle page refresh:</strong> Use <code>beforeunload</code> event to clean up subscriptions.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Monitor subscription count:</strong> Too many active subscriptions can impact performance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="user-experience" class="card-title text-info mb-4">
|
||||
🎯 User Experience Excellence
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
<strong>Great UX makes the difference.</strong> Users expect responsive, intuitive interfaces that provide
|
||||
clear feedback. Small details like loading states and empty state messages significantly impact user
|
||||
satisfaction.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Consistency with CCN design.</strong> Using DaisyUI components ensures your Arxlet feels
|
||||
integrated with the rest of the platform while saving you development time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-l-4 border-info pl-4">
|
||||
<h4 class="font-bold text-lg mb-3">UX Best Practices</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Show loading states for all async operations:</strong>
|
||||
<p>Users should never see blank screens or wonder if something is happening.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Handle empty states gracefully:</strong>
|
||||
<p>When no data is available, provide helpful messages or suggestions for next steps.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Implement optimistic updates:</strong>
|
||||
<p>Update the UI immediately when users take actions, then sync with the server.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Use consistent DaisyUI components:</strong>
|
||||
<p>Leverage the pre-built component library for consistent styling and behavior.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h4 class="font-semibold mb-3 text-lg">UI/UX Implementation Examples:</h4>
|
||||
<CodeBlock language="typescript" code={uiExample} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4 mt-6">
|
||||
<div class="border-2 border-success rounded-lg p-4">
|
||||
<h4 class="font-bold text-success mb-3">✨ Excellent UX Includes</h4>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>Loading spinners for async operations</li>
|
||||
<li>Helpful empty state messages</li>
|
||||
<li>Immediate feedback for user actions</li>
|
||||
<li>Clear error messages with solutions</li>
|
||||
<li>Consistent visual design</li>
|
||||
<li>Accessible keyboard navigation</li>
|
||||
<li>Responsive layout for different screen sizes</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-2 border-warning rounded-lg p-4">
|
||||
<h4 class="font-bold text-warning mb-3">! UX Anti-patterns</h4>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>Blank screens during loading</li>
|
||||
<li>No feedback for user actions</li>
|
||||
<li>Generic or confusing error messages</li>
|
||||
<li>Inconsistent styling with CCN</li>
|
||||
<li>Broken layouts on mobile devices</li>
|
||||
<li>Inaccessible interface elements</li>
|
||||
<li>Slow or unresponsive interactions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="security" class="card-title text-warning mb-4">
|
||||
🔒 Security & Privacy Considerations
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
<strong>Security is everyone's responsibility.</strong> Even though Arxlets run in a sandboxed
|
||||
environment, you still need to validate inputs, handle user data responsibly, and follow security best
|
||||
practices.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Privacy by design.</strong> Remember that events are visible to everyone in your CCN by default.
|
||||
Be mindful of what data you're storing and how you're handling user information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-l-4 border-warning pl-4">
|
||||
<h4 class="font-bold text-lg mb-3">Security Best Practices</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>Validate all user inputs:</strong>
|
||||
<p>Never trust user input. Validate, sanitize, and escape data before using it in events or UI.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Be mindful of public data:</strong>
|
||||
<p>Nostr events are public by default. Don't accidentally expose private information.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Handle signing errors gracefully:</strong>
|
||||
<p>Users might reject signing requests. Always have fallbacks and clear error messages.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Respect user privacy preferences:</strong>
|
||||
<p>Some users prefer pseudonymous usage. Don't force real names or personal information.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Sanitize HTML content:</strong>
|
||||
<p>If displaying user-generated content, sanitize it to prevent XSS attacks.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-error mt-6">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">🚨 Security Checklist</h4>
|
||||
<div class="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong>Input Validation:</strong>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Validate all form inputs</li>
|
||||
<li>Sanitize user-generated content</li>
|
||||
<li>Check data types and ranges</li>
|
||||
<li>Escape HTML when displaying content</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Privacy Protection:</strong>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Don't store sensitive data in events</li>
|
||||
<li>Respect user anonymity preferences</li>
|
||||
<li>Handle signing rejections gracefully</li>
|
||||
<li>Be transparent about data usage</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 id="production-checklist" class="card-title text-success mb-4">
|
||||
🚀 Production Readiness Checklist
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<p>
|
||||
Before publishing your Arxlet, run through this comprehensive checklist to ensure it meets production
|
||||
quality standards. A well-tested Arxlet provides a better user experience and reflects positively on the
|
||||
entire CCN ecosystem.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<div class="border-l-4 border-success pl-4">
|
||||
<h4 class="font-bold text-success mb-3">✅ Code Quality</h4>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>All API calls wrapped in try-catch blocks</li>
|
||||
<li>Null/undefined checks before using data</li>
|
||||
<li>Subscriptions properly cleaned up</li>
|
||||
<li>Input validation implemented</li>
|
||||
<li>Error handling with user feedback</li>
|
||||
<li>Performance optimizations applied</li>
|
||||
<li>Code is well-commented and organized</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-info pl-4">
|
||||
<h4 class="font-bold text-info mb-3">🎯 User Experience</h4>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>Loading states for all async operations</li>
|
||||
<li>Error messages are user-friendly</li>
|
||||
<li>Empty states handled gracefully</li>
|
||||
<li>Consistent DaisyUI styling</li>
|
||||
<li>Responsive design for mobile</li>
|
||||
<li>Keyboard navigation works</li>
|
||||
<li>Accessibility features implemented</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="border-l-4 border-warning pl-4">
|
||||
<h4 class="font-bold text-warning mb-3">🔒 Security & Privacy</h4>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>User inputs are validated and sanitized</li>
|
||||
<li>No sensitive data in public events</li>
|
||||
<li>Signing errors handled gracefully</li>
|
||||
<li>Privacy preferences respected</li>
|
||||
<li>HTML content properly escaped</li>
|
||||
<li>No hardcoded secrets or keys</li>
|
||||
<li>Data usage is transparent</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-l-4 border-accent pl-4">
|
||||
<h4 class="font-bold text-accent mb-3">⚡ Performance</h4>
|
||||
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||
<li>Efficient Nostr filters used</li>
|
||||
<li>Data caching implemented</li>
|
||||
<li>Pagination for large datasets</li>
|
||||
<li>User actions are debounced</li>
|
||||
<li>Memory leaks prevented</li>
|
||||
<li>Bundle size optimized</li>
|
||||
<li>Performance tested with large datasets</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success mt-6">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2">🎉 Ready for Production!</h4>
|
||||
<p class="text-sm">
|
||||
Once you've checked off all these items, your Arxlet is ready to provide an excellent experience for CCN
|
||||
users. Remember that you can always iterate and improve based on user feedback and changing
|
||||
requirements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
20
src/pages/docs/arxlets/examples/NostrPublisherExample.jsx
Normal file
20
src/pages/docs/arxlets/examples/NostrPublisherExample.jsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import { CodeBlock } from "../components/CodeBlock.jsx";
|
||||
import code from "../highlight/nostr-publisher.ts" with { type: "text" };
|
||||
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||
|
||||
export const NostrPublisherExample = () => {
|
||||
useSyntaxHighlighting();
|
||||
return (
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">📝 Nostr Note Publisher</h3>
|
||||
<p class="mb-4">Publish notes to your CCN using the window.eve API:</p>
|
||||
|
||||
<CodeBlock language="typescript" code={code} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
20
src/pages/docs/arxlets/examples/PreactCounterExample.jsx
Normal file
20
src/pages/docs/arxlets/examples/PreactCounterExample.jsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import { CodeBlock } from "../components/CodeBlock.jsx";
|
||||
import code from "../highlight/preact-counter.tsx" with { type: "text" };
|
||||
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||
|
||||
export const PreactCounterExample = () => {
|
||||
useSyntaxHighlighting();
|
||||
return (
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">⚛ Preact Counter with JSX</h3>
|
||||
<p class="mb-4">A modern counter using Preact hooks and JSX syntax:</p>
|
||||
|
||||
<CodeBlock language="typescript" code={code} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
55
src/pages/docs/arxlets/examples/SvelteCounterExample.jsx
Normal file
55
src/pages/docs/arxlets/examples/SvelteCounterExample.jsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import { CodeBlock } from "../components/CodeBlock.jsx";
|
||||
import code from "../highlight/svelte-counter.svelte" with { type: "text" };
|
||||
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||
|
||||
export const SvelteCounterExample = () => {
|
||||
useSyntaxHighlighting();
|
||||
return (
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">🔥 Svelte Counter</h3>
|
||||
<p class="mb-4">A reactive counter built with Svelte's elegant syntax and built-in reactivity:</p>
|
||||
|
||||
<div class="bg-green-50 border-l-4 border-green-400 p-4 mb-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-green-700">
|
||||
<strong>Why Svelte?</strong> Svelte compiles to vanilla JavaScript with no runtime overhead, making it
|
||||
perfect for arxlets. Features like runes (<kbd class="kbd">$state()</kbd>,{" "}
|
||||
<kbd class="kbd">$derived()</kbd>, etc), scoped CSS and intuitive event handling make development a
|
||||
breeze.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-amber-700">
|
||||
<strong>Build Setup Note:</strong> Building Svelte for arxlets requires specific Vite configuration to
|
||||
handle the compilation properly. While this adds some initial complexity, we've created a ready-to-use
|
||||
template at{" "}
|
||||
<a
|
||||
href="https://git.arx-ccn.com/Arx/arxlets-template"
|
||||
class="link link-primary"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
arxlets-template
|
||||
</a>{" "}
|
||||
with all the correct configurations. We still highly recommend Svelte because once set up, the
|
||||
development experience is incredibly smooth and optimal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CodeBlock language="svelte" code={code} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
1
src/pages/docs/arxlets/highlight/build-command.sh
Normal file
1
src/pages/docs/arxlets/highlight/build-command.sh
Normal file
|
@ -0,0 +1 @@
|
|||
bun build --minify --outfile=build.js --target=browser --production index.ts
|
718
src/pages/docs/arxlets/highlight/context.md
Normal file
718
src/pages/docs/arxlets/highlight/context.md
Normal file
|
@ -0,0 +1,718 @@
|
|||
# Arxlets API Context
|
||||
|
||||
## Overview
|
||||
|
||||
Arxlets are secure, sandboxed JavaScript applications that extend Eve's functionality. They run in isolated iframes and are registered on your CCN (Closed Community Network) for member-only access. Arxlets provide a powerful way to build custom applications that interact with Nostr events and profiles through Eve.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### What are Arxlets?
|
||||
|
||||
- **Sandboxed Applications**: Run in isolated iframes for security
|
||||
- **JavaScript-based**: Written in TypeScript/JavaScript with wasm support coming in the future
|
||||
- **CCN Integration**: Registered on your Closed Community Network
|
||||
- **Nostr-native**: Built-in access to Nostr protocol operations
|
||||
- **Real-time**: Support for live event subscriptions and updates
|
||||
|
||||
### CCN Local-First Architecture
|
||||
|
||||
CCNs (Closed Community Networks) are designed with a local-first approach that ensures data availability and functionality even when offline:
|
||||
|
||||
- **Local Data Storage**: All Nostr events and profiles are stored locally on your device, providing instant access without network dependencies
|
||||
- **Offline Functionality**: Arxlets can read, display, and interact with locally cached data when disconnected from the internet
|
||||
- **Sync When Connected**: When network connectivity is restored, the CCN automatically synchronizes with remote relays to fetch new events and propagate local changes
|
||||
- **Resilient Operation**: Your applications continue to work seamlessly regardless of network conditions, making CCNs ideal for unreliable connectivity scenarios
|
||||
- **Privacy by Design**: Local-first storage means your data remains on your device, reducing exposure to external services and improving privacy
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Frontend**: TypeScript applications with render functions
|
||||
- **Backend**: Eve relay providing Nostr protocol access
|
||||
- **Communication**: window.eve API or direct WebSocket connections
|
||||
|
||||
## API Reference
|
||||
|
||||
### window.eve API
|
||||
|
||||
The primary interface for Arxlets to interact with Eve's Nostr relay. All methods return promises for async operations.
|
||||
|
||||
#### Event Operations
|
||||
|
||||
```typescript
|
||||
// Publish a Nostr event
|
||||
await window.eve.publish(event: NostrEvent): Promise<void>
|
||||
|
||||
// Get a specific event by ID
|
||||
const event = await window.eve.getSingleEventById(id: string): Promise<NostrEvent | null>
|
||||
|
||||
// Get first event matching filter
|
||||
const event = await window.eve.getSingleEventWithFilter(filter: Filter): Promise<NostrEvent | null>
|
||||
|
||||
// Get all events matching filter
|
||||
const events = await window.eve.getAllEventsWithFilter(filter: Filter): Promise<NostrEvent[]>
|
||||
```
|
||||
|
||||
#### Real-time Subscriptions
|
||||
|
||||
```typescript
|
||||
// Subscribe to events with RxJS Observable
|
||||
const subscription = window.eve.subscribeToEvents(filter: Filter): Observable<NostrEvent>
|
||||
|
||||
// Subscribe to profile updates
|
||||
const profileSub = window.eve.subscribeToProfile(pubkey: string): Observable<Profile>
|
||||
|
||||
// Always unsubscribe when done
|
||||
subscription.unsubscribe()
|
||||
```
|
||||
|
||||
#### Profile Operations
|
||||
|
||||
```typescript
|
||||
// Get user profile
|
||||
const profile = await window.eve.getProfile(pubkey: string): Promise<Profile | null>
|
||||
|
||||
// Get user avatar URL
|
||||
const avatarUrl = await window.eve.getAvatar(pubkey: string): Promise<string | null>
|
||||
```
|
||||
|
||||
#### Cryptographic Operations
|
||||
|
||||
```typescript
|
||||
// Sign an unsigned event
|
||||
const signedEvent = await window.eve.signEvent(event: NostrEvent): Promise<NostrEvent>
|
||||
|
||||
// Get current user's public key
|
||||
const pubkey = await window.eve.publicKey: Promise<string>
|
||||
```
|
||||
|
||||
### WebSocket Alternative
|
||||
|
||||
For advanced use cases, connect directly to Eve's WebSocket relay, or use any nostr library. This is not recommended:
|
||||
|
||||
```typescript
|
||||
const ws = new WebSocket("ws://localhost:6942");
|
||||
|
||||
// Send Nostr protocol messages
|
||||
ws.send(JSON.stringify(["REQ", subscriptionId, filter]));
|
||||
ws.send(JSON.stringify(["EVENT", event]));
|
||||
ws.send(JSON.stringify(["CLOSE", subscriptionId]));
|
||||
```
|
||||
|
||||
## Type Definitions
|
||||
|
||||
```typescript
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
interface NostrEvent {
|
||||
id?: string;
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
sig?: string;
|
||||
}
|
||||
|
||||
interface Filter {
|
||||
ids?: string[];
|
||||
authors?: string[];
|
||||
kinds?: number[];
|
||||
since?: number;
|
||||
until?: number;
|
||||
limit?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
name?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
nip05?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface WindowEve {
|
||||
publish(event: NostrEvent): Promise<void>;
|
||||
getSingleEventById(id: string): Promise<NostrEvent | null>;
|
||||
getSingleEventWithFilter(filter: Filter): Promise<NostrEvent | null>;
|
||||
getAllEventsWithFilter(filter: Filter): Promise<NostrEvent[]>;
|
||||
subscribeToEvents(filter: Filter): Observable<NostrEvent>;
|
||||
subscribeToProfile(pubkey: string): Observable<Profile>;
|
||||
getProfile(pubkey: string): Promise<Profile | null>;
|
||||
getAvatar(pubkey: string): Promise<string | null>;
|
||||
signEvent(event: NostrEvent): Promise<NostrEvent>;
|
||||
get publicKey(): Promise<string>;
|
||||
}
|
||||
|
||||
// Global declarations for TypeScript
|
||||
declare global {
|
||||
interface Window {
|
||||
eve: WindowEve;
|
||||
nostr?: {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: NostrEvent): Promise<NostrEvent>;
|
||||
getRelays?(): Promise<{
|
||||
"ws://localhost:6942": { read: true; write: true };
|
||||
}>;
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Registration
|
||||
|
||||
Arxlets are registered using Nostr events with kind `30420`:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30420,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["d", "unique-arxlet-id"],
|
||||
["name", "Display Name"],
|
||||
["description", "Brief description"],
|
||||
["script", "export function render(container) { /* implementation */ }"],
|
||||
["icon", "mdi:icon-name, #hexcolor"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Required Tags
|
||||
|
||||
- `d`: Unique identifier (alphanumeric, hyphens, underscores)
|
||||
- `name`: Human-readable display name
|
||||
- `script`: Complete JavaScript code with render export function
|
||||
|
||||
### Optional Tags
|
||||
|
||||
- `description`: Brief description of functionality
|
||||
- `icon`: Iconify icon name and hex color
|
||||
|
||||
## Development Patterns
|
||||
|
||||
### Basic Arxlet Structure
|
||||
|
||||
```typescript
|
||||
export function render(container: HTMLElement): void {
|
||||
// Set up your UI
|
||||
container.innerHTML = `
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">My Arxlet</h2>
|
||||
<!-- Your content here -->
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners and logic
|
||||
const button = container.querySelector("#myButton");
|
||||
button?.addEventListener("click", handleClick);
|
||||
}
|
||||
|
||||
async function handleClick() {
|
||||
try {
|
||||
// Use window.eve API
|
||||
const events = await window.eve.getAllEventsWithFilter({
|
||||
kinds: [1],
|
||||
limit: 10,
|
||||
});
|
||||
// Update UI with events
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch events:", error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Real-time Updates
|
||||
|
||||
```typescript
|
||||
export function render(container: HTMLElement): void {
|
||||
let subscription: Subscription;
|
||||
|
||||
// Set up UI
|
||||
container.innerHTML = `<div id="events"></div>`;
|
||||
const eventsContainer = container.querySelector("#events");
|
||||
|
||||
// Subscribe to real-time events
|
||||
subscription = window.eve
|
||||
.subscribeToEvents({
|
||||
kinds: [1],
|
||||
limit: 50,
|
||||
})
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
// Update UI with new event
|
||||
const eventElement = document.createElement("div");
|
||||
eventElement.textContent = event.content;
|
||||
eventsContainer?.prepend(eventElement);
|
||||
},
|
||||
error: (err) => console.error("Subscription error:", err),
|
||||
});
|
||||
|
||||
// Cleanup when arxlet is destroyed
|
||||
window.addEventListener("beforeunload", () => {
|
||||
subscription?.unsubscribe();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Publishing Events
|
||||
|
||||
```typescript
|
||||
async function publishNote(content: string): Promise<void> {
|
||||
try {
|
||||
const unsignedEvent: NostrEvent = {
|
||||
kind: 1,
|
||||
content: content,
|
||||
tags: [["client", "my-arxlet"]],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: await window.eve.publicKey,
|
||||
};
|
||||
|
||||
const signedEvent = await window.eve.signEvent(unsignedEvent);
|
||||
await window.eve.publish(signedEvent);
|
||||
|
||||
console.log("Event published successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to publish event:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Always wrap API calls in try-catch blocks
|
||||
- Check for null returns from query methods
|
||||
- Provide user feedback for failed operations
|
||||
|
||||
### Performance
|
||||
|
||||
- Use specific filters to limit result sets
|
||||
- Cache profile data to avoid repeated lookups
|
||||
- Unsubscribe from observables when done
|
||||
- Debounce rapid API calls
|
||||
- Consider pagination for large datasets
|
||||
|
||||
### Security
|
||||
|
||||
- Validate all user inputs
|
||||
- Sanitize content before displaying
|
||||
- Use proper event signing for authenticity
|
||||
- Follow principle of least privilege
|
||||
|
||||
### Memory Management
|
||||
|
||||
- Always unsubscribe from RxJS observables
|
||||
- Clean up event listeners on component destruction
|
||||
- Avoid memory leaks in long-running subscriptions
|
||||
- Use weak references where appropriate
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Social Feed
|
||||
|
||||
- Subscribe to events from followed users
|
||||
- Display real-time updates
|
||||
- Handle profile information and avatars
|
||||
- Implement engagement features
|
||||
|
||||
### Publishing Tools
|
||||
|
||||
- Create and sign events
|
||||
- Validate content before publishing
|
||||
- Handle publishing errors gracefully
|
||||
- Provide user feedback
|
||||
|
||||
### Data Visualization
|
||||
|
||||
- Query historical events
|
||||
- Process and aggregate data
|
||||
- Create interactive charts and graphs
|
||||
- Real-time data updates
|
||||
|
||||
### Communication Apps
|
||||
|
||||
- Direct messaging interfaces
|
||||
- Group chat functionality
|
||||
- Notification systems
|
||||
- Presence indicators
|
||||
|
||||
## Framework Integration
|
||||
|
||||
Arxlets support various JavaScript frameworks. All frameworks must export a `render` function that accepts a container element:
|
||||
|
||||
### Vanilla JavaScript
|
||||
|
||||
```typescript
|
||||
export function render(container: HTMLElement): void {
|
||||
// Set up your UI with direct DOM manipulation
|
||||
container.innerHTML = `
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">My App</h2>
|
||||
<button id="myButton" class="btn btn-primary">Click me</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners
|
||||
const button = container.querySelector("#myButton");
|
||||
button?.addEventListener("click", () => {
|
||||
console.log("Button clicked!");
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Preact/React
|
||||
|
||||
```typescript
|
||||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
import { render } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
const App = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Counter: {count}</h2>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={() => setCount(count + 1)}
|
||||
>
|
||||
Increment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function render(container: HTMLElement): void {
|
||||
render(<App />, container);
|
||||
}
|
||||
```
|
||||
|
||||
### Svelte
|
||||
|
||||
```typescript
|
||||
import { mount } from "svelte";
|
||||
import "./app.css";
|
||||
import App from "./App.svelte";
|
||||
|
||||
export function render(container: HTMLElement) {
|
||||
return mount(App, {
|
||||
target: container,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Build Process
|
||||
|
||||
All frameworks require bundling into a single JavaScript file:
|
||||
|
||||
```bash
|
||||
# For TypeScript/JavaScript projects
|
||||
bun build index.ts --outfile=build.js --minify --target=browser --production
|
||||
|
||||
# The resulting build.js content goes in your registration event's script tag
|
||||
```
|
||||
|
||||
#### Svelte Build Requirements
|
||||
|
||||
**Important:** The standard build command above will NOT work for Svelte projects. Svelte requires specific Vite configuration to compile properly.
|
||||
|
||||
For Svelte arxlets:
|
||||
|
||||
1. Use the [arxlets-template](https://git.arx-ccn.com/Arx/arxlets-template) which includes the correct Vite configuration
|
||||
2. Run `bun run build` instead of the standard build command
|
||||
3. Your compiled file will be available at `dist/bundle.js`
|
||||
|
||||
While the initial setup is more complex, Svelte provides an excellent development experience once configured, with features like:
|
||||
|
||||
- Built-in reactivity with runes (`$state()`, `$derived()`, etc.)
|
||||
- Scoped CSS
|
||||
- Compile-time optimizations
|
||||
- No runtime overhead
|
||||
|
||||
## Debugging and Development
|
||||
|
||||
### Console Logging
|
||||
|
||||
- Use `console.log()` for debugging
|
||||
- Events and errors are logged to browser console
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Catch and log API errors
|
||||
- Display user-friendly error messages
|
||||
- Implement retry mechanisms for transient failures
|
||||
|
||||
### Testing
|
||||
|
||||
- Test with various event types and filters
|
||||
- Verify subscription cleanup
|
||||
- Test error scenarios and edge cases
|
||||
- Validate event signing and publishing
|
||||
|
||||
## Limitations and Considerations
|
||||
|
||||
### Sandbox Restrictions
|
||||
|
||||
- Limited access to browser APIs
|
||||
- No direct file system access
|
||||
- Restricted network access (only to Eve relay)
|
||||
- No access to parent window context
|
||||
|
||||
### Performance Constraints
|
||||
|
||||
- Iframe overhead for each arxlet
|
||||
- Memory usage for subscriptions
|
||||
- Event processing limitations
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- All events are public on Nostr
|
||||
- Private key management handled by Eve
|
||||
- Content sanitization required
|
||||
- XSS prevention necessary
|
||||
|
||||
## DaisyUI Components
|
||||
|
||||
Arxlets have access to DaisyUI 5, a comprehensive CSS component library. Use these pre-built components for consistent, accessible UI:
|
||||
|
||||
### Essential Components
|
||||
|
||||
```html
|
||||
<!-- Cards for content containers -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Card Title</h2>
|
||||
<p>Card content goes here</p>
|
||||
<div class="card-actions justify-end">
|
||||
<button class="btn btn-primary">Action</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons with various styles -->
|
||||
<button class="btn btn-primary">Primary</button>
|
||||
<button class="btn btn-secondary">Secondary</button>
|
||||
<button class="btn btn-success">Success</button>
|
||||
<button class="btn btn-error">Error</button>
|
||||
<button class="btn btn-ghost">Ghost</button>
|
||||
|
||||
<!-- Form controls -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Input Label</span>
|
||||
</label>
|
||||
<input type="text" class="input input-bordered" placeholder="Enter text" />
|
||||
</div>
|
||||
|
||||
<!-- Alerts for feedback -->
|
||||
<div class="alert alert-success">
|
||||
<span>✅ Success message</span>
|
||||
</div>
|
||||
<div class="alert alert-error">
|
||||
<span>❌ Error message</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading states -->
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<button class="btn btn-primary">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Loading...
|
||||
</button>
|
||||
|
||||
<!-- Modals for dialogs -->
|
||||
<dialog class="modal" id="my-modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Modal Title</h3>
|
||||
<p class="py-4">Modal content</p>
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick="document.getElementById('my-modal').close()">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
```
|
||||
|
||||
### Layout Utilities
|
||||
|
||||
```html
|
||||
<!-- Responsive grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="card">Content 1</div>
|
||||
<div class="card">Content 2</div>
|
||||
<div class="card">Content 3</div>
|
||||
</div>
|
||||
|
||||
<!-- Flexbox utilities -->
|
||||
<div class="flex justify-between items-center">
|
||||
<span>Left content</span>
|
||||
<button class="btn">Right button</button>
|
||||
</div>
|
||||
|
||||
<!-- Spacing -->
|
||||
<div class="p-4 m-2 space-y-4">
|
||||
<!-- p-4 = padding, m-2 = margin, space-y-4 = vertical spacing -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Color System
|
||||
|
||||
```html
|
||||
<!-- Background colors -->
|
||||
<div class="bg-base-100">Default background</div>
|
||||
<div class="bg-base-200">Slightly darker</div>
|
||||
<div class="bg-primary">Primary color</div>
|
||||
<div class="bg-secondary">Secondary color</div>
|
||||
|
||||
<!-- Text colors -->
|
||||
<span class="text-primary">Primary text</span>
|
||||
<span class="text-success">Success text</span>
|
||||
<span class="text-error">Error text</span>
|
||||
<span class="text-base-content">Default text</span>
|
||||
```
|
||||
|
||||
## Complete Example Patterns
|
||||
|
||||
### Simple Counter Arxlet
|
||||
|
||||
```typescript
|
||||
export function render(container: HTMLElement): void {
|
||||
let count = 0;
|
||||
|
||||
function updateUI() {
|
||||
container.innerHTML = `
|
||||
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="card-title justify-center">Counter</h2>
|
||||
<div class="text-6xl font-bold my-4 ${count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"}">
|
||||
${count}
|
||||
</div>
|
||||
<div class="card-actions justify-center gap-4">
|
||||
<button class="btn btn-error" id="decrement">−</button>
|
||||
<button class="btn btn-success" id="increment">+</button>
|
||||
<button class="btn btn-ghost" id="reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Attach event listeners
|
||||
container.querySelector("#increment")?.addEventListener("click", () => {
|
||||
count++;
|
||||
updateUI();
|
||||
});
|
||||
|
||||
container.querySelector("#decrement")?.addEventListener("click", () => {
|
||||
count--;
|
||||
updateUI();
|
||||
});
|
||||
|
||||
container.querySelector("#reset")?.addEventListener("click", () => {
|
||||
count = 0;
|
||||
updateUI();
|
||||
});
|
||||
}
|
||||
|
||||
updateUI();
|
||||
}
|
||||
```
|
||||
|
||||
### Nostr Event Publisher
|
||||
|
||||
```typescript
|
||||
export async function render(container: HTMLElement): Promise<void> {
|
||||
container.innerHTML = `
|
||||
<div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">📝 Publish a Note</h2>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">What's on your mind?</span>
|
||||
<span class="label-text-alt" id="charCount">0/280</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="textarea textarea-bordered h-32"
|
||||
id="noteContent"
|
||||
placeholder="Share your thoughts..."
|
||||
maxlength="280">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="card-actions justify-between items-center">
|
||||
<div id="status" class="flex-1"></div>
|
||||
<button class="btn btn-primary" id="publishBtn" disabled>
|
||||
Publish Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textarea =
|
||||
container.querySelector<HTMLTextAreaElement>("#noteContent")!;
|
||||
const publishBtn = container.querySelector<HTMLButtonElement>("#publishBtn")!;
|
||||
const status = container.querySelector<HTMLDivElement>("#status")!;
|
||||
const charCount = container.querySelector<HTMLSpanElement>("#charCount")!;
|
||||
|
||||
textarea.oninput = () => {
|
||||
const length = textarea.value.length;
|
||||
charCount.textContent = `${length}/280`;
|
||||
publishBtn.disabled = length === 0 || length > 280;
|
||||
};
|
||||
|
||||
publishBtn.onclick = async () => {
|
||||
const content = textarea.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
publishBtn.disabled = true;
|
||||
publishBtn.textContent = "Publishing...";
|
||||
status.innerHTML =
|
||||
'<span class="loading loading-spinner loading-sm"></span>';
|
||||
|
||||
try {
|
||||
const unsignedEvent: NostrEvent = {
|
||||
kind: 1,
|
||||
content: content,
|
||||
tags: [["client", "my-arxlet"]],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: await window.eve.publicKey,
|
||||
};
|
||||
|
||||
const signedEvent = await window.eve.signEvent(unsignedEvent);
|
||||
await window.eve.publish(signedEvent);
|
||||
|
||||
status.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
<span>✅ Note published successfully!</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
textarea.value = "";
|
||||
textarea.oninput?.();
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("Publishing failed:", error);
|
||||
status.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
<span>❌ Failed to publish: ${errorMessage}</span>
|
||||
</div>
|
||||
`;
|
||||
} finally {
|
||||
publishBtn.disabled = false;
|
||||
publishBtn.textContent = "Publish Note";
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
This context provides comprehensive information about the Arxlets API, enabling LLMs to understand and work with the system effectively.
|
44
src/pages/docs/arxlets/highlight/counter.ts
Normal file
44
src/pages/docs/arxlets/highlight/counter.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
export function render(container: HTMLElement) {
|
||||
let count: number = 0;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="card-title justify-center">Counter App</h2>
|
||||
<div class="text-6xl font-bold text-primary my-4" id="display">
|
||||
${count}
|
||||
</div>
|
||||
<div class="card-actions justify-center gap-4">
|
||||
<button class="btn btn-error" id="decrement">−</button>
|
||||
<button class="btn btn-success" id="increment">+</button>
|
||||
<button class="btn btn-ghost" id="reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const display = container.querySelector<HTMLDivElement>("#display")!;
|
||||
const incrementBtn = container.querySelector<HTMLButtonElement>("#increment")!;
|
||||
const decrementBtn = container.querySelector<HTMLButtonElement>("#decrement")!;
|
||||
const resetBtn = container.querySelector<HTMLButtonElement>("#reset")!;
|
||||
|
||||
const updateDisplay = (): void => {
|
||||
display.textContent = count.toString();
|
||||
display.className = `text-6xl font-bold my-4 ${
|
||||
count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"
|
||||
}`;
|
||||
};
|
||||
|
||||
incrementBtn.onclick = (): void => {
|
||||
count++;
|
||||
updateDisplay();
|
||||
};
|
||||
decrementBtn.onclick = (): void => {
|
||||
count--;
|
||||
updateDisplay();
|
||||
};
|
||||
resetBtn.onclick = (): void => {
|
||||
count = 0;
|
||||
updateDisplay();
|
||||
};
|
||||
}
|
55
src/pages/docs/arxlets/highlight/eve-api-example.ts
Normal file
55
src/pages/docs/arxlets/highlight/eve-api-example.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
// Using window.eve API for Nostr operations
|
||||
import type { Filter, NostrEvent } from "./types";
|
||||
|
||||
// Publish a new event
|
||||
const event: NostrEvent = {
|
||||
kind: 1,
|
||||
content: "Hello from my Arxlet!",
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: "your-pubkey-here",
|
||||
};
|
||||
|
||||
await window.eve.publish(event);
|
||||
|
||||
// Get a specific event by ID
|
||||
const eventId = "event-id-here";
|
||||
const event = await window.eve.getSingleEventById(eventId);
|
||||
|
||||
// Query events with a filter
|
||||
const filter: Filter = {
|
||||
kinds: [1],
|
||||
authors: ["pubkey-here"],
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
const singleEvent = await window.eve.getSingleEventWithFilter(filter);
|
||||
const allEvents = await window.eve.getAllEventsWithFilter(filter);
|
||||
|
||||
// Real-time subscription with RxJS Observable
|
||||
const subscription = window.eve.subscribeToEvents(filter).subscribe({
|
||||
next: (event) => {
|
||||
console.log("New event received:", event);
|
||||
// Update your UI with the new event
|
||||
},
|
||||
error: (err) => console.error("Subscription error:", err),
|
||||
complete: () => console.log("Subscription completed"),
|
||||
});
|
||||
|
||||
// Subscribe to profile updates for a specific user
|
||||
const profileSubscription = window.eve.subscribeToProfile(pubkey).subscribe({
|
||||
next: (profile) => {
|
||||
console.log("Profile updated:", profile);
|
||||
// Update your UI with the new profile data
|
||||
},
|
||||
error: (err) => console.error("Profile subscription error:", err),
|
||||
});
|
||||
|
||||
// Don't forget to unsubscribe when done
|
||||
// subscription.unsubscribe();
|
||||
// profileSubscription.unsubscribe();
|
||||
|
||||
// Get user profile and avatar
|
||||
const pubkey = "user-pubkey-here";
|
||||
const profile = await window.eve.getProfile(pubkey);
|
||||
const avatarUrl = await window.eve.getAvatar(pubkey);
|
85
src/pages/docs/arxlets/highlight/nostr-publisher.ts
Normal file
85
src/pages/docs/arxlets/highlight/nostr-publisher.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
import type { NostrEvent } from "./type-definitions.ts";
|
||||
|
||||
export async function render(container: HTMLElement): Promise<void> {
|
||||
container.innerHTML = `
|
||||
<div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">📝 Publish a Note</h2>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">What's on your mind?</span>
|
||||
<span class="label-text-alt" id="charCount">0/280</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="textarea textarea-bordered h-32"
|
||||
id="noteContent"
|
||||
placeholder="Share your thoughts with your CCN..."
|
||||
maxlength="280">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-between items-center">
|
||||
<div id="status" class="flex-1"></div>
|
||||
<button class="btn btn-primary" id="publishBtn" disabled>
|
||||
Publish Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("#noteContent")!;
|
||||
const publishBtn = container.querySelector<HTMLButtonElement>("#publishBtn")!;
|
||||
const status = container.querySelector<HTMLDivElement>("#status")!;
|
||||
const charCount = container.querySelector<HTMLSpanElement>("#charCount")!;
|
||||
|
||||
textarea.oninput = (): void => {
|
||||
const length: number = textarea.value.length;
|
||||
charCount.textContent = `${length}/280`;
|
||||
publishBtn.disabled = length === 0 || length > 280;
|
||||
};
|
||||
|
||||
publishBtn.onclick = async (e): Promise<void> => {
|
||||
const content: string = textarea.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
publishBtn.disabled = true;
|
||||
publishBtn.textContent = "Publishing...";
|
||||
status.innerHTML = '<span class="loading loading-spinner loading-sm"></span>';
|
||||
|
||||
try {
|
||||
const unsignedEvent: NostrEvent = {
|
||||
kind: 1, // Text note
|
||||
content: content,
|
||||
tags: [["client", "arxlet-publisher"]],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: await window.eve.publicKey,
|
||||
};
|
||||
|
||||
const signedEvent: NostrEvent = await window.eve.signEvent(unsignedEvent);
|
||||
|
||||
await window.eve.publish(signedEvent);
|
||||
|
||||
status.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
<span>✅ Note published successfully!</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
textarea.value = "";
|
||||
textarea.oninput?.(e);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("Publishing failed:", error);
|
||||
status.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
<span>❌ Failed to publish: ${errorMessage}</span>
|
||||
</div>
|
||||
`;
|
||||
} finally {
|
||||
publishBtn.disabled = false;
|
||||
publishBtn.textContent = "Publish Note";
|
||||
}
|
||||
};
|
||||
}
|
61
src/pages/docs/arxlets/highlight/preact-counter.tsx
Normal file
61
src/pages/docs/arxlets/highlight/preact-counter.tsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
// @jsx h
|
||||
// @jsxImportSource preact
|
||||
|
||||
import { render as renderPreact } from "preact";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
const CounterApp = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const increment = () => {
|
||||
setCount((prev) => prev + 1);
|
||||
setMessage(`Clicked ${count + 1} times!`);
|
||||
};
|
||||
|
||||
const decrement = () => {
|
||||
setCount((prev) => prev - 1);
|
||||
setMessage(`Count decreased to ${count - 1}`);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setCount(0);
|
||||
setMessage("Counter reset!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="card-title justify-center"> Preact Counter </h2>
|
||||
|
||||
<div
|
||||
class={`text-6xl font-bold my-4 ${count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"}`}
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-center gap-4">
|
||||
<button class="btn btn-error" onClick={decrement}>
|
||||
−
|
||||
</button>
|
||||
<button class="btn btn-success" onClick={increment}>
|
||||
+
|
||||
</button>
|
||||
<button class="btn btn-ghost" onClick={reset}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div class="alert alert-info mt-4">
|
||||
<span>{message} </span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function render(container: HTMLElement): void {
|
||||
renderPreact(<CounterApp />, container);
|
||||
}
|
12
src/pages/docs/arxlets/highlight/registration-event.json
Normal file
12
src/pages/docs/arxlets/highlight/registration-event.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"kind": 30420,
|
||||
"tags": [
|
||||
["d", "my-calculator"],
|
||||
["name", "Simple Calculator"],
|
||||
["description", "A basic calculator for quick math"],
|
||||
["script", "export function render(el) { /* your code */ }"],
|
||||
["icon", "mdi:calculator", "#3b82f6"]
|
||||
],
|
||||
"content": "",
|
||||
"created_at": 1735171200
|
||||
}
|
23
src/pages/docs/arxlets/highlight/render-function.ts
Normal file
23
src/pages/docs/arxlets/highlight/render-function.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Required export function - Entry point for your Arxlet
|
||||
*/
|
||||
export function render(container: HTMLElement): void {
|
||||
// Initialize your application
|
||||
container.innerHTML = `
|
||||
<div class="p-6">
|
||||
<h1 class="text-3xl font-bold mb-4">My Arxlet</h1>
|
||||
<p class="text-lg">Hello from Eve!</p>
|
||||
<button class="btn btn-primary mt-4" id="myButton">
|
||||
Click me!
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners with proper typing
|
||||
const button = container.querySelector<HTMLButtonElement>("#myButton");
|
||||
button?.addEventListener("click", (): void => {
|
||||
alert("Button clicked!");
|
||||
});
|
||||
|
||||
// Your app logic here...
|
||||
}
|
54
src/pages/docs/arxlets/highlight/subscription-examples.ts
Normal file
54
src/pages/docs/arxlets/highlight/subscription-examples.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
// Real-time subscription examples
|
||||
import { filter, map, takeUntil } from "rxjs/operators";
|
||||
|
||||
// Basic subscription
|
||||
const subscription = window.eve
|
||||
.subscribeToEvents({
|
||||
kinds: [1], // Text notes
|
||||
limit: 50,
|
||||
})
|
||||
.subscribe((event) => {
|
||||
console.log("New text note:", event.content);
|
||||
});
|
||||
|
||||
// Advanced filtering with RxJS operators
|
||||
const filteredSubscription = window.eve
|
||||
.subscribeToEvents({
|
||||
kinds: [1, 6, 7], // Notes, reposts, reactions
|
||||
authors: ["pubkey1", "pubkey2"],
|
||||
})
|
||||
.pipe(
|
||||
filter((event) => event.content.includes("#arxlet")), // Only events mentioning arxlets
|
||||
map((event) => ({
|
||||
id: event.id,
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: new Date(event.created_at * 1000),
|
||||
})),
|
||||
)
|
||||
.subscribe({
|
||||
next: (processedEvent) => {
|
||||
// Update your UI
|
||||
updateEventsList(processedEvent);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error("Subscription error:", err);
|
||||
showErrorMessage("Failed to receive real-time updates");
|
||||
},
|
||||
});
|
||||
|
||||
// Profile subscription example
|
||||
const profileSubscription = window.eve.subscribeToProfile("user-pubkey-here").subscribe({
|
||||
next: (profile) => {
|
||||
console.log("Profile updated:", profile);
|
||||
updateUserProfile(profile);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error("Profile subscription error:", err);
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up subscriptions when component unmounts
|
||||
// subscription.unsubscribe();
|
||||
// filteredSubscription.unsubscribe();
|
||||
// profileSubscription.unsubscribe();
|
49
src/pages/docs/arxlets/highlight/svelte-counter.svelte
Normal file
49
src/pages/docs/arxlets/highlight/svelte-counter.svelte
Normal file
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
let count = $state(0);
|
||||
let message = $state("");
|
||||
|
||||
function increment() {
|
||||
count += 1;
|
||||
message = `Clicked ${count} times!`;
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
count -= 1;
|
||||
message = `Count decreased to ${count}`;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
count = 0;
|
||||
message = "Counter reset!";
|
||||
}
|
||||
|
||||
const countColor = $derived(count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary");
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="card-title justify-center">🔥 Svelte Counter</h2>
|
||||
|
||||
<div class="text-6xl font-bold my-4 {countColor}">
|
||||
{count}
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-center gap-4">
|
||||
<button class="btn btn-error" onclick={decrement}> − </button>
|
||||
<button class="btn btn-success" onclick={increment}> + </button>
|
||||
<button class="btn btn-ghost" onclick={reset}> Reset </button>
|
||||
</div>
|
||||
|
||||
{#if message}
|
||||
<div class="alert alert-info mt-4">
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card-title {
|
||||
color: var(--primary);
|
||||
}
|
||||
</style>
|
48
src/pages/docs/arxlets/highlight/type-definitions.ts
Normal file
48
src/pages/docs/arxlets/highlight/type-definitions.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import type { Observable } from "rxjs";
|
||||
|
||||
export interface NostrEvent {
|
||||
id?: string;
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
sig?: string;
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
ids?: string[];
|
||||
authors?: string[];
|
||||
kinds?: number[];
|
||||
since?: number;
|
||||
until?: number;
|
||||
limit?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
name?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
nip05?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface WindowEve {
|
||||
publish(event: NostrEvent): Promise<void>;
|
||||
getSingleEventById(id: string): Promise<NostrEvent | null>;
|
||||
getSingleEventWithFilter(filter: Filter): Promise<NostrEvent | null>;
|
||||
getAllEventsWithFilter(filter: Filter): Promise<NostrEvent[]>;
|
||||
subscribeToEvents(filter: Filter): Observable<NostrEvent>;
|
||||
subscribeToProfile(pubkey: string): Observable<Profile>;
|
||||
getProfile(pubkey: string): Promise<Profile | null>;
|
||||
getAvatar(pubkey: string): Promise<string | null>;
|
||||
signEvent(event: NostrEvent): Promise<NostrEvent>;
|
||||
get publicKey(): Promise<string>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
eve: WindowEve;
|
||||
}
|
||||
}
|
18
src/pages/docs/arxlets/highlight/websocket-example.ts
Normal file
18
src/pages/docs/arxlets/highlight/websocket-example.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
// Alternative: Direct WebSocket connection
|
||||
const ws = new WebSocket("ws://localhost:6942");
|
||||
|
||||
ws.onopen = () => {
|
||||
// Subscribe to events
|
||||
ws.send(JSON.stringify(["REQ", "sub1", { kinds: [1], limit: 10 }]));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const [type, subId, data] = JSON.parse(event.data);
|
||||
if (type === "EVENT") {
|
||||
console.log("Received event:", data);
|
||||
}
|
||||
};
|
||||
|
||||
// Publish an event
|
||||
const signedEvent = await window.nostr.signEvent(unsignedEvent);
|
||||
ws.send(JSON.stringify(["EVENT", signedEvent]));
|
30
src/pages/docs/arxlets/hooks/useSyntaxHighlighting.js
Normal file
30
src/pages/docs/arxlets/hooks/useSyntaxHighlighting.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { useEffect } from "preact/hooks";
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/components/prism-json";
|
||||
import "prismjs/components/prism-javascript";
|
||||
import "prismjs/components/prism-typescript";
|
||||
import "prismjs/components/prism-bash";
|
||||
import "prism-svelte";
|
||||
|
||||
/**
|
||||
* Custom hook for managing syntax highlighting
|
||||
* Handles initialization and tab change events
|
||||
*/
|
||||
export const useSyntaxHighlighting = () => {
|
||||
useEffect(() => {
|
||||
const highlightCode = () => setTimeout(() => Prism.highlightAll(), 100);
|
||||
|
||||
highlightCode();
|
||||
|
||||
const tabInputs = document.querySelectorAll('input[name="arxlet_tabs"]');
|
||||
tabInputs.forEach((input) => {
|
||||
input.addEventListener("change", highlightCode);
|
||||
});
|
||||
|
||||
return () => {
|
||||
tabInputs.forEach((input) => {
|
||||
input.removeEventListener("change", highlightCode);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
};
|
5
src/pages/home/home.css
Normal file
5
src/pages/home/home.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
@import "tailwindcss";
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@plugin "daisyui";
|
285
src/pages/home/home.html
Normal file
285
src/pages/home/home.html
Normal file
|
@ -0,0 +1,285 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Eve - Secure, Decentralized Communities</title>
|
||||
<link rel="stylesheet" href="home.css" />
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-base-200">
|
||||
<div data-theme="cyberpunk">
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 shadow-lg">
|
||||
<div class="flex-1">
|
||||
<a class="btn btn-ghost normal-case text-xl">
|
||||
<img src="/assets/logo.png" alt="Eve Logo" class="w-8 h-8 mr-2" />
|
||||
Eve
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><a href="#features">Features</a></li>
|
||||
<li><a href="#how-it-works">How It Works</a></li>
|
||||
<li><a href="/docs/arxlets">Arxlet Docs</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<div class="hero min-h-screen">
|
||||
<div class="hero-overlay bg-opacity-60"></div>
|
||||
<div class="hero-content text-center text-neutral-content">
|
||||
<div class="max-w-md">
|
||||
<h1 class="mb-5 text-5xl font-bold">Welcome to Eve</h1>
|
||||
<p class="mb-5">
|
||||
Your personal gateway to secure, decentralized communities. Create
|
||||
encrypted <strong>Closed Community Networks (CCNs)</strong> where
|
||||
your messages and data stay truly private.
|
||||
</p>
|
||||
<a href="/docs/arxlets" class="btn btn-primary">Get Started</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Section -->
|
||||
<div id="features" class="container mx-auto px-4 py-16">
|
||||
<h2 class="text-4xl font-bold text-center mb-12">Why Choose Eve?</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<!-- Feature 1: End-to-End Encryption -->
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300 fade-in"
|
||||
>
|
||||
<div class="card-body items-center text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-12 w-12 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="card-title mt-4">End-to-End Encryption</h3>
|
||||
<p>
|
||||
Every message, every file, every interaction is secured with
|
||||
cutting-edge encryption. Only you and your community hold the
|
||||
keys.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300 fade-in"
|
||||
style="animation-delay: 0.1s"
|
||||
>
|
||||
<div class="card-body items-center text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-12 w-12 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12s-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.368a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="card-title mt-4">Decentralized by Design</h3>
|
||||
<p>
|
||||
No central servers, no single point of failure. Your community's
|
||||
data is distributed, resilient, and censorship-resistant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Feature 3: Extensible with Arxlets -->
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300 fade-in"
|
||||
style="animation-delay: 0.2s"
|
||||
>
|
||||
<div class="card-body items-center text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-12 w-12 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4-8-4V7m8 4l8-4"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="card-title mt-4">Extensible with Arxlets</h3>
|
||||
<p>
|
||||
Supercharge your community with powerful mini-apps. From shared
|
||||
calendars to collaborative tools, the possibilities are
|
||||
limitless.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="how-it-works" class="bg-base-100">
|
||||
<div class="container mx-auto px-4 py-16">
|
||||
<h2 class="text-4xl font-bold text-center mb-12">How Eve Works</h2>
|
||||
<ul
|
||||
class="timeline timeline-snap-icon max-md:timeline-compact timeline-vertical"
|
||||
>
|
||||
<li>
|
||||
<div class="timeline-middle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="timeline-start md:text-end mb-10">
|
||||
<time class="font-mono italic">Step 1</time>
|
||||
<div class="text-lg font-black">Create a CCN</div>
|
||||
Generate a unique, encrypted Closed Community Network. This is
|
||||
your private digital space, secured by a key that only you and
|
||||
your members possess.
|
||||
</div>
|
||||
<hr />
|
||||
</li>
|
||||
<li>
|
||||
<hr />
|
||||
<div class="timeline-middle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="timeline-end mb-10">
|
||||
<time class="font-mono italic">Step 2</time>
|
||||
<div class="text-lg font-black">Invite Members</div>
|
||||
Securely share the CCN key with trusted members. Only those with
|
||||
the key can join, ensuring your community remains private and
|
||||
exclusive.
|
||||
</div>
|
||||
<hr />
|
||||
</li>
|
||||
<li>
|
||||
<hr />
|
||||
<div class="timeline-middle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="timeline-start md:text-end mb-10">
|
||||
<time class="font-mono italic">Step 3</time>
|
||||
<div class="text-lg font-black">Communicate & Collaborate</div>
|
||||
Share messages, files, and use Arxlets within your secure
|
||||
environment. Your data is always protected and under your
|
||||
control.
|
||||
</div>
|
||||
<hr />
|
||||
</li>
|
||||
<li>
|
||||
<hr />
|
||||
<div class="timeline-middle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="timeline-end">
|
||||
<time class="font-mono italic">Step 4</time>
|
||||
<div class="text-lg font-black">Extend with Arxlets</div>
|
||||
Browse and install Arxlets to add new features to your CCN.
|
||||
Customize your community's experience with powerful,
|
||||
decentralized applications.
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 py-16">
|
||||
<div class="hero bg-base-200 rounded-box">
|
||||
<div class="hero-content flex-col lg:flex-row">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold">Unleash the Power of Arxlets</h2>
|
||||
<p class="py-6">
|
||||
<strong>Arxlets</strong> are the heart of Eve's extensibility.
|
||||
They are secure, sandboxed applications that run within your
|
||||
CCN, allowing you to add powerful features without compromising
|
||||
privacy. Imagine a decentralized social feed, a collaborative
|
||||
whiteboard, or a secure voting system—all running within your
|
||||
private community.
|
||||
</p>
|
||||
<a href="/docs/arxlets" class="btn btn-primary"
|
||||
>Explore Arxlet Development</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer footer-center p-10 bg-base-200 text-base-content">
|
||||
<aside>
|
||||
<img src="/assets/logo.png" alt="Eve Logo" class="w-16 h-16" />
|
||||
<p class="font-bold">Eve Lite<br />Secure, Decentralized, Yours.</p>
|
||||
<p>Copyright © 2025 - All right reserved</p>
|
||||
</aside>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
100
src/rollingIndex.ts
Normal file
100
src/rollingIndex.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
import { bytesToHex, hexToBytes } from "nostr-tools/utils";
|
||||
|
||||
export const DEFAULT_PERIOD_MINUTES = 8 * 60;
|
||||
|
||||
export class RollingIndex {
|
||||
private static PERIOD_BYTES = 2;
|
||||
private static PERIOD_OFFSET_BYTES = 6;
|
||||
|
||||
static diff(left: Uint8Array, right: Uint8Array): Uint8Array[] {
|
||||
const leftData = this.extract(left);
|
||||
const rightData = this.extract(right);
|
||||
|
||||
if (leftData.periodMinutes !== rightData.periodMinutes)
|
||||
throw new Error(
|
||||
`Period minutes mismatch! Left: ${leftData.periodMinutes}, Right: ${rightData.periodMinutes}.`,
|
||||
);
|
||||
|
||||
const startPeriod = Math.min(leftData.periodNumber, rightData.periodNumber);
|
||||
const endPeriod = Math.max(leftData.periodNumber, rightData.periodNumber);
|
||||
const periodMinutes = leftData.periodMinutes;
|
||||
|
||||
const result: Uint8Array[] = [];
|
||||
|
||||
for (
|
||||
let periodNumber = startPeriod;
|
||||
periodNumber <= endPeriod;
|
||||
periodNumber++
|
||||
) {
|
||||
const buffer = new ArrayBuffer(
|
||||
this.PERIOD_BYTES + this.PERIOD_OFFSET_BYTES,
|
||||
);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
view.setUint16(0, periodMinutes, false);
|
||||
view.setUint32(2, Math.floor(periodNumber / 0x10000), false);
|
||||
view.setUint16(6, periodNumber & 0xffff, false);
|
||||
|
||||
result.push(new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static compare(left: Uint8Array, right: Uint8Array): number {
|
||||
const leftData = this.extract(left);
|
||||
const rightData = this.extract(right);
|
||||
|
||||
if (leftData.periodMinutes < rightData.periodMinutes) return -1;
|
||||
if (leftData.periodMinutes > rightData.periodMinutes) return 1;
|
||||
if (leftData.periodNumber < rightData.periodNumber) return -1;
|
||||
if (leftData.periodNumber > rightData.periodNumber) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static at(
|
||||
nowMs: number,
|
||||
periodMinutes: number = DEFAULT_PERIOD_MINUTES,
|
||||
): Uint8Array {
|
||||
const now = Math.floor(nowMs / 1000);
|
||||
const periodSeconds = periodMinutes * 60;
|
||||
const periodNumber = Math.floor(now / periodSeconds);
|
||||
|
||||
const buffer = new ArrayBuffer(
|
||||
this.PERIOD_BYTES + this.PERIOD_OFFSET_BYTES,
|
||||
);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
view.setUint16(0, periodMinutes, false);
|
||||
view.setUint32(2, Math.floor(periodNumber / 0x10000), false);
|
||||
view.setUint16(6, periodNumber & 0xffff, false);
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
static get(periodMinutes: number = DEFAULT_PERIOD_MINUTES): Uint8Array {
|
||||
return this.at(Date.now(), periodMinutes);
|
||||
}
|
||||
|
||||
static extract(index: Uint8Array): {
|
||||
periodMinutes: number;
|
||||
periodNumber: number;
|
||||
} {
|
||||
const view = new DataView(index.buffer);
|
||||
|
||||
const periodMinutes = view.getUint16(0, false);
|
||||
const periodNumberHigh = view.getUint32(2, false);
|
||||
const periodNumberLow = view.getUint16(6, false);
|
||||
const periodNumber = periodNumberHigh * 0x10000 + periodNumberLow;
|
||||
|
||||
return { periodMinutes, periodNumber };
|
||||
}
|
||||
|
||||
static toHex(index: Uint8Array): string {
|
||||
return bytesToHex(index);
|
||||
}
|
||||
|
||||
static fromHex(hex: string): Uint8Array {
|
||||
return hexToBytes(hex);
|
||||
}
|
||||
}
|
83
src/utils/Uint8Array.ts
Normal file
83
src/utils/Uint8Array.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { bytesToHex, hexToBytes } from "nostr-tools/utils";
|
||||
import { isHex } from "./general";
|
||||
|
||||
export function write_varint(bytes: number[], n: number): number {
|
||||
let len = 0;
|
||||
while (true) {
|
||||
let b = n & 0x7f;
|
||||
n >>= 7;
|
||||
if (n !== 0) b |= 0x80;
|
||||
bytes.push(b);
|
||||
len += 1;
|
||||
if (n === 0) break;
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
export const write_tagged_varint = (
|
||||
bytes: number[],
|
||||
value: number,
|
||||
tagged: boolean,
|
||||
): number => write_varint(bytes, (value << 1) | (tagged ? 1 : 0));
|
||||
|
||||
export function write_string(bytes: number[], s: string) {
|
||||
if (s.length === 0) return write_tagged_varint(bytes, 0, false);
|
||||
|
||||
if (isHex(s)) {
|
||||
const parsed = hexToBytes(s);
|
||||
write_tagged_varint(bytes, parsed.length, true);
|
||||
bytes.push(...parsed);
|
||||
} else {
|
||||
const contentBytes = new TextEncoder().encode(s);
|
||||
write_tagged_varint(bytes, contentBytes.length, false);
|
||||
bytes.push(...contentBytes);
|
||||
}
|
||||
}
|
||||
|
||||
export const read_tagged_varint = (
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
): [number, boolean, number] => {
|
||||
const [value, bytes_read] = read_varint(data, offset);
|
||||
const tagged = (value & 1) === 1;
|
||||
const actual_value = value >> 1;
|
||||
return [actual_value, tagged, bytes_read];
|
||||
};
|
||||
|
||||
export function read_varint(
|
||||
buffer: Uint8Array,
|
||||
offset: number,
|
||||
): [number, number] {
|
||||
let value = 0;
|
||||
let shift = 0;
|
||||
let bytesRead = 0;
|
||||
|
||||
while (offset + bytesRead < buffer.length) {
|
||||
const byte = buffer[offset + bytesRead];
|
||||
if (typeof byte === "undefined") return [value, bytesRead];
|
||||
bytesRead++;
|
||||
|
||||
value |= (byte & 0x7f) << shift;
|
||||
|
||||
if ((byte & 0x80) === 0) break;
|
||||
|
||||
shift += 7;
|
||||
}
|
||||
|
||||
return [value, bytesRead];
|
||||
}
|
||||
|
||||
export function read_string(
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
): [string, number] {
|
||||
const [length, tagged, varint_bytes] = read_tagged_varint(data, offset);
|
||||
offset += varint_bytes;
|
||||
|
||||
if (length === 0) return ["", varint_bytes];
|
||||
|
||||
const stringData = data.slice(offset, offset + length);
|
||||
|
||||
if (tagged) return [bytesToHex(stringData), varint_bytes + length];
|
||||
return [new TextDecoder().decode(stringData), varint_bytes + length];
|
||||
}
|
46
src/utils/color.ts
Normal file
46
src/utils/color.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
export function getColorFromPubkey(pubkey: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < pubkey.length; i++) hash = ((hash << 5) - hash + pubkey.charCodeAt(i)) & 0xffffffff;
|
||||
hash = Math.abs(hash);
|
||||
|
||||
const hue = hash % 360;
|
||||
|
||||
const saturation = hue >= 216 && hue <= 273 ? 0.8 : 0.9;
|
||||
const lightness = hue >= 32 && hue <= 212 ? 0.85 : 0.65;
|
||||
|
||||
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
||||
const huePrime = hue / 60;
|
||||
const secondComponent = chroma * (1 - Math.abs((huePrime % 2) - 1));
|
||||
const lightnessAdjustment = lightness - chroma / 2;
|
||||
|
||||
let [r, g, b] = [0, 0, 0];
|
||||
|
||||
const sector = Math.floor(huePrime);
|
||||
switch (sector) {
|
||||
case 0:
|
||||
[r, g, b] = [chroma, secondComponent, 0];
|
||||
break;
|
||||
case 1:
|
||||
[r, g, b] = [secondComponent, chroma, 0];
|
||||
break;
|
||||
case 2:
|
||||
[r, g, b] = [0, chroma, secondComponent];
|
||||
break;
|
||||
case 3:
|
||||
[r, g, b] = [0, secondComponent, chroma];
|
||||
break;
|
||||
case 4:
|
||||
[r, g, b] = [secondComponent, 0, chroma];
|
||||
break;
|
||||
default:
|
||||
[r, g, b] = [chroma, 0, secondComponent];
|
||||
break;
|
||||
}
|
||||
|
||||
const toHex = (value: number): string =>
|
||||
Math.round((value + lightnessAdjustment) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
|
||||
return `${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
193
src/utils/encryption.ts
Normal file
193
src/utils/encryption.ts
Normal file
|
@ -0,0 +1,193 @@
|
|||
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
|
||||
import { bytesToHex, hexToBytes } from "@noble/ciphers/utils";
|
||||
import { managedNonce } from "@noble/ciphers/webcrypto";
|
||||
import {
|
||||
finalizeEvent,
|
||||
generateSecretKey,
|
||||
getPublicKey,
|
||||
type NostrEvent,
|
||||
nip13,
|
||||
verifyEvent,
|
||||
} from "nostr-tools";
|
||||
import { CURRENT_VERSION, POW_TO_ACCEPT, POW_TO_MINE } from "../consts";
|
||||
import { isHex } from "./general";
|
||||
import {
|
||||
read_string,
|
||||
read_varint,
|
||||
write_string,
|
||||
write_varint,
|
||||
} from "./Uint8Array";
|
||||
|
||||
const secureClear = (data: Uint8Array) => data.fill(0);
|
||||
|
||||
export function serializeEventData(event: NostrEvent): Uint8Array {
|
||||
const bytes: number[] = [];
|
||||
bytes.push(CURRENT_VERSION);
|
||||
const id = hexToBytes(event.id);
|
||||
bytes.push(...id);
|
||||
const pk = hexToBytes(event.pubkey);
|
||||
bytes.push(...pk);
|
||||
const sig = hexToBytes(event.sig);
|
||||
bytes.push(...sig);
|
||||
write_varint(bytes, event.created_at);
|
||||
write_varint(bytes, event.kind);
|
||||
write_string(bytes, event.content);
|
||||
write_varint(bytes, event.tags.length);
|
||||
for (const tag of event.tags) {
|
||||
write_varint(bytes, tag.length);
|
||||
for (const element of tag) write_string(bytes, element);
|
||||
}
|
||||
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
function deserializeEventData(buffer: Uint8Array): NostrEvent {
|
||||
let offset = 0;
|
||||
|
||||
const version = buffer[offset++];
|
||||
if (version !== CURRENT_VERSION)
|
||||
throw new Error(`Unsupported version: ${version}`);
|
||||
|
||||
const id = bytesToHex(buffer.slice(offset, offset + 32));
|
||||
offset += 32;
|
||||
const pubkey = bytesToHex(buffer.slice(offset, offset + 32));
|
||||
offset += 32;
|
||||
const sig = bytesToHex(buffer.slice(offset, offset + 64));
|
||||
offset += 64;
|
||||
const [created_at, created_at_bytes] = read_varint(buffer, offset);
|
||||
offset += created_at_bytes;
|
||||
const [kind, kind_bytes] = read_varint(buffer, offset);
|
||||
offset += kind_bytes;
|
||||
const [content, content_bytes] = read_string(buffer, offset);
|
||||
offset += content_bytes;
|
||||
const [tags_length, tags_length_bytes] = read_varint(buffer, offset);
|
||||
offset += tags_length_bytes;
|
||||
|
||||
const tags: string[][] = [];
|
||||
for (let i = 0; i < tags_length; i++) {
|
||||
const [tag_length, tag_length_bytes] = read_varint(buffer, offset);
|
||||
offset += tag_length_bytes;
|
||||
|
||||
const tag: string[] = [];
|
||||
for (let j = 0; j < tag_length; j++) {
|
||||
const [element, element_bytes] = read_string(buffer, offset);
|
||||
offset += element_bytes;
|
||||
|
||||
tag.push(element);
|
||||
}
|
||||
tags.push(tag);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
pubkey,
|
||||
sig,
|
||||
created_at,
|
||||
kind,
|
||||
content,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a given Uint8Array using the XChaCha20-Poly1305 algorithm.
|
||||
*
|
||||
* @param data - The data to be encrypted as a Uint8Array.
|
||||
* @param key - The encryption key as a Uint8Array.
|
||||
* @returns The encrypted data as a Uint8Array.
|
||||
*
|
||||
* @note The key being cloned is not a mistake in the function. If the key is not a copy, it will be cleared from memory, causing future encryptions and decryptions to use key = 0
|
||||
*/
|
||||
export function encryptUint8Array(
|
||||
data: Uint8Array,
|
||||
key: Uint8Array,
|
||||
): Uint8Array {
|
||||
if (key.length !== 32) throw new Error("Encryption key must be 32 bytes");
|
||||
if (data.length === 0) throw new Error("Cannot encrypt empty data");
|
||||
return managedNonce(xchacha20poly1305)(new Uint8Array(key)).encrypt(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a given Uint8Array using the XChaCha20-Poly1305 algorithm.
|
||||
*
|
||||
* @param data - The data to be decrypted as a Uint8Array.
|
||||
* @param key - The decryption key as a Uint8Array.
|
||||
* @returns The decrypted data as a Uint8Array.
|
||||
*
|
||||
* @note The key being cloned is not a mistake in the function. If the key is not a copy, it will be cleared from memory, causing future encryptions and decryptions to use key = 0
|
||||
*/
|
||||
export function decryptUint8Array(
|
||||
data: Uint8Array,
|
||||
key: Uint8Array,
|
||||
): Uint8Array {
|
||||
if (key.length !== 32) throw new Error("Decryption key must be 32 bytes");
|
||||
if (data.length === 0) throw new Error("Cannot decrypt empty data");
|
||||
return managedNonce(xchacha20poly1305)(new Uint8Array(key)).decrypt(data);
|
||||
}
|
||||
|
||||
export async function createEncryptedEvent(
|
||||
event: NostrEvent,
|
||||
encryptionKey: Uint8Array,
|
||||
): Promise<NostrEvent> {
|
||||
const ccnPubkey = getPublicKey(encryptionKey);
|
||||
let randomKey: Uint8Array | null = null;
|
||||
|
||||
try {
|
||||
const serializedData = serializeEventData(event);
|
||||
|
||||
const encryptedEvent = encryptUint8Array(serializedData, encryptionKey);
|
||||
|
||||
const randomTimeUpTo2DaysInThePast =
|
||||
Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 2 * 86400);
|
||||
|
||||
randomKey = generateSecretKey();
|
||||
const randomKeyPub = getPublicKey(randomKey);
|
||||
|
||||
const mainEvent = nip13.minePow(
|
||||
{
|
||||
kind: 1060,
|
||||
content: bytesToHex(encryptedEvent),
|
||||
created_at: randomTimeUpTo2DaysInThePast,
|
||||
tags: [["p", ccnPubkey]],
|
||||
pubkey: randomKeyPub,
|
||||
},
|
||||
POW_TO_MINE,
|
||||
);
|
||||
|
||||
return finalizeEvent(mainEvent, randomKey);
|
||||
} finally {
|
||||
if (randomKey) secureClear(randomKey);
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptEvent(
|
||||
event: NostrEvent,
|
||||
encryptionKey: Uint8Array,
|
||||
) {
|
||||
let decryptedData: Uint8Array | null = null;
|
||||
|
||||
try {
|
||||
if (!verifyEvent(event)) throw new Error("Operation failed: invalid event");
|
||||
|
||||
if (nip13.getPow(event.id) < POW_TO_ACCEPT)
|
||||
throw new Error("Operation failed: insufficient proof of work");
|
||||
|
||||
if (!isHex(event.content))
|
||||
throw new Error("Operation failed: invalid content encoding");
|
||||
|
||||
if (event.kind !== 1060) throw new Error("Operation failed: invalid kind");
|
||||
|
||||
decryptedData = decryptUint8Array(hexToBytes(event.content), encryptionKey);
|
||||
|
||||
const innerEvent = deserializeEventData(decryptedData);
|
||||
|
||||
console.log(innerEvent);
|
||||
|
||||
if (!verifyEvent(innerEvent))
|
||||
throw new Error("Operation failed: invalid inner event");
|
||||
|
||||
return innerEvent;
|
||||
} finally {
|
||||
if (decryptedData) secureClear(decryptedData);
|
||||
}
|
||||
}
|
29
src/utils/files.ts
Normal file
29
src/utils/files.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import { CCN } from "../ccns";
|
||||
|
||||
export function getDataDir() {
|
||||
if (Bun.env.NODE_ENV === "production") {
|
||||
const baseDir = "/var/lib/eve-lite";
|
||||
mkdirSync(baseDir, { recursive: true });
|
||||
return baseDir;
|
||||
}
|
||||
let home = Bun.env.XDG_CONFIG_HOME;
|
||||
if (!home) home = join(Bun.env.HOME!, ".config");
|
||||
const configDir = join(home, "arx", "eve-lite");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
return configDir;
|
||||
}
|
||||
|
||||
export async function loadSeenEvents() {
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) throw "No CCN";
|
||||
return ccn.loadSeenEvents();
|
||||
}
|
||||
|
||||
export async function saveSeenEvent(event: NostrEvent) {
|
||||
const ccn = await CCN.getActive();
|
||||
if (!ccn) throw "No CCN";
|
||||
return ccn.saveSeenEvent(event);
|
||||
}
|
98
src/utils/general.ts
Normal file
98
src/utils/general.ts
Normal file
|
@ -0,0 +1,98 @@
|
|||
import { type Filter, type NostrEvent, SimplePool } from "nostr-tools";
|
||||
import type { SubCloser } from "nostr-tools/abstract-pool";
|
||||
|
||||
export const isHex = (hex: string) =>
|
||||
/^[0-9a-fA-F]+$/.test(hex) && hex.length % 2 === 0;
|
||||
|
||||
export const pool = new SimplePool();
|
||||
|
||||
export const relays = [
|
||||
"wss://relay.arx-ccn.com/",
|
||||
"wss://nos.lol/",
|
||||
"wss://nostr.einundzwanzig.space/",
|
||||
"wss://nostr.massmux.com/",
|
||||
"wss://nostr.mom/",
|
||||
"wss://purplerelay.com/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.goodmorningbitcoin.com/",
|
||||
"wss://relay.lexingtonbitcoin.org/",
|
||||
"wss://relay.nostr.band/",
|
||||
"wss://relay.snort.social/",
|
||||
"wss://strfry.iris.to/",
|
||||
"wss://cache2.primal.net/v1",
|
||||
];
|
||||
|
||||
export const queryRemoteRelays = (
|
||||
filer: Filter,
|
||||
callback: (event: NostrEvent) => void,
|
||||
): SubCloser =>
|
||||
pool.subscribe(relays, filer, {
|
||||
onevent: callback,
|
||||
});
|
||||
|
||||
export const queryRemoteRelaysSync = (filter: Filter): Promise<NostrEvent[]> =>
|
||||
pool.querySync(relays, filter);
|
||||
|
||||
export const queryRemoteEvent = (id: string): Promise<NostrEvent | null> =>
|
||||
pool.get(relays, {
|
||||
ids: [id],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export async function sendEncryptedEventToRelays(
|
||||
event: NostrEvent,
|
||||
): Promise<string> {
|
||||
if (event.kind !== 1059 && event.kind !== 1060)
|
||||
throw new Error("Event is not an eve encrypted event");
|
||||
|
||||
const pool = new SimplePool();
|
||||
|
||||
return Promise.any(pool.publish(relays, event));
|
||||
}
|
||||
|
||||
export async function sendUnencryptedEventToLocalRelay(
|
||||
event: NostrEvent,
|
||||
): Promise<string> {
|
||||
const pool = new SimplePool();
|
||||
return Promise.any(pool.publish(["ws://localhost:6942"], event));
|
||||
}
|
||||
|
||||
export function splitIntoParts(str: string, partsCount: number): string[] {
|
||||
let remainder = str.length % partsCount;
|
||||
const partSize = (str.length - remainder) / partsCount;
|
||||
const parts: string[] = new Array(partsCount).fill("");
|
||||
let currentPart = 0;
|
||||
for (let i = 0; i < str.length; ) {
|
||||
let end = i + partSize;
|
||||
if (remainder) {
|
||||
end++;
|
||||
remainder--;
|
||||
}
|
||||
parts[currentPart++] = str.slice(i, end);
|
||||
i = end;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function getSvgGroup(svg: string, transform: string): string {
|
||||
const gOpen = svg.match(/<g(\s[^>]*)?>/);
|
||||
if (!gOpen) throw new Error("Malformed SVG");
|
||||
|
||||
let depth = 1;
|
||||
let i = gOpen.index! + gOpen[0].length;
|
||||
while (depth && i < svg.length) {
|
||||
const open = svg.indexOf("<g", i);
|
||||
const close = svg.indexOf("</g>", i);
|
||||
if (close === -1) throw new Error("Malformed SVG");
|
||||
if (open !== -1 && open < close) {
|
||||
depth++;
|
||||
i = open + 2;
|
||||
} else {
|
||||
depth--;
|
||||
i = close + 4;
|
||||
if (!depth)
|
||||
return `<g${gOpen[1]?.replace(/(mask|transform|stroke)="[^"]*"/g, "").trim()} transform="${transform}" stroke="black" stroke-width="1.5">${svg.slice(gOpen.index! + gOpen[0].length, close)}</g>`;
|
||||
}
|
||||
}
|
||||
throw new Error("Malformed SVG");
|
||||
}
|
119
src/validation/index.ts
Normal file
119
src/validation/index.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
import { hexToBytes } from "@noble/ciphers/utils";
|
||||
import { getPublicKey } from "nostr-tools";
|
||||
import { isHex } from "../utils/general";
|
||||
|
||||
export function validatePrivateKey(privateKey: string) {
|
||||
if (!privateKey)
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Private key is required",
|
||||
};
|
||||
|
||||
if (!isHex(privateKey))
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Private key must be a valid hexadecimal string",
|
||||
};
|
||||
|
||||
if (privateKey.length !== 64)
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Private key must be exactly 32 bytes (64 hex characters)",
|
||||
};
|
||||
|
||||
try {
|
||||
const keyBytes = hexToBytes(privateKey);
|
||||
getPublicKey(keyBytes);
|
||||
return { isValid: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Invalid private key format",
|
||||
details: error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCommunityName(name: string) {
|
||||
if (typeof name !== "string")
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Community name is required and must be a string",
|
||||
};
|
||||
|
||||
if (!name || name.trim().length === 0)
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Community name cannot be empty",
|
||||
};
|
||||
|
||||
if (name.length > 100)
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Community name must be 100 characters or less",
|
||||
};
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function validateCommunityDescription(description: string) {
|
||||
if (typeof description !== "string")
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Community description must be a string",
|
||||
};
|
||||
|
||||
if (description.length > 500)
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Community description must be 500 characters or less",
|
||||
};
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function validateRelayUrl(url: string) {
|
||||
if (!url || typeof url !== "string")
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Relay URL is required and must be a string",
|
||||
};
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (!["ws:", "wss:"].includes(parsedUrl.protocol))
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Relay URL must use ws:// or wss:// protocol",
|
||||
};
|
||||
return { isValid: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Invalid URL format",
|
||||
details: error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePowDifficulty(difficulty: number) {
|
||||
if (typeof difficulty !== "number")
|
||||
return {
|
||||
isValid: false,
|
||||
error: "PoW difficulty must be a number",
|
||||
};
|
||||
|
||||
if (!Number.isInteger(difficulty))
|
||||
return {
|
||||
isValid: false,
|
||||
error: "PoW difficulty must be an integer",
|
||||
};
|
||||
|
||||
if (difficulty < 0 || difficulty > 64)
|
||||
return {
|
||||
isValid: false,
|
||||
error: "PoW difficulty must be between 0 and 64",
|
||||
};
|
||||
|
||||
return { isValid: true };
|
||||
}
|
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue