Compare commits
66 Commits
29d49c7b26
...
1db8341fb2
| Author | SHA1 | Date |
|---|---|---|
|
|
1db8341fb2 | |
|
|
75b148e772 | |
|
|
5613370817 | |
|
|
8895c0fb75 | |
|
|
3ea787ed82 | |
|
|
bb6643cf70 | |
|
|
abbfb552cc | |
|
|
d9bd7557fa | |
|
|
08928a9f8e | |
|
|
58af5a304c | |
|
|
bf1d126ee5 | |
|
|
db078d3152 | |
|
|
e55e56bc06 | |
|
|
9db9c89bed | |
|
|
e40db06407 | |
|
|
01fa8b1ba5 | |
|
|
7e5a8624d7 | |
|
|
f1e90924c0 | |
|
|
f9a36b9d3e | |
|
|
5b7ee71eb9 | |
|
|
c20a79078a | |
|
|
c3deb18df8 | |
|
|
c59b7d85a1 | |
|
|
32ed06d43a | |
|
|
990704a57d | |
|
|
186a1695c1 | |
|
|
4db56c1b81 | |
|
|
c4ebecf5df | |
|
|
6f4befdd19 | |
|
|
c00379eee6 | |
|
|
54851861c0 | |
|
|
7108bcb6ad | |
|
|
d64bedf647 | |
|
|
6f80f7abeb | |
|
|
8a29c7bf8d | |
|
|
f03d92d9b3 | |
|
|
1165a7fd5a | |
|
|
0ca41b1734 | |
|
|
1cc18a06e5 | |
|
|
cce4e8d357 | |
|
|
7ae105208e | |
|
|
1d0c0013fd | |
|
|
0f5b747a30 | |
|
|
ad5cfe4385 | |
|
|
18e19ddac8 | |
|
|
cede058637 | |
|
|
2ef68e7217 | |
|
|
76f7da8adf | |
|
|
59f2be356b | |
|
|
4e8dd9e70b | |
|
|
c8b1908b20 | |
|
|
e2baf71361 | |
|
|
28fda77b0b | |
|
|
a0188195d7 | |
|
|
a444cb804e | |
|
|
88899684ae | |
|
|
be995b33ff | |
|
|
db8fc6243e | |
|
|
ee46f78ac1 | |
|
|
31c93920a6 | |
|
|
29df97ccc8 | |
|
|
d9c374f784 | |
|
|
54ead05224 | |
|
|
15cdadfbf3 | |
|
|
53b3cb5b30 | |
|
|
ba8a87727e |
|
|
@ -6,7 +6,7 @@ dist/
|
|||
|
||||
# Data storage
|
||||
data/
|
||||
!modules/data/
|
||||
!modules/rdata/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
|
|
|||
465
bun.lock
465
bun.lock
|
|
@ -6,15 +6,22 @@
|
|||
"name": "rspace-online",
|
||||
"dependencies": {
|
||||
"@automerge/automerge": "^2.2.8",
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@encryptid/sdk": "file:../encryptid-sdk",
|
||||
"@lit/reactive-element": "^2.0.4",
|
||||
"@x402/core": "^2.3.1",
|
||||
"@x402/evm": "^2.3.1",
|
||||
"hono": "^4.11.7",
|
||||
"imapflow": "^1.0.170",
|
||||
"mailparser": "^3.7.2",
|
||||
"nodemailer": "^6.9.0",
|
||||
"perfect-arrows": "^0.3.7",
|
||||
"perfect-freehand": "^1.2.2",
|
||||
"postgres": "^3.4.5",
|
||||
"sharp": "^0.33.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mailparser": "^3.4.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"bun-types": "^1.1.38",
|
||||
|
|
@ -26,8 +33,92 @@
|
|||
},
|
||||
},
|
||||
"packages": {
|
||||
"@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="],
|
||||
|
||||
"@automerge/automerge": ["@automerge/automerge@2.2.9", "", { "dependencies": { "uuid": "^9.0.0" } }, "sha512-6HM52Ops79hAQBWMg/t0MNfGOdEiXyenQjO9F1hKZq0RWDsMLpPa1SzRy/C4/4UyX67sTHuA5CwBpH34SpfZlA=="],
|
||||
|
||||
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||
|
||||
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
|
||||
|
||||
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
|
||||
|
||||
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||
|
||||
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
||||
|
||||
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
|
||||
|
||||
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1000.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.15", "@aws-sdk/credential-provider-node": "^3.972.14", "@aws-sdk/middleware-bucket-endpoint": "^3.972.6", "@aws-sdk/middleware-expect-continue": "^3.972.6", "@aws-sdk/middleware-flexible-checksums": "^3.973.1", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-location-constraint": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-sdk-s3": "^3.972.15", "@aws-sdk/middleware-ssec": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.15", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/signature-v4-multi-region": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.0", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.6", "@smithy/eventstream-serde-browser": "^4.2.10", "@smithy/eventstream-serde-config-resolver": "^4.3.10", "@smithy/eventstream-serde-node": "^4.2.10", "@smithy/fetch-http-handler": "^5.3.11", "@smithy/hash-blob-browser": "^4.2.11", "@smithy/hash-node": "^4.2.10", "@smithy/hash-stream-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/md5-js": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/middleware-retry": "^4.4.37", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.12", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.36", "@smithy/util-defaults-mode-node": "^4.2.39", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-stream": "^4.5.15", "@smithy/util-utf8": "^4.2.1", "@smithy/util-waiter": "^4.2.10", "tslib": "^2.6.2" } }, "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow=="],
|
||||
|
||||
"@aws-sdk/core": ["@aws-sdk/core@3.973.15", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws-sdk/xml-builder": "^3.972.8", "@smithy/core": "^3.23.6", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-AlC0oQ1/mdJ8vCIqu524j5RB7M8i8E24bbkZmya1CuiQxkY7SdIZAyw7NDNMGaNINQFq/8oGRMX0HeOfCVsl/A=="],
|
||||
|
||||
"@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.3", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-UExeK+EFiq5LAcbHm96CQLSia+5pvpUVSAsVApscBzayb7/6dJBJKwV4/onsk4VbWSmqxDMcfuTD+pC4RxgZHg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-6ljXKIQ22WFKyIs1jbORIkGanySBHaPPTOI4OxACP5WXgbcR0nDYfqNJfXEGwCK7IzHdNbCSFsNKKs0qCexR8Q=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@smithy/fetch-http-handler": "^5.3.11", "@smithy/node-http-handler": "^4.4.12", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.15", "tslib": "^2.6.2" } }, "sha512-dJuSTreu/T8f24SHDNTjd7eQ4rabr0TzPh2UTCwYexQtzG3nTDKm1e5eIdhiroTMDkPEJeY+WPkA6F9wod/20A=="],
|
||||
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/credential-provider-env": "^3.972.13", "@aws-sdk/credential-provider-http": "^3.972.15", "@aws-sdk/credential-provider-login": "^3.972.13", "@aws-sdk/credential-provider-process": "^3.972.13", "@aws-sdk/credential-provider-sso": "^3.972.13", "@aws-sdk/credential-provider-web-identity": "^3.972.13", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-JKSoGb7XeabZLBJptpqoZIFbROUIS65NuQnEHGOpuT9GuuZwag2qciKANiDLFiYk4u8nSrJC9JIOnWKVvPVjeA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RtYcrxdnJHKY8MFQGLltCURcjuMjnaQpAxPE6+/QEdDHHItMKZgabRe/KScX737F9vJMQsmJy9EmMOkCnoC1JQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.14", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.13", "@aws-sdk/credential-provider-http": "^3.972.15", "@aws-sdk/credential-provider-ini": "^3.972.13", "@aws-sdk/credential-provider-process": "^3.972.13", "@aws-sdk/credential-provider-sso": "^3.972.13", "@aws-sdk/credential-provider-web-identity": "^3.972.13", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-WqoC2aliIjQM/L3oFf6j+op/enT2i9Cc4UTxxMEKrJNECkq4/PlKE5BOjSYFcq6G9mz65EFbXJh7zOU4CvjSKQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-rsRG0LQA4VR+jnDyuqtXi2CePYSmfm5GNL9KxiW8DSe25YwJSr06W8TdUfONAC+rjsTI+aIH2rBGG5FjMeANrw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/token-providers": "3.999.0", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-fr0UU1wx8kNHDhTQBXioc/YviSW8iXuAxHvnH7eQUtn8F8o/FU3uu6EUMvAQgyvn7Ne5QFnC0Cj0BFlwCk+RFw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-a6iFMh1pgUH0TdcouBppLJUfPM7Yd3R9S1xFodPtCRoLqCz2RQFA3qjA8x4112PVYXEd4/pHX2eihapq39w0rA=="],
|
||||
|
||||
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-arn-parser": "^3.972.2", "@smithy/node-config-provider": "^4.3.10", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-3H2bhvb7Cb/S6WFsBy/Dy9q2aegC9JmGH1inO8Lb2sWirSqpLJlZmvQHPE29h2tIxzv6el/14X/tLCQ8BQU6ZQ=="],
|
||||
|
||||
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-QMdffpU+GkSGC+bz6WdqlclqIeCsOfgX8JFZ5xvwDtX+UTj4mIXm3uXu7Ko6dBseRcJz1FA6T9OmlAAY6JgJUg=="],
|
||||
|
||||
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.973.1", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.15", "@aws-sdk/crc64-nvme": "^3.972.3", "@aws-sdk/types": "^3.973.4", "@smithy/is-array-buffer": "^4.2.1", "@smithy/node-config-provider": "^4.3.10", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-middleware": "^4.2.10", "@smithy/util-stream": "^4.5.15", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-QLXsxsI6VW8LuGK+/yx699wzqP/NMCGk/hSGP+qtB+Lcff+23UlbahyouLlk+nfT7Iu021SkXBhnAuVd6IZcPw=="],
|
||||
|
||||
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w=="],
|
||||
|
||||
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-XdZ2TLwyj3Am6kvUc67vquQvs6+D8npXvXgyEUJAdkUDx5oMFJKOqpK+UpJhVDsEL068WAJl2NEGzbSik7dGJQ=="],
|
||||
|
||||
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw=="],
|
||||
|
||||
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw=="],
|
||||
|
||||
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-arn-parser": "^3.972.2", "@smithy/core": "^3.23.6", "@smithy/node-config-provider": "^4.3.10", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-stream": "^4.5.15", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-WDLgssevOU5BFx1s8jA7jj6cE5HuImz28sy9jKOaVtz0AW1lYqSzotzdyiybFaBcQTs5zxXOb2pUfyMxgEKY3Q=="],
|
||||
|
||||
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-acvMUX9jF4I2Ew+Z/EA6gfaFaz9ehci5wxBmXCZeulLuv8m+iGf6pY9uKz8TPjg39bdAz3hxoE0eLP8Qz+IYlA=="],
|
||||
|
||||
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@smithy/core": "^3.23.6", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-ABlFVcIMmuRAwBT+8q5abAxOr7WmaINirDJBnqGY5b5jSDo00UMlg/G4a0xoAgwm6oAECeJcwkvDlxDwKf58fQ=="],
|
||||
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.3", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.15", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.15", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.0", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.6", "@smithy/fetch-http-handler": "^5.3.11", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/middleware-retry": "^4.4.37", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.12", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.36", "@smithy/util-defaults-mode-node": "^4.2.39", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw=="],
|
||||
|
||||
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/config-resolver": "^4.4.9", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw=="],
|
||||
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.3", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.15", "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-gQYI/Buwp0CAGQxY7mR5VzkP56rkWq2Y1ROkFuXh5XY94DsSjJw62B3I0N0lysQmtwiL2ht2KHI9NylM/RP4FA=="],
|
||||
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.999.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-cx0hHUlgXULfykx4rdu/ciNAJaa3AL5xz3rieCz7NKJ68MJwlj3664Y8WR5MGgxfyYJBdamnkjNSx5Kekuc0cg=="],
|
||||
|
||||
"@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="],
|
||||
|
||||
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg=="],
|
||||
|
||||
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" } }, "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ=="],
|
||||
|
||||
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.4", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog=="],
|
||||
|
||||
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA=="],
|
||||
|
||||
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.15", "@aws-sdk/types": "^3.973.4", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-A9J2G4Nf236e9GpaC1JnA8wRn6u6GjnOXiTwBLA6NUJhlBTIGfrTy+K1IazmF8y+4OFdW3O5TZlhyspJMqiqjA=="],
|
||||
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.8", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.3.6", "tslib": "^2.6.2" } }, "sha512-Ql8elcUdYCha83Ol7NznBsgN5GVZnv3vUd86fEc6waU6oUdY0T1O9NODkEEOS/Uaogr87avDrUC6DSeM4oXjZg=="],
|
||||
|
||||
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@encryptid/sdk": ["@encryptid/sdk@file:../encryptid-sdk", { "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "hono": "^4.11.0" }, "devDependencies": { "@types/react": "^19.0.0", "typescript": "^5.7.0" }, "peerDependencies": { "next": ">=14.0.0", "react": ">=18.0.0" }, "optionalPeers": ["next", "react"] }],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
|
@ -82,14 +173,56 @@
|
|||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.5.0", "", {}, "sha512-HLomZXMmrCFHSRKESF5vklAKsDY7/fsT/ZhqCu3V0UoW/Qbv8wxmO4W9bx4KnCCF2Zak4yuk+AGraK/bPmI4kA=="],
|
||||
|
||||
"@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||
|
||||
"@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@rollup/plugin-virtual": ["@rollup/plugin-virtual@3.0.2", "", { "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
|
||||
|
|
@ -136,6 +269,126 @@
|
|||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
|
||||
|
||||
"@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
|
||||
|
||||
"@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="],
|
||||
|
||||
"@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="],
|
||||
|
||||
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
|
||||
|
||||
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g=="],
|
||||
|
||||
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-y5d4xRiD6TzeP5BWlb+Ig/VFqF+t9oANNhGeMqyzU7obw7FYgTgVi50i5JqBTeKp+TABeDIeeXFZdz65RipNtA=="],
|
||||
|
||||
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.2", "", { "dependencies": { "@smithy/util-base64": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-QzzYIlf4yg0w5TQaC9VId3B3ugSk1MI/wb7tgcHtd7CBV9gNRKZrhc2EPSxSZuDy10zUZ0lomNMgkc6/VVe8xg=="],
|
||||
|
||||
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.9", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.1", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "tslib": "^2.6.2" } }, "sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q=="],
|
||||
|
||||
"@smithy/core": ["@smithy/core@3.23.6", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.11", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-stream": "^4.5.15", "@smithy/util-utf8": "^4.2.1", "@smithy/uuid": "^1.1.1", "tslib": "^2.6.2" } }, "sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg=="],
|
||||
|
||||
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.10", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "tslib": "^2.6.2" } }, "sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ=="],
|
||||
|
||||
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.10", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ=="],
|
||||
|
||||
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.10", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw=="],
|
||||
|
||||
"@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA=="],
|
||||
|
||||
"@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.10", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ=="],
|
||||
|
||||
"@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.10", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw=="],
|
||||
|
||||
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.11", "", { "dependencies": { "@smithy/protocol-http": "^5.3.10", "@smithy/querystring-builder": "^4.2.10", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g=="],
|
||||
|
||||
"@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.11", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.1", "@smithy/chunked-blob-reader-native": "^4.2.2", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-DrcAx3PM6AEbWZxsKl6CWAGnVwiz28Wp1ZhNu+Hi4uI/6C1PIZBIaPM2VoqBDAsOWbM6ZVzOEQMxFLLdmb4eBQ=="],
|
||||
|
||||
"@smithy/hash-node": ["@smithy/hash-node@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-buffer-from": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w=="],
|
||||
|
||||
"@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-w78xsYrOlwXKwN5tv1GnKIRbHb1HygSpeZMP6xDxCPGf1U/xDHjCpJu64c5T35UKyEPwa0bPeIcvU69VY3khUA=="],
|
||||
|
||||
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org=="],
|
||||
|
||||
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q=="],
|
||||
|
||||
"@smithy/md5-js": ["@smithy/md5-js@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-Op+Dh6dPLWTjWITChFayDllIaCXRofOed8ecpggTC5fkh8yXes0vAEX7gRUfjGK+TlyxoCAA05gHbZW/zB9JwQ=="],
|
||||
|
||||
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.10", "", { "dependencies": { "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w=="],
|
||||
|
||||
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.20", "", { "dependencies": { "@smithy/core": "^3.23.6", "@smithy/middleware-serde": "^4.2.11", "@smithy/node-config-provider": "^4.3.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-middleware": "^4.2.10", "tslib": "^2.6.2" } }, "sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw=="],
|
||||
|
||||
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.37", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/protocol-http": "^5.3.10", "@smithy/service-error-classification": "^4.2.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/uuid": "^1.1.1", "tslib": "^2.6.2" } }, "sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA=="],
|
||||
|
||||
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.11", "", { "dependencies": { "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg=="],
|
||||
|
||||
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA=="],
|
||||
|
||||
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.10", "", { "dependencies": { "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w=="],
|
||||
|
||||
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.12", "", { "dependencies": { "@smithy/abort-controller": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/querystring-builder": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w=="],
|
||||
|
||||
"@smithy/property-provider": ["@smithy/property-provider@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw=="],
|
||||
|
||||
"@smithy/protocol-http": ["@smithy/protocol-http@5.3.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A=="],
|
||||
|
||||
"@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-uri-escape": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA=="],
|
||||
|
||||
"@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q=="],
|
||||
|
||||
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0" } }, "sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg=="],
|
||||
|
||||
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug=="],
|
||||
|
||||
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.10", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.1", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-uri-escape": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA=="],
|
||||
|
||||
"@smithy/smithy-client": ["@smithy/smithy-client@4.12.0", "", { "dependencies": { "@smithy/core": "^3.23.6", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/middleware-stack": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.15", "tslib": "^2.6.2" } }, "sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ=="],
|
||||
|
||||
"@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="],
|
||||
|
||||
"@smithy/url-parser": ["@smithy/url-parser@4.2.10", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A=="],
|
||||
|
||||
"@smithy/util-base64": ["@smithy/util-base64@4.3.1", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w=="],
|
||||
|
||||
"@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g=="],
|
||||
|
||||
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw=="],
|
||||
|
||||
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.1", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig=="],
|
||||
|
||||
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A=="],
|
||||
|
||||
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.36", "", { "dependencies": { "@smithy/property-provider": "^4.2.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew=="],
|
||||
|
||||
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.39", "", { "dependencies": { "@smithy/config-resolver": "^4.4.9", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg=="],
|
||||
|
||||
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.1", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg=="],
|
||||
|
||||
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA=="],
|
||||
|
||||
"@smithy/util-middleware": ["@smithy/util-middleware@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA=="],
|
||||
|
||||
"@smithy/util-retry": ["@smithy/util-retry@4.2.10", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A=="],
|
||||
|
||||
"@smithy/util-stream": ["@smithy/util-stream@4.5.15", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.11", "@smithy/node-http-handler": "^4.4.12", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-buffer-from": "^4.2.1", "@smithy/util-hex-encoding": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw=="],
|
||||
|
||||
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q=="],
|
||||
|
||||
"@smithy/util-utf8": ["@smithy/util-utf8@4.2.1", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g=="],
|
||||
|
||||
"@smithy/util-waiter": ["@smithy/util-waiter@4.2.10", "", { "dependencies": { "@smithy/abort-controller": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-4eTWph/Lkg1wZEDAyObwme0kmhEb7J/JjibY2znJdrYRgKbKqB7YoEhhJVJ4R1g/SYih4zuwX7LpJaM8RsnTVg=="],
|
||||
|
||||
"@smithy/uuid": ["@smithy/uuid@1.1.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw=="],
|
||||
|
||||
"@spruceid/siwe-parser": ["@spruceid/siwe-parser@2.1.2", "", { "dependencies": { "@noble/hashes": "^1.1.2", "apg-js": "^4.3.0", "uri-js": "^4.4.1", "valid-url": "^1.0.9" } }, "sha512-d/r3S1LwJyMaRAKQ0awmo9whfXeE88Qt00vRj91q5uv5ATtWIQEGJ67Yr5eSZw5zp1/fZCXZYuEckt8lSkereQ=="],
|
||||
|
||||
"@stablelib/binary": ["@stablelib/binary@1.0.1", "", { "dependencies": { "@stablelib/int": "^1.0.1" } }, "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q=="],
|
||||
|
||||
"@stablelib/int": ["@stablelib/int@1.0.1", "", {}, "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w=="],
|
||||
|
||||
"@stablelib/random": ["@stablelib/random@1.0.2", "", { "dependencies": { "@stablelib/binary": "^1.0.1", "@stablelib/wipe": "^1.0.1" } }, "sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w=="],
|
||||
|
||||
"@stablelib/wipe": ["@stablelib/wipe@1.0.1", "", {}, "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg=="],
|
||||
|
||||
"@swc/core": ["@swc/core@1.15.8", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.8", "@swc/core-darwin-x64": "1.15.8", "@swc/core-linux-arm-gnueabihf": "1.15.8", "@swc/core-linux-arm64-gnu": "1.15.8", "@swc/core-linux-arm64-musl": "1.15.8", "@swc/core-linux-x64-gnu": "1.15.8", "@swc/core-linux-x64-musl": "1.15.8", "@swc/core-win32-arm64-msvc": "1.15.8", "@swc/core-win32-ia32-msvc": "1.15.8", "@swc/core-win32-x64-msvc": "1.15.8" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw=="],
|
||||
|
||||
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg=="],
|
||||
|
|
@ -166,28 +419,122 @@
|
|||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/mailparser": ["@types/mailparser@3.4.6", "", { "dependencies": { "@types/node": "*", "iconv-lite": "^0.6.3" } }, "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
|
||||
|
||||
"@types/nodemailer": ["@types/nodemailer@6.4.22", "", { "dependencies": { "@types/node": "*" } }, "sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@x402/core": ["@x402/core@2.5.0", "", { "dependencies": { "zod": "^3.24.2" } }, "sha512-nUr8HW8WhkU1DvrpUfsRvALy5NF8UWKoFezZOtX61mohxp2lWZpJ2GnvscxDM8nmBAbtIollmksd5z5pj8InXw=="],
|
||||
|
||||
"@x402/evm": ["@x402/evm@2.5.0", "", { "dependencies": { "@x402/core": "~2.5.0", "@x402/extensions": "~2.5.0", "viem": "^2.39.3", "zod": "^3.24.2" } }, "sha512-MBSTQZwLobMVcmYO7itOMJRkxfHstsDyr7F94o9Rk/Oinz0kjvCe4DFgZmFXyz3nQUgQFmDVgTK5KIzfYR5uIA=="],
|
||||
|
||||
"@x402/extensions": ["@x402/extensions@2.5.0", "", { "dependencies": { "@scure/base": "^1.2.6", "@x402/core": "~2.5.0", "ajv": "^8.17.1", "siwe": "^2.3.2", "tweetnacl": "^1.0.3", "viem": "^2.43.5", "zod": "^3.24.2" } }, "sha512-e7IQShbGUM/XQmzI8DQh2tX/k2XDUGI9DNF+ij2NHUyPEqAt5/mJCwOlaxS/60FWFdfFRfWjTsqaoS7Z8WLi+A=="],
|
||||
|
||||
"@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.8", "", { "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1" } }, "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA=="],
|
||||
|
||||
"abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="],
|
||||
|
||||
"aes-js": ["aes-js@4.0.0-beta.5", "", {}, "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"apg-js": ["apg-js@4.4.0", "", {}, "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||
|
||||
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
|
||||
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
|
||||
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"encoding-japanese": ["encoding-japanese@2.2.0", "", {}, "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"ethers": ["ethers@6.16.0", "", { "dependencies": { "@adraffy/ens-normalize": "1.10.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.2", "@types/node": "22.7.5", "aes-js": "4.0.0-beta.5", "tslib": "2.7.0", "ws": "8.17.1" } }, "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.3.6", "", { "dependencies": { "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||
|
||||
"hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||
|
||||
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"imapflow": ["imapflow@1.2.10", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "iconv-lite": "0.7.2", "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1", "nodemailer": "8.0.1", "pino": "10.3.1", "socks": "2.8.7" } }, "sha512-tqmk0Gj4YBEnGCjjrUYWIf3Z4tzn4iihUcMkBRbafvHF3LqEiYNCSJAAYYbwERFxlikOJ3zzqtEcoxCUTjMv2Q=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
|
||||
|
||||
"isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
|
||||
|
||||
"libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="],
|
||||
|
||||
"libmime": ["libmime@5.3.7", "", { "dependencies": { "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", "libqp": "2.1.1" } }, "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw=="],
|
||||
|
||||
"libqp": ["libqp@2.1.1", "", {}, "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow=="],
|
||||
|
||||
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
|
||||
|
||||
"mailparser": ["mailparser@3.9.3", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.7.2", "libmime": "5.3.7", "linkify-it": "5.0.0", "nodemailer": "7.0.13", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"nodemailer": ["nodemailer@6.10.1", "", {}, "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"ox": ["ox@0.12.4", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q=="],
|
||||
|
||||
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
|
||||
|
||||
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
||||
|
||||
"perfect-arrows": ["perfect-arrows@0.3.7", "", {}, "sha512-wEN2gerTPVWl3yqoFEF8OeGbg3aRe2sxNUi9rnyYrCsL4JcI6K2tBDezRtqVrYG0BNtsWLdYiiTrYm+X//8yLQ=="],
|
||||
|
||||
"perfect-freehand": ["perfect-freehand@1.2.2", "", {}, "sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ=="],
|
||||
|
|
@ -196,28 +543,146 @@
|
|||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||
|
||||
"simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
|
||||
|
||||
"siwe": ["siwe@2.3.2", "", { "dependencies": { "@spruceid/siwe-parser": "^2.1.2", "@stablelib/random": "^1.0.1", "uri-js": "^4.4.1", "valid-url": "^1.0.9" }, "peerDependencies": { "ethers": "^5.6.8 || ^6.0.8" } }, "sha512-aSf+6+Latyttbj5nMu6GF3doMfv2UYj83hhwZgUF20ky6fTS83uVhkQABdIVnEuS8y1bBdk7p6ltb9SmlhTTlA=="],
|
||||
|
||||
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||
|
||||
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
|
||||
|
||||
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
|
||||
|
||||
"valid-url": ["valid-url@1.0.9", "", {}, "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA=="],
|
||||
|
||||
"viem": ["viem@2.46.3", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.4", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-2LJS+Hyh2sYjHXQtzfv1kU9pZx9dxFzvoU/ZKIcn0FNtOU0HQuIICuYdWtUDFHaGXbAdVo8J1eCvmjkL9JVGwg=="],
|
||||
|
||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
"vite-plugin-top-level-await": ["vite-plugin-top-level-await@1.6.0", "", { "dependencies": { "@rollup/plugin-virtual": "^3.0.2", "@swc/core": "^1.12.14", "@swc/wasm": "^1.12.14", "uuid": "10.0.0" }, "peerDependencies": { "vite": ">=2.8" } }, "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww=="],
|
||||
|
||||
"vite-plugin-wasm": ["vite-plugin-wasm@3.5.0", "", { "peerDependencies": { "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" } }, "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ=="],
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@automerge/automerge/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@scure/bip32/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
|
||||
|
||||
"@scure/bip32/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@scure/bip39/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@spruceid/siwe-parser/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"ethers/@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.10.1", "", {}, "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw=="],
|
||||
|
||||
"ethers/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="],
|
||||
|
||||
"ethers/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
|
||||
|
||||
"ethers/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="],
|
||||
|
||||
"ethers/tslib": ["tslib@2.7.0", "", {}, "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="],
|
||||
|
||||
"ethers/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
|
||||
|
||||
"imapflow/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"imapflow/nodemailer": ["nodemailer@8.0.1", "", {}, "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg=="],
|
||||
|
||||
"mailparser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"mailparser/nodemailer": ["nodemailer@7.0.13", "", {}, "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw=="],
|
||||
|
||||
"ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
|
||||
|
||||
"ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"viem/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
|
||||
|
||||
"viem/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"ethers/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ services:
|
|||
- SMTP_PASS=${SMTP_PASS}
|
||||
- SMTP_FROM=${SMTP_FROM:-EncryptID <noreply@rspace.online>}
|
||||
- RECOVERY_URL=${RECOVERY_URL:-https://auth.rspace.online/recover}
|
||||
- MAILCOW_API_URL=${MAILCOW_API_URL:-http://nginx-mailcow:8080}
|
||||
- MAILCOW_API_KEY=${MAILCOW_API_KEY:-}
|
||||
labels:
|
||||
# Traefik auto-discovery
|
||||
- "traefik.enable=true"
|
||||
|
|
@ -42,6 +44,7 @@ services:
|
|||
networks:
|
||||
- traefik-public
|
||||
- encryptid-internal
|
||||
- rmail-mailcow
|
||||
healthcheck:
|
||||
test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/health').then(r => r.json()).then(d => process.exit(d.database ? 0 : 1)).catch(() => process.exit(1))"]
|
||||
interval: 30s
|
||||
|
|
@ -76,3 +79,6 @@ networks:
|
|||
external: true
|
||||
encryptid-internal:
|
||||
driver: bridge
|
||||
rmail-mailcow:
|
||||
external: true
|
||||
name: mailcowdockerized_mailcow-network
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ services:
|
|||
rbooks-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rbooks-standalone
|
||||
command: ["bun", "run", "modules/books/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rbooks/standalone.ts"]
|
||||
volumes:
|
||||
- rspace-books:/data/books
|
||||
environment:
|
||||
|
|
@ -51,7 +51,7 @@ services:
|
|||
rpubs-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rpubs-standalone
|
||||
command: ["bun", "run", "modules/pubs/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rpubs/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rpubs-sa.rule: Host(`rpubs.online`)
|
||||
|
|
@ -62,7 +62,7 @@ services:
|
|||
rcart-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rcart-standalone
|
||||
command: ["bun", "run", "modules/cart/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rcart/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
FLOW_SERVICE_URL: http://payment-flow:3010
|
||||
|
|
@ -86,7 +86,7 @@ services:
|
|||
rswag-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rswag-standalone
|
||||
command: ["bun", "run", "modules/swag/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rswag/standalone.ts"]
|
||||
volumes:
|
||||
- rspace-swag:/data/swag-artifacts
|
||||
environment:
|
||||
|
|
@ -102,7 +102,7 @@ services:
|
|||
rchoices-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rchoices-standalone
|
||||
command: ["bun", "run", "modules/choices/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rchoices/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rchoices-sa.rule: Host(`rchoices.online`)
|
||||
|
|
@ -113,7 +113,7 @@ services:
|
|||
rfunds-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rfunds-standalone
|
||||
command: ["bun", "run", "modules/funds/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rfunds/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
FLOW_SERVICE_URL: http://payment-flow:3010
|
||||
|
|
@ -133,7 +133,7 @@ services:
|
|||
rfiles-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rfiles-standalone
|
||||
command: ["bun", "run", "modules/files/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rfiles/standalone.ts"]
|
||||
volumes:
|
||||
- rspace-files:/data/files
|
||||
environment:
|
||||
|
|
@ -149,7 +149,7 @@ services:
|
|||
rforum-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rforum-standalone
|
||||
command: ["bun", "run", "modules/forum/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rforum/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
HETZNER_API_TOKEN: ${HETZNER_API_TOKEN}
|
||||
|
|
@ -165,7 +165,7 @@ services:
|
|||
rvote-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rvote-standalone
|
||||
command: ["bun", "run", "modules/vote/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rvote/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rvote-sa.rule: Host(`rvote.online`)
|
||||
|
|
@ -176,7 +176,7 @@ services:
|
|||
rnotes-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rnotes-standalone
|
||||
command: ["bun", "run", "modules/notes/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rnotes/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rnotes-sa.rule: Host(`rnotes.online`)
|
||||
|
|
@ -187,7 +187,7 @@ services:
|
|||
rmaps-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rmaps-standalone
|
||||
command: ["bun", "run", "modules/maps/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rmaps/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
MAPS_SYNC_URL: wss://sync.rmaps.online
|
||||
|
|
@ -201,7 +201,7 @@ services:
|
|||
rwork-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rwork-standalone
|
||||
command: ["bun", "run", "modules/work/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rwork/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rwork-sa.rule: Host(`rwork.online`)
|
||||
|
|
@ -212,7 +212,7 @@ services:
|
|||
rtrips-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rtrips-standalone
|
||||
command: ["bun", "run", "modules/trips/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rtrips/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
OSRM_URL: http://osrm-backend:5000
|
||||
|
|
@ -226,7 +226,7 @@ services:
|
|||
rcal-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rcal-standalone
|
||||
command: ["bun", "run", "modules/cal/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rcal/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rcal-sa.rule: Host(`rcal.online`)
|
||||
|
|
@ -237,7 +237,7 @@ services:
|
|||
rnetwork-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rnetwork-standalone
|
||||
command: ["bun", "run", "modules/network/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rnetwork/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
TWENTY_API_URL: https://rnetwork.online
|
||||
|
|
@ -252,7 +252,7 @@ services:
|
|||
rtube-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rtube-standalone
|
||||
command: ["bun", "run", "modules/tube/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rtube/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
R2_ENDPOINT: ${R2_ENDPOINT}
|
||||
|
|
@ -270,7 +270,7 @@ services:
|
|||
rinbox-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rinbox-standalone
|
||||
command: ["bun", "run", "modules/inbox/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rinbox/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
IMAP_HOST: mail.rmail.online
|
||||
|
|
@ -290,7 +290,7 @@ services:
|
|||
rdata-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rdata-standalone
|
||||
command: ["bun", "run", "modules/data/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rdata/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
UMAMI_URL: https://analytics.rspace.online
|
||||
|
|
@ -305,7 +305,7 @@ services:
|
|||
rwallet-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rwallet-standalone
|
||||
command: ["bun", "run", "modules/wallet/standalone.ts"]
|
||||
command: ["bun", "run", "modules/rwallet/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rwallet-sa.rule: Host(`rwallet.online`)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* DemoSync — vanilla JS class for real-time demo data via rSpace WebSocket.
|
||||
*
|
||||
* Mirrors the React useDemoSync hook but uses EventTarget for framework-free
|
||||
* operation. Designed for module demo pages served as server-rendered HTML.
|
||||
*
|
||||
* Events:
|
||||
* - "snapshot" → detail: { shapes: Record<string, DemoShape> }
|
||||
* - "connected" → WebSocket opened
|
||||
* - "disconnected" → WebSocket closed
|
||||
*
|
||||
* Usage:
|
||||
* const sync = new DemoSync({ filter: ['demo-poll'] });
|
||||
* sync.addEventListener('snapshot', (e) => renderUI(e.detail.shapes));
|
||||
* sync.connect();
|
||||
*/
|
||||
|
||||
export interface DemoShape {
|
||||
type: string;
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DemoSyncOptions {
|
||||
/** Community slug (default: 'demo') */
|
||||
slug?: string;
|
||||
/** Only subscribe to these shape types */
|
||||
filter?: string[];
|
||||
/** rSpace server URL (default: auto-detect) */
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SLUG = "demo";
|
||||
const RECONNECT_BASE_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30000;
|
||||
const PING_INTERVAL_MS = 30000;
|
||||
|
||||
function getDefaultServerUrl(): string {
|
||||
if (typeof window === "undefined") return "https://rspace.online";
|
||||
const host = window.location.hostname;
|
||||
if (host === "localhost" || host === "127.0.0.1") {
|
||||
return `http://${host}:3000`;
|
||||
}
|
||||
return "https://rspace.online";
|
||||
}
|
||||
|
||||
export class DemoSync extends EventTarget {
|
||||
private slug: string;
|
||||
private filter: string[] | undefined;
|
||||
private serverUrl: string;
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectAttempt = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private destroyed = false;
|
||||
|
||||
shapes: Record<string, DemoShape> = {};
|
||||
connected = false;
|
||||
|
||||
constructor(options?: DemoSyncOptions) {
|
||||
super();
|
||||
this.slug = options?.slug ?? DEFAULT_SLUG;
|
||||
this.filter = options?.filter;
|
||||
this.serverUrl = options?.serverUrl ?? getDefaultServerUrl();
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.destroyed) return;
|
||||
|
||||
const wsProtocol = this.serverUrl.startsWith("https") ? "wss" : "ws";
|
||||
const host = this.serverUrl.replace(/^https?:\/\//, "");
|
||||
const wsUrl = `${wsProtocol}://${host}/ws/${this.slug}?mode=json`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (this.destroyed) return;
|
||||
this.connected = true;
|
||||
this.reconnectAttempt = 0;
|
||||
this.dispatchEvent(new Event("connected"));
|
||||
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (this.destroyed) return;
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "snapshot" && msg.shapes) {
|
||||
this.shapes = this.applyFilter(msg.shapes);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("snapshot", {
|
||||
detail: { shapes: this.shapes },
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (this.destroyed) return;
|
||||
this.connected = false;
|
||||
this.cleanup();
|
||||
this.dispatchEvent(new Event("disconnected"));
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose fires after onerror
|
||||
};
|
||||
}
|
||||
|
||||
updateShape(id: string, data: Partial<DemoShape>): void {
|
||||
const ws = this.ws;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
// Optimistic local update
|
||||
const existing = this.shapes[id];
|
||||
if (existing) {
|
||||
const updated = { ...existing, ...data, id };
|
||||
if (this.filter && this.filter.length > 0 && !this.filter.includes(updated.type)) return;
|
||||
this.shapes = { ...this.shapes, [id]: updated as DemoShape };
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("snapshot", {
|
||||
detail: { shapes: this.shapes },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: "update", id, data: { ...data, id } }));
|
||||
}
|
||||
|
||||
deleteShape(id: string): void {
|
||||
const ws = this.ws;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const { [id]: _, ...rest } = this.shapes;
|
||||
this.shapes = rest;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("snapshot", { detail: { shapes: this.shapes } }),
|
||||
);
|
||||
ws.send(JSON.stringify({ type: "delete", id }));
|
||||
}
|
||||
|
||||
async resetDemo(): Promise<void> {
|
||||
const res = await fetch(`${this.serverUrl}/api/communities/demo/reset`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Reset failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
this.cleanup();
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private applyFilter(allShapes: Record<string, DemoShape>): Record<string, DemoShape> {
|
||||
if (!this.filter || this.filter.length === 0) return allShapes;
|
||||
const filtered: Record<string, DemoShape> = {};
|
||||
for (const [id, shape] of Object.entries(allShapes)) {
|
||||
if (this.filter.includes(shape.type)) {
|
||||
filtered[id] = shape;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.destroyed) return;
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempt),
|
||||
RECONNECT_MAX_MS,
|
||||
);
|
||||
this.reconnectAttempt++;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
if (!this.destroyed) this.connect();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
/**
|
||||
* <folk-calendar-view> — temporal coordination calendar.
|
||||
*
|
||||
* Month grid view with event dots, lunar phase overlay,
|
||||
* event creation, and source filtering.
|
||||
*/
|
||||
|
||||
class FolkCalendarView extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
private currentDate = new Date();
|
||||
private events: any[] = [];
|
||||
private sources: any[] = [];
|
||||
private lunarData: Record<string, { phase: string; illumination: number }> = {};
|
||||
private showLunar = true;
|
||||
private selectedDate = "";
|
||||
private selectedEvent: any = null;
|
||||
private error = "";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.loadMonth();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/cal/);
|
||||
return match ? `/${match[1]}/cal` : "";
|
||||
}
|
||||
|
||||
private async loadMonth() {
|
||||
const year = this.currentDate.getFullYear();
|
||||
const month = this.currentDate.getMonth();
|
||||
const start = `${year}-${String(month + 1).padStart(2, "0")}-01`;
|
||||
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||
const end = `${year}-${String(month + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
|
||||
|
||||
const base = this.getApiBase();
|
||||
try {
|
||||
const [eventsRes, sourcesRes, lunarRes] = await Promise.all([
|
||||
fetch(`${base}/api/events?start=${start}&end=${end}`),
|
||||
fetch(`${base}/api/sources`),
|
||||
fetch(`${base}/api/lunar?start=${start}&end=${end}`),
|
||||
]);
|
||||
if (eventsRes.ok) { const data = await eventsRes.json(); this.events = data.results || []; }
|
||||
if (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; }
|
||||
if (lunarRes.ok) { this.lunarData = await lunarRes.json(); }
|
||||
} catch { /* offline fallback */ }
|
||||
this.render();
|
||||
}
|
||||
|
||||
private navigate(delta: number) {
|
||||
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1);
|
||||
this.loadMonth();
|
||||
}
|
||||
|
||||
private getMoonEmoji(phase: string): string {
|
||||
const map: Record<string, string> = {
|
||||
new_moon: "🌑", waxing_crescent: "🌒", first_quarter: "🌓",
|
||||
waxing_gibbous: "🌔", full_moon: "🌕", waning_gibbous: "🌖",
|
||||
last_quarter: "🌗", waning_crescent: "🌘",
|
||||
};
|
||||
return map[phase] || "";
|
||||
}
|
||||
|
||||
private render() {
|
||||
const year = this.currentDate.getFullYear();
|
||||
const month = this.currentDate.getMonth();
|
||||
const monthName = this.currentDate.toLocaleString("default", { month: "long" });
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
||||
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 16px; }
|
||||
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; text-align: center; color: #e2e8f0; }
|
||||
.toggle-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 12px; }
|
||||
.toggle-btn.active { border-color: #6366f1; color: #6366f1; }
|
||||
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 12px; }
|
||||
|
||||
.weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-bottom: 4px; }
|
||||
.weekday { text-align: center; font-size: 11px; color: #666; padding: 4px; font-weight: 600; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
|
||||
.day {
|
||||
background: #16161e; border: 1px solid #222; border-radius: 6px;
|
||||
min-height: 80px; padding: 6px; cursor: pointer; position: relative;
|
||||
}
|
||||
.day:hover { border-color: #444; }
|
||||
.day.today { border-color: #6366f1; }
|
||||
.day.other-month { opacity: 0.3; }
|
||||
.day-num { font-size: 12px; font-weight: 600; margin-bottom: 2px; display: flex; justify-content: space-between; }
|
||||
.moon { font-size: 10px; opacity: 0.7; }
|
||||
.event-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin: 1px; }
|
||||
.event-dots { display: flex; flex-wrap: wrap; gap: 1px; }
|
||||
.event-label { font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #aaa; line-height: 1.4; }
|
||||
|
||||
.event-modal {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.modal-content { background: #1e1e2e; border: 1px solid #333; border-radius: 12px; padding: 20px; max-width: 400px; width: 90%; }
|
||||
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
|
||||
.modal-field { font-size: 13px; color: #aaa; margin-bottom: 6px; }
|
||||
.modal-close { float: right; background: none; border: none; color: #888; font-size: 18px; cursor: pointer; }
|
||||
|
||||
.sources { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.source-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; }
|
||||
</style>
|
||||
|
||||
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
|
||||
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" id="prev">←</button>
|
||||
<span class="rapp-nav__title">${monthName} ${year}</span>
|
||||
<button class="toggle-btn ${this.showLunar ? "active" : ""}" id="toggle-lunar">🌙 Lunar</button>
|
||||
<button class="rapp-nav__back" id="next">→</button>
|
||||
</div>
|
||||
|
||||
${this.sources.length > 0 ? `<div class="sources">
|
||||
${this.sources.map(s => `<span class="source-badge" style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
|
||||
</div>` : ""}
|
||||
|
||||
<div class="weekdays">
|
||||
${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => `<div class="weekday">${d}</div>`).join("")}
|
||||
</div>
|
||||
<div class="grid">
|
||||
${this.renderDays(year, month)}
|
||||
</div>
|
||||
|
||||
${this.selectedEvent ? this.renderEventModal() : ""}
|
||||
`;
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
private renderDays(year: number, month: number): string {
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
|
||||
let html = "";
|
||||
// Previous month padding
|
||||
const prevDays = new Date(year, month, 0).getDate();
|
||||
for (let i = firstDay - 1; i >= 0; i--) {
|
||||
html += `<div class="day other-month"><div class="day-num">${prevDays - i}</div></div>`;
|
||||
}
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
const isToday = dateStr === todayStr;
|
||||
const dayEvents = this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
|
||||
const lunar = this.lunarData[dateStr];
|
||||
|
||||
html += `<div class="day ${isToday ? "today" : ""}" data-date="${dateStr}">
|
||||
<div class="day-num">
|
||||
<span>${d}</span>
|
||||
${this.showLunar && lunar ? `<span class="moon">${this.getMoonEmoji(lunar.phase)}</span>` : ""}
|
||||
</div>
|
||||
${dayEvents.length > 0 ? `
|
||||
<div class="event-dots">
|
||||
${dayEvents.slice(0, 3).map(e => `<span class="event-dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")}
|
||||
${dayEvents.length > 3 ? `<span style="font-size:9px;color:#888">+${dayEvents.length - 3}</span>` : ""}
|
||||
</div>
|
||||
${dayEvents.slice(0, 2).map(e => `<div class="event-label" data-event='${JSON.stringify({ id: e.id })}'>${this.esc(e.title)}</div>`).join("")}
|
||||
` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Next month padding
|
||||
const totalCells = firstDay + daysInMonth;
|
||||
const remaining = (7 - (totalCells % 7)) % 7;
|
||||
for (let i = 1; i <= remaining; i++) {
|
||||
html += `<div class="day other-month"><div class="day-num">${i}</div></div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private renderEventModal(): string {
|
||||
const e = this.selectedEvent;
|
||||
return `
|
||||
<div class="event-modal" id="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<button class="modal-close" id="modal-close">✕</button>
|
||||
<div class="modal-title">${this.esc(e.title)}</div>
|
||||
${e.description ? `<div class="modal-field">${this.esc(e.description)}</div>` : ""}
|
||||
<div class="modal-field">When: ${new Date(e.start_time).toLocaleString()}${e.end_time ? ` — ${new Date(e.end_time).toLocaleString()}` : ""}</div>
|
||||
${e.location_name ? `<div class="modal-field">Where: ${this.esc(e.location_name)}</div>` : ""}
|
||||
${e.source_name ? `<div class="modal-field">Source: ${this.esc(e.source_name)}</div>` : ""}
|
||||
${e.is_virtual ? `<div class="modal-field">Virtual: ${this.esc(e.virtual_platform || "")} ${e.virtual_url ? `<a href="${e.virtual_url}" target="_blank" style="color:#6366f1">Join</a>` : ""}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private attachListeners() {
|
||||
this.shadow.getElementById("prev")?.addEventListener("click", () => this.navigate(-1));
|
||||
this.shadow.getElementById("next")?.addEventListener("click", () => this.navigate(1));
|
||||
this.shadow.getElementById("toggle-lunar")?.addEventListener("click", () => {
|
||||
this.showLunar = !this.showLunar;
|
||||
this.render();
|
||||
});
|
||||
|
||||
this.shadow.querySelectorAll("[data-event]").forEach(el => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const data = JSON.parse((el as HTMLElement).dataset.event!);
|
||||
this.selectedEvent = this.events.find(ev => ev.id === data.id);
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
this.shadow.getElementById("modal-overlay")?.addEventListener("click", (e) => {
|
||||
if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); }
|
||||
});
|
||||
this.shadow.getElementById("modal-close")?.addEventListener("click", () => {
|
||||
this.selectedEvent = null; this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-calendar-view", FolkCalendarView);
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
/**
|
||||
* rCal landing page — relational calendar.
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
<!-- Hero -->
|
||||
<div class="rl-hero">
|
||||
<span class="rl-tagline">rCal</span>
|
||||
<h1 class="rl-heading">(You)rCal, your rhythm.</h1>
|
||||
<p class="rl-subtitle">Time is shared. Your calendar should be too.</p>
|
||||
<p class="rl-subtext">
|
||||
A spatiotemporal calendar that couples where and when, supports natural cycles,
|
||||
and zooms from 30-second moments to geological epochs.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rcal" class="rl-cta-primary" id="ml-primary">Open Calendar</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">What rCal Handles</h2>
|
||||
<div class="rl-grid-4">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔭</div>
|
||||
<h3>Temporal Zoom</h3>
|
||||
<p>Ten zoom levels from 30-second moments to cosmic time. Year, month, week, day — and beyond.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">📅</div>
|
||||
<h3>Shared Events</h3>
|
||||
<p>Calendars belong to spaces, not individuals. Everyone sees the same schedule by default.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🌙</div>
|
||||
<h3>Lunar Phases</h3>
|
||||
<p>Moon phases, solstices, and seasonal rhythms overlaid on the Gregorian grid. Plan with natural cycles.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🗺</div>
|
||||
<h3>Location-Aware</h3>
|
||||
<p>Events have places, not just times. Spatial and temporal zoom are coupled at every level.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-step">
|
||||
<span class="rl-step__num">1</span>
|
||||
<h3>Create events</h3>
|
||||
<p>Add events with time, location, and source context. Pull from ICS feeds, CalDAV, or other r* modules.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<span class="rl-step__num">2</span>
|
||||
<h3>Zoom and explore</h3>
|
||||
<p>Navigate across ten temporal zoom levels. Spatial and temporal zoom are coupled — zoom out in time and the map zooms out too.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<span class="rl-step__num">3</span>
|
||||
<h3>Share with your space</h3>
|
||||
<p>Space members see the same calendar. rWork sprints, rVote deadlines, rTrips itineraries — all in one view.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Temporal Zoom -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Temporal zoom</h2>
|
||||
<p class="rl-subtext" style="text-align:center">Ten levels of time — from the blink of an eye to deep time.</p>
|
||||
<div class="rl-card" style="max-width:700px;margin:2rem auto 0">
|
||||
<div class="rl-zoom-bar">
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">0</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:10%;background:rgba(99,102,241,0.3)">
|
||||
<span class="rl-zoom-bar__name">Moment</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">30 s</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">1</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:15%;background:rgba(99,102,241,0.28)">
|
||||
<span class="rl-zoom-bar__name">Minute</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">1–10 min</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">2</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:22%;background:rgba(99,102,241,0.25)">
|
||||
<span class="rl-zoom-bar__name">Hour</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">1–6 hrs</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">3</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:30%;background:rgba(99,102,241,0.22)">
|
||||
<span class="rl-zoom-bar__name">Day</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">24 hrs</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">4</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:40%;background:rgba(99,102,241,0.20)">
|
||||
<span class="rl-zoom-bar__name">Week</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">7 days</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">5</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:50%;background:rgba(99,102,241,0.18)">
|
||||
<span class="rl-zoom-bar__name">Lunar</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">29.5 days</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">6</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:60%;background:rgba(99,102,241,0.16)">
|
||||
<span class="rl-zoom-bar__name">Season</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">~90 days</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">7</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:70%;background:rgba(99,102,241,0.14)">
|
||||
<span class="rl-zoom-bar__name">Year</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">365 days</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">8</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:85%;background:rgba(99,102,241,0.12)">
|
||||
<span class="rl-zoom-bar__name">Epoch</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">decades–centuries</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">9</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:100%;background:rgba(99,102,241,0.10)">
|
||||
<span class="rl-zoom-bar__name">Cosmic</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">geological</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Calendar Views -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Four views</h2>
|
||||
<div class="rl-grid-4" style="margin-top:2rem">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box"><span style="font-size:1.25rem">📅</span></div>
|
||||
<h3>Temporal</h3>
|
||||
<p>Classic calendar grid — day, week, month. The view you know.</p>
|
||||
<p style="font-size:0.7rem;color:#64748b;margin-top:0.5rem"><kbd style="background:rgba(255,255,255,0.1);padding:0.1rem 0.3rem;border-radius:3px;font-size:0.65rem">T</kbd> to switch</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box"><span style="font-size:1.25rem">🗺</span></div>
|
||||
<h3>Spatial</h3>
|
||||
<p>Map view with events pinned to locations. Zoom couples time and space.</p>
|
||||
<p style="font-size:0.7rem;color:#64748b;margin-top:0.5rem"><kbd style="background:rgba(255,255,255,0.1);padding:0.1rem 0.3rem;border-radius:3px;font-size:0.65rem">S</kbd> to switch</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box"><span style="font-size:1.25rem">🌙</span></div>
|
||||
<h3>Lunar</h3>
|
||||
<p>Moon-phase overlay with illumination percentages. Plan with natural rhythms.</p>
|
||||
<p style="font-size:0.7rem;color:#64748b;margin-top:0.5rem"><kbd style="background:rgba(255,255,255,0.1);padding:0.1rem 0.3rem;border-radius:3px;font-size:0.65rem">L</kbd> to switch</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box"><span style="font-size:1.25rem">🧩</span></div>
|
||||
<h3>Context</h3>
|
||||
<p>Events grouped by r* source module. See your rWork sprints next to rVote deadlines.</p>
|
||||
<p style="font-size:0.7rem;color:#64748b;margin-top:0.5rem"><kbd style="background:rgba(255,255,255,0.1);padding:0.1rem 0.3rem;border-radius:3px;font-size:0.65rem">C</kbd> to switch</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Ecosystem Integration -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Ecosystem integration</h2>
|
||||
<p class="rl-subtext" style="text-align:center">rCal pulls events from across the r* ecosystem automatically.</p>
|
||||
<div class="rl-grid-3" style="margin-top:2rem">
|
||||
<div class="rl-integration">
|
||||
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">✈️</span></div>
|
||||
<div>
|
||||
<h3><a href="/rtrips" style="color:#e2e8f0;text-decoration:none">rTrips</a></h3>
|
||||
<p>Travel itineraries appear as multi-day calendar events with location pins.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-integration">
|
||||
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">🗺</span></div>
|
||||
<div>
|
||||
<h3><a href="/rmaps" style="color:#e2e8f0;text-decoration:none">rMaps</a></h3>
|
||||
<p>Location-tagged events render on the spatial view map layer.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-integration">
|
||||
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">👥</span></div>
|
||||
<div>
|
||||
<h3><a href="/rnetwork" style="color:#e2e8f0;text-decoration:none">rNetwork</a></h3>
|
||||
<p>Meeting events auto-link to participant profiles and relationship context.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-integration">
|
||||
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">🛒</span></div>
|
||||
<div>
|
||||
<h3><a href="/rcart" style="color:#e2e8f0;text-decoration:none">rCart</a></h3>
|
||||
<p>Group-buy deadlines and fulfillment ETAs show up as calendar milestones.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-integration">
|
||||
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">📝</span></div>
|
||||
<div>
|
||||
<h3><a href="/rnotes" style="color:#e2e8f0;text-decoration:none">rNotes</a></h3>
|
||||
<p>Meeting notes link back to the calendar event that spawned them.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-integration">
|
||||
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">🌐</span></div>
|
||||
<div>
|
||||
<h3><a href="/" style="color:#e2e8f0;text-decoration:none">rSpace</a></h3>
|
||||
<p>Space-level milestones and deadlines aggregate across all modules into one timeline.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Built on Open Source -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
|
||||
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rCal.</p>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🗃</div>
|
||||
<h3>PostgreSQL</h3>
|
||||
<p>Rock-solid relational database for events, sources, locations, and calendar metadata.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🚗</div>
|
||||
<h3>OSRM</h3>
|
||||
<p>Open-source routing engine for location-based scheduling, travel times, and spatial queries.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔥</div>
|
||||
<h3>Hono</h3>
|
||||
<p>Ultrafast web framework for the API layer. Lightweight, edge-ready, and built for speed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Your Data, Protected -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">Your Data, Protected</h2>
|
||||
<p class="rl-subtext">How rCal keeps your information safe.</p>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔒</div>
|
||||
<h3>End-to-End Encryption</h3>
|
||||
<span class="rl-badge">Coming Soon</span>
|
||||
<p>All content encrypted before it leaves your device. Not even the server can read it.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🕵</div>
|
||||
<h3>Zero-Knowledge Architecture</h3>
|
||||
<span class="rl-badge">Coming Soon</span>
|
||||
<p>The server processes your requests without ever seeing your data in the clear.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🏠</div>
|
||||
<h3>Self-Hosted</h3>
|
||||
<p>Run on your own infrastructure. Your server, your rules, your data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">(You)rCal, your rhythm.</h2>
|
||||
<p class="rl-subtext">Try the demo or create a space to get started.</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rcal" class="rl-cta-primary">Open Calendar</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rl-back">
|
||||
<a href="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
/**
|
||||
* <folk-choices-dashboard> — lists choice shapes (polls, rankings, spider charts)
|
||||
* from the current space and links to the canvas to create/interact with them.
|
||||
*/
|
||||
|
||||
class FolkChoicesDashboard extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private choices: any[] = [];
|
||||
private loading = true;
|
||||
private space: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.loadChoices();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
return parts.length >= 2 ? `/${parts[0]}/choices` : "/demo/choices";
|
||||
}
|
||||
|
||||
private async loadChoices() {
|
||||
this.loading = true;
|
||||
this.render();
|
||||
try {
|
||||
const res = await fetch(`${this.getApiBase()}/api/choices`);
|
||||
const data = await res.json();
|
||||
this.choices = data.choices || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load choices:", e);
|
||||
}
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
const typeIcons: Record<string, string> = {
|
||||
"folk-choice-vote": "☑",
|
||||
"folk-choice-rank": "📊",
|
||||
"folk-choice-spider": "🕸",
|
||||
};
|
||||
const typeLabels: Record<string, string> = {
|
||||
"folk-choice-vote": "Poll",
|
||||
"folk-choice-rank": "Ranking",
|
||||
"folk-choice-spider": "Spider Chart",
|
||||
};
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; padding: 1.5rem; }
|
||||
.rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
|
||||
.create-btns { display: flex; gap: 0.5rem; }
|
||||
.create-btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; text-decoration: none; }
|
||||
.create-btn:hover { border-color: #6366f1; color: #f1f5f9; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
|
||||
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; cursor: pointer; text-decoration: none; display: block; }
|
||||
.card:hover { border-color: #6366f1; }
|
||||
.card-icon { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
.card-title { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin: 0 0 0.25rem; }
|
||||
.card-type { color: #818cf8; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
||||
.card-meta { color: #94a3b8; font-size: 0.8125rem; }
|
||||
.stat { display: inline-block; margin-right: 1rem; }
|
||||
.empty { text-align: center; padding: 3rem; color: #64748b; }
|
||||
.empty-icon { font-size: 3rem; margin-bottom: 1rem; }
|
||||
.empty p { margin: 0.5rem 0; font-size: 0.875rem; }
|
||||
.loading { text-align: center; padding: 3rem; color: #94a3b8; }
|
||||
.info { background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; color: #a5b4fc; font-size: 0.8125rem; }
|
||||
</style>
|
||||
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Choices</span>
|
||||
<div class="create-btns">
|
||||
<a class="create-btn" href="/${this.space}/rspace" title="Open canvas to create choices">➕ New on Canvas</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
Choice tools (Polls, Rankings, Spider Charts) live on the collaborative canvas.
|
||||
Create them there and they'll appear here for quick access.
|
||||
</div>
|
||||
|
||||
${this.loading ? `<div class="loading">⏳ Loading choices...</div>` :
|
||||
this.choices.length === 0 ? this.renderEmpty() : this.renderGrid(typeIcons, typeLabels)}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmpty(): string {
|
||||
return `<div class="empty">
|
||||
<div class="empty-icon">☑</div>
|
||||
<p>No choices in this space yet.</p>
|
||||
<p>Open the <a href="/${this.space}/rspace" style="color:#818cf8">canvas</a> and use the Poll, Rank, or Spider buttons to create one.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderGrid(icons: Record<string, string>, labels: Record<string, string>): string {
|
||||
return `<div class="grid">
|
||||
${this.choices.map((ch) => `
|
||||
<a class="card" href="/${this.space}/rspace">
|
||||
<div class="card-icon">${icons[ch.type] || "☑"}</div>
|
||||
<div class="card-type">${labels[ch.type] || ch.type}</div>
|
||||
<h3 class="card-title">${this.esc(ch.title)}</h3>
|
||||
<div class="card-meta">
|
||||
<span class="stat">${ch.optionCount} options</span>
|
||||
<span class="stat">${ch.voteCount} responses</span>
|
||||
</div>
|
||||
</a>
|
||||
`).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-choices-dashboard", FolkChoicesDashboard);
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
/**
|
||||
* rChoices — rich landing page body.
|
||||
* Returned by landingPage() in the module export;
|
||||
* the shell wraps it with header, CSS, and analytics.
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
<!-- Hero -->
|
||||
<div class="rl-hero">
|
||||
<span class="rl-tagline">rChoices</span>
|
||||
<h1 class="rl-heading">(You)rChoices, your voice.</h1>
|
||||
<p class="rl-subtitle">Decide Together, Fairly</p>
|
||||
<p class="rl-subtext">
|
||||
Quadratic voting, ranked choice, and multi-criteria scoring — all as
|
||||
interactive shapes on your canvas. Drop a choice, let members vote,
|
||||
watch results emerge in real time.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rchoices" class="rl-cta-primary" id="ml-primary">Start Deciding</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">What rChoices Handles</h2>
|
||||
<div class="rl-grid-4">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">⚖</div>
|
||||
<h3>Quadratic Voting</h3>
|
||||
<p>Express intensity of preference. The cost of additional votes grows exponentially, balancing passion with fairness.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">📋</div>
|
||||
<h3>Ranked Choice Voting</h3>
|
||||
<p>Order preferences from first to last. Instant-runoff tabulation finds the option with the broadest support.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔄</div>
|
||||
<h3>CRDT-Based Shared State</h3>
|
||||
<p>Automerge CRDTs keep all participants in sync — no conflicts, no lost votes, even offline.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">⚡</div>
|
||||
<h3>Real-Time Collaboration</h3>
|
||||
<p>Tallies, rankings, and spider charts update live as members vote. Results emerge instantly.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">1</div>
|
||||
<h3>Create a Decision</h3>
|
||||
<p>Pick a voting method, name it, and add options. The choice shape appears on the canvas.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">2</div>
|
||||
<h3>Add Options & Invite Voters</h3>
|
||||
<p>Space members interact with the shape to cast votes, rank preferences, or score criteria.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">3</div>
|
||||
<h3>Results Calculated Fairly in Real Time</h3>
|
||||
<p>Live tallies update as votes arrive. View charts, rankings, and spider diagrams instantly.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Voting Methods -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Voting Methods</h2>
|
||||
<p class="rl-subtext" style="text-align:center">
|
||||
Three powerful mechanisms, each designed for different decision contexts.
|
||||
</p>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">⚖</div>
|
||||
<h3>Quadratic Voting</h3>
|
||||
<p>Express intensity of preference. The cost of additional votes on one option grows exponentially, balancing passion with fairness.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">📋</div>
|
||||
<h3>Ranked Choice</h3>
|
||||
<p>Order your preferences from first to last. Instant-runoff tabulation finds the option with the broadest support.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🕸</div>
|
||||
<h3>Multi-Criteria Scoring</h3>
|
||||
<p>Score options across weighted attributes. Spider diagrams reveal trade-offs at a glance.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Built on Open Source -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
|
||||
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rChoices.</p>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔄</div>
|
||||
<h3>Automerge</h3>
|
||||
<p>Conflict-free replicated data types for shared state — real-time sync without merge conflicts.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🗃</div>
|
||||
<h3>PostgreSQL</h3>
|
||||
<p>Reliable relational storage for decisions, vote records, and result history.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">⚡</div>
|
||||
<h3>Hono</h3>
|
||||
<p>Ultrafast web framework for the API layer — lightweight, typed, and edge-ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Your Data, Protected -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">Your Data, Protected</h2>
|
||||
<p class="rl-subtext">How rChoices keeps your information safe.</p>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔒</div>
|
||||
<h3>End-to-End Encryption</h3>
|
||||
<span class="rl-badge">Coming Soon</span>
|
||||
<p>All content encrypted before it leaves your device. Not even the server can read it.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🕵</div>
|
||||
<h3>Zero-Knowledge Architecture</h3>
|
||||
<span class="rl-badge">Coming Soon</span>
|
||||
<p>The server processes your requests without ever seeing your data in the clear.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🏠</div>
|
||||
<h3>Self-Hosted</h3>
|
||||
<p>Run on your own infrastructure. Your server, your rules, your data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">(You)rChoices, your voice.</h2>
|
||||
<p class="rl-subtext">Try the demo or create a space to get started.</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rchoices" class="rl-cta-primary">Start Deciding</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rl-back">
|
||||
<a href="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
/**
|
||||
* rFunds — rich landing page body.
|
||||
* Returned by landingPage() in the module export;
|
||||
* the shell wraps it with header, CSS, and analytics.
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
<!-- Hero -->
|
||||
<div class="rl-hero">
|
||||
<span class="rl-tagline">rFunds</span>
|
||||
<h1 class="rl-heading">(You)rFunds, your flow.</h1>
|
||||
<p class="rl-subtitle">Visualize Your Community's Money</p>
|
||||
<p class="rl-subtext">
|
||||
Budget flows, river visualization, and conviction funding.
|
||||
Watch resources move through your community in real time.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rfunds" class="rl-cta-primary" id="ml-primary">View Flows</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">What rFunds Handles</h2>
|
||||
<div class="rl-grid-4">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🌊</div>
|
||||
<h3>Budget Flow Visualization</h3>
|
||||
<p>Animated flow diagram showing how funds move between pools, funnels, and outcomes in real time.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">📊</div>
|
||||
<h3>Treasury Dashboards</h3>
|
||||
<p>Full overview of balances, inflows, outflows, and historical transaction data for your community.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">💡</div>
|
||||
<h3>Conviction-Based Funding</h3>
|
||||
<p>Continuous signaling lets community members express preferences that compound over time.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">💨</div>
|
||||
<h3>River Metaphor</h3>
|
||||
<p>Named budget streams between funding pools with configurable rates, thresholds, and activation rules.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">1</div>
|
||||
<h3>Connect Your Treasury</h3>
|
||||
<p>Link funding pools and define budget streams with rates, thresholds, and allocation rules.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">2</div>
|
||||
<h3>Map Budget Flows Visually</h3>
|
||||
<p>The river visualization animates budget movement in real time — deposits, withdrawals, and funnels.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">3</div>
|
||||
<h3>Decide Funding with Conviction</h3>
|
||||
<p>Community members signal preferences through conviction funding. Resources flow where attention goes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Ecosystem Integration -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Ecosystem Integration</h2>
|
||||
<p class="rl-subtext" style="text-align:center">
|
||||
rFunds connects to other rSpace modules for end-to-end treasury governance.
|
||||
</p>
|
||||
<div class="rl-grid-2" style="max-width:700px;margin:0 auto">
|
||||
<div class="rl-integration">
|
||||
<div class="rl-icon-box">💰</div>
|
||||
<div>
|
||||
<h3><a href="/rwallet" style="color:#14b8a6;text-decoration:none">rWallet</a></h3>
|
||||
<p>On-chain balances and wallet-based treasury tracking for decentralized communities.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-integration">
|
||||
<div class="rl-icon-box">🗳</div>
|
||||
<div>
|
||||
<h3><a href="/rvote" style="color:#14b8a6;text-decoration:none">rVote</a></h3>
|
||||
<p>Governance decisions that direct fund allocation through formal proposal workflows.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Built on Open Source -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
|
||||
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rFunds.</p>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🌊</div>
|
||||
<h3>Flow Service</h3>
|
||||
<p>Custom budget flow engine powering the river visualization and conviction-based allocation logic.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🗃</div>
|
||||
<h3>PostgreSQL</h3>
|
||||
<p>Reliable relational storage for space-flow associations, transaction history, and pool balances.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">⚡</div>
|
||||
<h3>Hono</h3>
|
||||
<p>Ultrafast web framework for the API layer — lightweight, typed, and edge-ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Your Data, Protected -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">Your Data, Protected</h2>
|
||||
<p class="rl-subtext">How rFunds keeps your information safe.</p>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔒</div>
|
||||
<h3>End-to-End Encryption</h3>
|
||||
<span class="rl-badge">Coming Soon</span>
|
||||
<p>All content encrypted before it leaves your device. Not even the server can read it.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🕵</div>
|
||||
<h3>Zero-Knowledge Architecture</h3>
|
||||
<span class="rl-badge">Coming Soon</span>
|
||||
<p>The server processes your requests without ever seeing your data in the clear.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🏠</div>
|
||||
<h3>Self-Hosted</h3>
|
||||
<p>Run on your own infrastructure. Your server, your rules, your data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">(You)rFunds, your flow.</h2>
|
||||
<p class="rl-subtext">Try the demo or create a space to get started.</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rfunds" class="rl-cta-primary">View Flows</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rl-back">
|
||||
<a href="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
/**
|
||||
* Inbox module landing page — static HTML, no React.
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
<!-- Hero -->
|
||||
<div class="rl-hero">
|
||||
<span class="rl-tagline">rInbox</span>
|
||||
<h1 class="rl-heading" style="background:linear-gradient(135deg,#67e8f9,#93c5fd,#c4b5fd);-webkit-background-clip:text;background-clip:text">(You)rInbox, your team's voice.</h1>
|
||||
<p class="rl-subtitle" style="background:linear-gradient(135deg,#67e8f9,#93c5fd,#c4b5fd);-webkit-background-clip:text;background-clip:text">Collaborative Multi-Sig Inbox</p>
|
||||
<p class="rl-subtext">
|
||||
A shared email client where teams read, discuss, and approve messages
|
||||
together — with cryptographic multi-signature workflows before anything gets sent.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rinbox" class="rl-cta-primary" id="ml-primary">Open Inbox</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">What rInbox Handles</h2>
|
||||
<div class="rl-grid-4">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">📧</div>
|
||||
<h3>Shared Team Email</h3>
|
||||
<p>Your team shares one real email address. Every member sees every thread in real time — no forwarding, no BCC chains.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">✅</div>
|
||||
<h3>Multi-Signature Approval</h3>
|
||||
<p>Outbound emails require M-of-N cryptographic signatures before sending. Every approval is auditable — no rogue replies.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">💬</div>
|
||||
<h3>Threaded Discussions</h3>
|
||||
<p>Comment on any thread privately before replying. Tag members, discuss strategy, and reach consensus alongside the original message.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔑</div>
|
||||
<h3>Cryptographic Signing</h3>
|
||||
<p>Every outbound message is cryptographically signed. Full audit trail of approvals, rejections, and edits.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">1</div>
|
||||
<h3>Connect a Mailbox</h3>
|
||||
<p>Your team shares one real email address. Every member sees every thread in real time — no forwarding, no lost context.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">2</div>
|
||||
<h3>Discuss Messages as a Team</h3>
|
||||
<p>Comment on any thread privately before replying. Tag members, discuss strategy, and reach consensus alongside the original message.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">3</div>
|
||||
<h3>Approve and Send with Multi-Sig</h3>
|
||||
<p>Outbound emails require M-of-N cryptographic signatures before sending. Every approval is auditable — no rogue replies.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Collaboration Features -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Collaboration Features</h2>
|
||||
<p class="rl-subtext" style="text-align:center">Built for collective intelligence, not individual productivity.</p>
|
||||
<div class="rl-grid-2" style="margin-top:2rem">
|
||||
<div class="rl-card">
|
||||
<h3 style="color:#06b6d4;margin-bottom:0.75rem">Real-Time Collaboration</h3>
|
||||
<ul class="rl-check-list">
|
||||
<li><strong>Threaded Comments</strong> — discuss any email privately before replying</li>
|
||||
<li><strong>Draft Reviews</strong> — collaborative draft editing with inline suggestions</li>
|
||||
<li><strong>Forwarding Rules</strong> — route incoming messages by sender, subject, or tag</li>
|
||||
<li><strong>Inline Signatures</strong> — per-mailbox signature templates with merge fields</li>
|
||||
<li><strong>Read Receipts</strong> — see who has read each thread and when</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rl-card">
|
||||
<h3 style="color:#8b5cf6;margin-bottom:0.75rem">Approval Workflows</h3>
|
||||
<ul class="rl-check-list">
|
||||
<li><strong>Multi-sig sending</strong> — outbound emails require M-of-N approval</li>
|
||||
<li><strong>Configurable thresholds</strong> — set approval requirements per mailbox</li>
|
||||
<li><strong>Audit trail</strong> — every approval, rejection, and edit is logged</li>
|
||||
<li><strong>Role-based access</strong> — Admin, Reviewer, Approver, Viewer, Bot</li>
|
||||
<li><strong>Deadline alerts</strong> — notifications when approvals are waiting</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Team Roles -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Team Roles</h2>
|
||||
<p class="rl-subtext" style="text-align:center">Fine-grained access control for every team member.</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem;margin-top:2rem">
|
||||
<div class="rl-card rl-card--center" style="padding:1.25rem">
|
||||
<p style="font-size:0.875rem;font-weight:600;color:#06b6d4">Admin</p>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin-top:0.35rem">Full control, manage members</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center" style="padding:1.25rem">
|
||||
<p style="font-size:0.875rem;font-weight:600;color:#3b82f6">Reviewer</p>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin-top:0.35rem">Read, comment, suggest edits</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center" style="padding:1.25rem">
|
||||
<p style="font-size:0.875rem;font-weight:600;color:#8b5cf6">Approver</p>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin-top:0.35rem">Sign outbound emails</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center" style="padding:1.25rem">
|
||||
<p style="font-size:0.875rem;font-weight:600;color:#94a3b8">Viewer</p>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin-top:0.35rem">Read-only access</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center" style="padding:1.25rem">
|
||||
<p style="font-size:0.875rem;font-weight:600;color:#94a3b8">Bot</p>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin-top:0.35rem">Automated integrations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Built on Open Source -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
|
||||
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rInbox.</p>
|
||||
<div class="rl-grid-4">
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>ImapFlow</h3>
|
||||
<p>High-performance IMAP client for real-time email synchronization.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>Mailparser</h3>
|
||||
<p>Email parsing and MIME handling for reliable message processing.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>Gnosis Safe</h3>
|
||||
<p>Multisig approval protocol adapted for email signing workflows.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>PostgreSQL</h3>
|
||||
<p>Threads, approvals, and audit trails stored in a reliable relational database.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Your Data, Protected -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">Your Data, Protected</h2>
|
||||
<p class="rl-subtext">How rInbox keeps your information safe.</p>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔒</div>
|
||||
<h3>End-to-End Encryption</h3>
|
||||
<span class="rl-badge">Coming Soon</span>
|
||||
<p>All content encrypted before it leaves your device. Not even the server can read it.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🕵</div>
|
||||
<h3>Zero-Knowledge Architecture</h3>
|
||||
<span class="rl-badge">Coming Soon</span>
|
||||
<p>The server processes your requests without ever seeing your data in the clear.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🏠</div>
|
||||
<h3>Self-Hosted</h3>
|
||||
<p>Run on your own infrastructure. Your server, your rules, your data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">(You)rInbox, your team's voice.</h2>
|
||||
<p class="rl-subtext">Try the demo or create a space to get started.</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rinbox" class="rl-cta-primary">Open Inbox</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rl-back">
|
||||
<a href="/">← Back to rSpace</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
/**
|
||||
* <folk-map-viewer> — real-time location sharing map.
|
||||
*
|
||||
* Creates/joins map rooms, shows participant locations on a map,
|
||||
* and provides location sharing controls.
|
||||
*/
|
||||
|
||||
class FolkMapViewer extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
private room = "";
|
||||
private view: "lobby" | "map" = "lobby";
|
||||
private rooms: string[] = [];
|
||||
private loading = false;
|
||||
private error = "";
|
||||
private syncStatus: "disconnected" | "connected" = "disconnected";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.room = this.getAttribute("room") || "";
|
||||
if (this.room) {
|
||||
this.view = "map";
|
||||
}
|
||||
this.checkSyncHealth();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/maps/);
|
||||
return match ? `/${match[1]}/maps` : "";
|
||||
}
|
||||
|
||||
private async checkSyncHealth() {
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/health`, { signal: AbortSignal.timeout(3000) });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.syncStatus = data.sync !== false ? "connected" : "disconnected";
|
||||
}
|
||||
} catch {
|
||||
this.syncStatus = "disconnected";
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async loadStats() {
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/stats`, { signal: AbortSignal.timeout(3000) });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.rooms = Object.keys(data.rooms || {});
|
||||
}
|
||||
} catch {
|
||||
this.rooms = [];
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
private joinRoom(slug: string) {
|
||||
this.room = slug;
|
||||
this.view = "map";
|
||||
this.render();
|
||||
}
|
||||
|
||||
private createRoom() {
|
||||
const name = prompt("Room name (slug):");
|
||||
if (!name?.trim()) return;
|
||||
const slug = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||||
this.joinRoom(slug);
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
||||
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; }
|
||||
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
|
||||
}
|
||||
.status-connected { background: #22c55e; }
|
||||
.status-disconnected { background: #ef4444; }
|
||||
|
||||
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
|
||||
.rapp-nav__btn:hover { background: #6366f1; }
|
||||
|
||||
.room-card {
|
||||
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
|
||||
padding: 16px; margin-bottom: 12px; cursor: pointer; transition: border-color 0.2s;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.room-card:hover { border-color: #555; }
|
||||
.room-icon { font-size: 24px; }
|
||||
.room-name { font-size: 15px; font-weight: 600; }
|
||||
|
||||
.map-container {
|
||||
width: 100%; height: 500px; border-radius: 10px;
|
||||
background: #1a1a2e; border: 1px solid #333;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.map-placeholder {
|
||||
text-align: center; color: #666; padding: 40px;
|
||||
}
|
||||
.map-placeholder p { margin: 8px 0; }
|
||||
|
||||
.controls {
|
||||
display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.ctrl-btn {
|
||||
padding: 8px 16px; border-radius: 8px; border: 1px solid #444;
|
||||
background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.ctrl-btn:hover { border-color: #666; }
|
||||
.ctrl-btn.sharing {
|
||||
border-color: #22c55e; color: #22c55e; animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.share-link {
|
||||
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
|
||||
padding: 12px; margin-top: 12px; font-family: monospace; font-size: 12px;
|
||||
color: #aaa; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.share-link span { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||
.copy-btn {
|
||||
padding: 4px 10px; border-radius: 4px; border: 1px solid #444;
|
||||
background: #2a2a3e; color: #ccc; cursor: pointer; font-size: 11px;
|
||||
}
|
||||
|
||||
.empty { text-align: center; color: #666; padding: 40px; }
|
||||
</style>
|
||||
|
||||
${this.error ? `<div style="color:#ef5350;text-align:center;padding:12px">${this.esc(this.error)}</div>` : ""}
|
||||
${this.view === "lobby" ? this.renderLobby() : this.renderMap()}
|
||||
`;
|
||||
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
private renderLobby(): string {
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Map Rooms</span>
|
||||
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
|
||||
<span style="font-size:12px;color:#888;margin-right:12px">${this.syncStatus === "connected" ? "Sync online" : "Sync offline"}</span>
|
||||
<button class="rapp-nav__btn" id="create-room">+ New Room</button>
|
||||
</div>
|
||||
|
||||
${this.rooms.length > 0 ? this.rooms.map((r) => `
|
||||
<div class="room-card" data-room="${r}">
|
||||
<span class="room-icon">🗺</span>
|
||||
<span class="room-name">${this.esc(r)}</span>
|
||||
</div>
|
||||
`).join("") : ""}
|
||||
|
||||
<div class="empty">
|
||||
<p style="font-size:16px;margin-bottom:8px">Create or join a map room to share locations</p>
|
||||
<p style="font-size:13px">Share the room link with friends to see each other on the map in real-time</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMap(): string {
|
||||
const shareUrl = `${window.location.origin}/${this.space}/maps/${this.room}`;
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-back="lobby">← Rooms</button>
|
||||
<span class="rapp-nav__title">🗺 ${this.esc(this.room)}</span>
|
||||
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<div class="map-placeholder">
|
||||
<p style="font-size:48px">🌍</p>
|
||||
<p style="font-size:16px">Map Room: <strong>${this.esc(this.room)}</strong></p>
|
||||
<p>Connect the MapLibre GL library to display the interactive map.</p>
|
||||
<p style="font-size:12px;color:#555">WebSocket sync: ${this.syncStatus}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="ctrl-btn" id="share-location">Share My Location</button>
|
||||
<button class="ctrl-btn" id="copy-link">Copy Room Link</button>
|
||||
</div>
|
||||
|
||||
<div class="share-link">
|
||||
<span>${this.esc(shareUrl)}</span>
|
||||
<button class="copy-btn" id="copy-url">Copy</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private attachListeners() {
|
||||
this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom());
|
||||
|
||||
this.shadow.querySelectorAll("[data-room]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const room = (el as HTMLElement).dataset.room!;
|
||||
this.joinRoom(room);
|
||||
});
|
||||
});
|
||||
|
||||
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
this.view = "lobby";
|
||||
this.loadStats();
|
||||
});
|
||||
});
|
||||
|
||||
this.shadow.getElementById("share-location")?.addEventListener("click", () => {
|
||||
if ("geolocation" in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const btn = this.shadow.getElementById("share-location");
|
||||
if (btn) {
|
||||
btn.textContent = `Location: ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`;
|
||||
btn.classList.add("sharing");
|
||||
}
|
||||
},
|
||||
() => {
|
||||
this.error = "Location access denied";
|
||||
this.render();
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const copyUrl = this.shadow.getElementById("copy-url") || this.shadow.getElementById("copy-link");
|
||||
copyUrl?.addEventListener("click", () => {
|
||||
const url = `${window.location.origin}/${this.space}/maps/${this.room}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
if (copyUrl) copyUrl.textContent = "Copied!";
|
||||
setTimeout(() => { if (copyUrl) copyUrl.textContent = "Copy"; }, 2000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-map-viewer", FolkMapViewer);
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
/**
|
||||
* <folk-graph-viewer> — community relationship graph.
|
||||
*
|
||||
* Displays network nodes (people, companies, opportunities)
|
||||
* and edges in a force-directed layout with search and filtering.
|
||||
*/
|
||||
|
||||
class FolkGraphViewer extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
private workspaces: any[] = [];
|
||||
private info: any = null;
|
||||
private filter: "all" | "person" | "company" | "opportunity" = "all";
|
||||
private searchQuery = "";
|
||||
private error = "";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.loadData();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/network/);
|
||||
return match ? `/${match[1]}/network` : "";
|
||||
}
|
||||
|
||||
private async loadData() {
|
||||
const base = this.getApiBase();
|
||||
try {
|
||||
const [wsRes, infoRes] = await Promise.all([
|
||||
fetch(`${base}/api/workspaces`),
|
||||
fetch(`${base}/api/info`),
|
||||
]);
|
||||
if (wsRes.ok) this.workspaces = await wsRes.json();
|
||||
if (infoRes.ok) this.info = await infoRes.json();
|
||||
} catch { /* offline */ }
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
|
||||
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
|
||||
.search-input {
|
||||
border: 1px solid #333; border-radius: 8px; padding: 8px 12px;
|
||||
background: #16161e; color: #e0e0e0; font-size: 13px; width: 200px; outline: none;
|
||||
}
|
||||
.search-input:focus { border-color: #6366f1; }
|
||||
.filter-btn {
|
||||
padding: 6px 12px; border-radius: 8px; border: 1px solid #333;
|
||||
background: #16161e; color: #888; cursor: pointer; font-size: 12px;
|
||||
}
|
||||
.filter-btn:hover { border-color: #555; }
|
||||
.filter-btn.active { border-color: #6366f1; color: #6366f1; }
|
||||
|
||||
.graph-canvas {
|
||||
width: 100%; height: 500px; border-radius: 12px;
|
||||
background: #0d0d14; border: 1px solid #222;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.placeholder { text-align: center; color: #555; padding: 40px; }
|
||||
.placeholder p { margin: 6px 0; }
|
||||
|
||||
.workspace-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.ws-card {
|
||||
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
|
||||
padding: 16px; cursor: pointer; transition: border-color 0.2s;
|
||||
}
|
||||
.ws-card:hover { border-color: #555; }
|
||||
.ws-name { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
||||
.ws-meta { font-size: 12px; color: #888; }
|
||||
|
||||
.legend { display: flex; gap: 16px; margin-top: 12px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #888; }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.dot-person { background: #3b82f6; }
|
||||
.dot-company { background: #22c55e; }
|
||||
.dot-opportunity { background: #f59e0b; }
|
||||
|
||||
.stats { display: flex; gap: 20px; margin-bottom: 16px; }
|
||||
.stat { text-align: center; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; color: #6366f1; }
|
||||
.stat-label { font-size: 11px; color: #888; }
|
||||
</style>
|
||||
|
||||
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
|
||||
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Network Graph</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<input class="search-input" type="text" placeholder="Search nodes..." id="search-input" value="${this.esc(this.searchQuery)}">
|
||||
${(["all", "person", "company", "opportunity"] as const).map(f =>
|
||||
`<button class="filter-btn ${this.filter === f ? "active" : ""}" data-filter="${f}">${f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}s</button>`
|
||||
).join("")}
|
||||
</div>
|
||||
|
||||
<div class="graph-canvas">
|
||||
<div class="placeholder">
|
||||
<p style="font-size:48px">🕸️</p>
|
||||
<p style="font-size:16px">Community Relationship Graph</p>
|
||||
<p>Connect the force-directed layout engine to visualize your network.</p>
|
||||
<p style="font-size:12px;color:#444">Automerge CRDT sync + d3-force layout</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item"><span class="legend-dot dot-person"></span> People</div>
|
||||
<div class="legend-item"><span class="legend-dot dot-company"></span> Companies</div>
|
||||
<div class="legend-item"><span class="legend-dot dot-opportunity"></span> Opportunities</div>
|
||||
</div>
|
||||
|
||||
${this.workspaces.length > 0 ? `
|
||||
<div style="margin-top:20px;font-size:14px;font-weight:600;color:#aaa">Workspaces</div>
|
||||
<div class="workspace-list">
|
||||
${this.workspaces.map(ws => `
|
||||
<div class="ws-card">
|
||||
<div class="ws-name">${this.esc(ws.name || ws.slug)}</div>
|
||||
<div class="ws-meta">${ws.nodeCount || 0} nodes · ${ws.edgeCount || 0} edges</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
` : ""}
|
||||
`;
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
private attachListeners() {
|
||||
this.shadow.querySelectorAll("[data-filter]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
this.filter = (el as HTMLElement).dataset.filter as any;
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
this.shadow.getElementById("search-input")?.addEventListener("input", (e) => {
|
||||
this.searchQuery = (e.target as HTMLInputElement).value;
|
||||
});
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-graph-viewer", FolkGraphViewer);
|
||||
|
|
@ -1,714 +0,0 @@
|
|||
/**
|
||||
* <folk-notes-app> — notebook and note management.
|
||||
*
|
||||
* Browse notebooks, create/edit notes with rich text,
|
||||
* search, tag management.
|
||||
*
|
||||
* Notebook list: REST (GET /api/notebooks)
|
||||
* Notebook detail + notes: Automerge sync via WebSocket
|
||||
* Search: REST (GET /api/notes?q=...)
|
||||
*/
|
||||
|
||||
import * as Automerge from '@automerge/automerge';
|
||||
|
||||
interface Notebook {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
cover_color: string;
|
||||
note_count: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
content_plain: string;
|
||||
type: string;
|
||||
tags: string[] | null;
|
||||
is_pinned: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Shape of Automerge notebook doc (matches PG→Automerge migration) */
|
||||
interface NotebookDoc {
|
||||
meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number };
|
||||
notebook: {
|
||||
id: string; title: string; slug: string; description: string;
|
||||
coverColor: string; isPublic: boolean; createdAt: number; updatedAt: number;
|
||||
};
|
||||
items: Record<string, {
|
||||
id: string; notebookId: string; title: string; content: string;
|
||||
contentPlain: string; type: string; tags: string[]; isPinned: boolean;
|
||||
sortOrder: number; createdAt: number; updatedAt: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
class FolkNotesApp extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
private view: "notebooks" | "notebook" | "note" = "notebooks";
|
||||
private notebooks: Notebook[] = [];
|
||||
private selectedNotebook: (Notebook & { notes: Note[] }) | null = null;
|
||||
private selectedNote: Note | null = null;
|
||||
private searchQuery = "";
|
||||
private searchResults: Note[] = [];
|
||||
private loading = false;
|
||||
private error = "";
|
||||
|
||||
// Automerge sync state
|
||||
private ws: WebSocket | null = null;
|
||||
private doc: Automerge.Doc<NotebookDoc> | null = null;
|
||||
private syncState: Automerge.SyncState = Automerge.initSyncState();
|
||||
private subscribedDocId: string | null = null;
|
||||
private syncConnected = false;
|
||||
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.connectSync();
|
||||
this.loadNotebooks();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.disconnectSync();
|
||||
}
|
||||
|
||||
// ── WebSocket Sync ──
|
||||
|
||||
private connectSync() {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${proto}//${location.host}/ws/${this.space}`;
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.syncConnected = true;
|
||||
// Keepalive ping every 30s
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
|
||||
}
|
||||
}, 30000);
|
||||
// If we had a pending subscription, re-subscribe
|
||||
if (this.subscribedDocId && this.doc) {
|
||||
this.subscribeNotebook(this.subscribedDocId.split(":").pop()!);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === "sync" && msg.docId === this.subscribedDocId) {
|
||||
this.handleSyncMessage(new Uint8Array(msg.data));
|
||||
}
|
||||
// pong and other messages are ignored
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.syncConnected = false;
|
||||
if (this.pingInterval) clearInterval(this.pingInterval);
|
||||
// Reconnect after 3s
|
||||
setTimeout(() => {
|
||||
if (this.isConnected) this.connectSync();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose will fire after this
|
||||
};
|
||||
}
|
||||
|
||||
private disconnectSync() {
|
||||
if (this.pingInterval) clearInterval(this.pingInterval);
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null; // prevent reconnect
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.syncConnected = false;
|
||||
}
|
||||
|
||||
private handleSyncMessage(syncMsg: Uint8Array) {
|
||||
if (!this.doc) return;
|
||||
|
||||
const [newDoc, newSyncState] = Automerge.receiveSyncMessage(
|
||||
this.doc, this.syncState, syncMsg
|
||||
);
|
||||
this.doc = newDoc;
|
||||
this.syncState = newSyncState;
|
||||
|
||||
// Send reply if needed
|
||||
const [nextState, reply] = Automerge.generateSyncMessage(this.doc, this.syncState);
|
||||
this.syncState = nextState;
|
||||
if (reply && this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: "sync", docId: this.subscribedDocId,
|
||||
data: Array.from(reply),
|
||||
}));
|
||||
}
|
||||
|
||||
this.renderFromDoc();
|
||||
}
|
||||
|
||||
private subscribeNotebook(notebookId: string) {
|
||||
this.subscribedDocId = `${this.space}:notes:notebooks:${notebookId}`;
|
||||
this.doc = Automerge.init<NotebookDoc>();
|
||||
this.syncState = Automerge.initSyncState();
|
||||
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
// Send subscribe
|
||||
this.ws.send(JSON.stringify({ type: "subscribe", docIds: [this.subscribedDocId] }));
|
||||
|
||||
// Send initial sync message to kick off handshake
|
||||
const [s, m] = Automerge.generateSyncMessage(this.doc, this.syncState);
|
||||
this.syncState = s;
|
||||
if (m) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: "sync", docId: this.subscribedDocId,
|
||||
data: Array.from(m),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private unsubscribeNotebook() {
|
||||
if (this.subscribedDocId && this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: "unsubscribe", docIds: [this.subscribedDocId] }));
|
||||
}
|
||||
this.subscribedDocId = null;
|
||||
this.doc = null;
|
||||
this.syncState = Automerge.initSyncState();
|
||||
}
|
||||
|
||||
/** Extract notebook + notes from Automerge doc into component state */
|
||||
private renderFromDoc() {
|
||||
if (!this.doc) return;
|
||||
|
||||
const nb = this.doc.notebook;
|
||||
const items = this.doc.items;
|
||||
|
||||
if (!nb) return; // doc not yet synced
|
||||
|
||||
// Build notebook data from doc
|
||||
const notes: Note[] = [];
|
||||
if (items) {
|
||||
for (const [, item] of Object.entries(items)) {
|
||||
notes.push({
|
||||
id: item.id,
|
||||
title: item.title || "Untitled",
|
||||
content: item.content || "",
|
||||
content_plain: item.contentPlain || "",
|
||||
type: item.type || "NOTE",
|
||||
tags: item.tags?.length ? Array.from(item.tags) : null,
|
||||
is_pinned: item.isPinned || false,
|
||||
created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
|
||||
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: pinned first, then by sort order, then by updated_at desc
|
||||
notes.sort((a, b) => {
|
||||
if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1;
|
||||
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
|
||||
});
|
||||
|
||||
this.selectedNotebook = {
|
||||
id: nb.id,
|
||||
title: nb.title,
|
||||
description: nb.description || "",
|
||||
cover_color: nb.coverColor || "#3b82f6",
|
||||
note_count: String(notes.length),
|
||||
updated_at: nb.updatedAt ? new Date(nb.updatedAt).toISOString() : new Date().toISOString(),
|
||||
notes,
|
||||
};
|
||||
|
||||
// If viewing a specific note, update it from doc too
|
||||
if (this.view === "note" && this.selectedNote) {
|
||||
const noteItem = items?.[this.selectedNote.id];
|
||||
if (noteItem) {
|
||||
this.selectedNote = {
|
||||
id: noteItem.id,
|
||||
title: noteItem.title || "Untitled",
|
||||
content: noteItem.content || "",
|
||||
content_plain: noteItem.contentPlain || "",
|
||||
type: noteItem.type || "NOTE",
|
||||
tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null,
|
||||
is_pinned: noteItem.isPinned || false,
|
||||
created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
|
||||
updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
// ── Automerge mutations ──
|
||||
|
||||
private createNoteViaSync() {
|
||||
if (!this.doc || !this.selectedNotebook) return;
|
||||
|
||||
const noteId = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => {
|
||||
if (!d.items) (d as any).items = {};
|
||||
d.items[noteId] = {
|
||||
id: noteId,
|
||||
notebookId: this.selectedNotebook!.id,
|
||||
title: "Untitled Note",
|
||||
content: "",
|
||||
contentPlain: "",
|
||||
type: "NOTE",
|
||||
tags: [],
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
|
||||
this.sendSyncAfterChange();
|
||||
this.renderFromDoc();
|
||||
|
||||
// Open the new note for editing
|
||||
this.selectedNote = {
|
||||
id: noteId, title: "Untitled Note", content: "", content_plain: "",
|
||||
type: "NOTE", tags: null, is_pinned: false,
|
||||
created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
|
||||
};
|
||||
this.view = "note";
|
||||
this.render();
|
||||
}
|
||||
|
||||
private updateNoteField(noteId: string, field: string, value: string) {
|
||||
if (!this.doc || !this.doc.items?.[noteId]) return;
|
||||
|
||||
this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => {
|
||||
(d.items[noteId] as any)[field] = value;
|
||||
d.items[noteId].updatedAt = Date.now();
|
||||
});
|
||||
|
||||
this.sendSyncAfterChange();
|
||||
}
|
||||
|
||||
private sendSyncAfterChange() {
|
||||
if (!this.doc || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const [newState, msg] = Automerge.generateSyncMessage(this.doc, this.syncState);
|
||||
this.syncState = newState;
|
||||
if (msg) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: "sync", docId: this.subscribedDocId,
|
||||
data: Array.from(msg),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ── REST (notebook list + search) ──
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/notes/);
|
||||
return match ? `/${match[1]}/notes` : "";
|
||||
}
|
||||
|
||||
private async loadNotebooks() {
|
||||
this.loading = true;
|
||||
this.render();
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/notebooks`);
|
||||
const data = await res.json();
|
||||
this.notebooks = data.notebooks || [];
|
||||
} catch {
|
||||
this.error = "Failed to load notebooks";
|
||||
}
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async loadNotebook(id: string) {
|
||||
this.loading = true;
|
||||
this.render();
|
||||
|
||||
// Unsubscribe from any previous notebook
|
||||
this.unsubscribeNotebook();
|
||||
|
||||
// Subscribe to the new notebook via Automerge
|
||||
this.subscribeNotebook(id);
|
||||
|
||||
// Set a timeout — if doc doesn't arrive in 5s, fall back to REST
|
||||
setTimeout(() => {
|
||||
if (this.loading && this.view === "notebook") {
|
||||
this.loadNotebookREST(id);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/** REST fallback for notebook detail */
|
||||
private async loadNotebookREST(id: string) {
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/notebooks/${id}`);
|
||||
this.selectedNotebook = await res.json();
|
||||
} catch {
|
||||
this.error = "Failed to load notebook";
|
||||
}
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private loadNote(id: string) {
|
||||
// Note is already in the Automerge doc — just select it
|
||||
if (this.doc?.items?.[id]) {
|
||||
const item = this.doc.items[id];
|
||||
this.selectedNote = {
|
||||
id: item.id,
|
||||
title: item.title || "Untitled",
|
||||
content: item.content || "",
|
||||
content_plain: item.contentPlain || "",
|
||||
type: item.type || "NOTE",
|
||||
tags: item.tags?.length ? Array.from(item.tags) : null,
|
||||
is_pinned: item.isPinned || false,
|
||||
created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
|
||||
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
|
||||
};
|
||||
} else if (this.selectedNotebook?.notes) {
|
||||
// Fallback: find in REST-loaded data
|
||||
this.selectedNote = this.selectedNotebook.notes.find(n => n.id === id) || null;
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async searchNotes(query: string) {
|
||||
if (!query.trim()) {
|
||||
this.searchResults = [];
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/notes?q=${encodeURIComponent(query)}`);
|
||||
const data = await res.json();
|
||||
this.searchResults = data.notes || [];
|
||||
} catch {
|
||||
this.searchResults = [];
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async createNotebook() {
|
||||
const title = prompt("Notebook name:");
|
||||
if (!title?.trim()) return;
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
await fetch(`${base}/api/notebooks`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
await this.loadNotebooks();
|
||||
} catch {
|
||||
this.error = "Failed to create notebook";
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private getNoteIcon(type: string): string {
|
||||
switch (type) {
|
||||
case "NOTE": return "📝";
|
||||
case "CODE": return "💻";
|
||||
case "BOOKMARK": return "🔗";
|
||||
case "IMAGE": return "🖼";
|
||||
case "AUDIO": return "🎤";
|
||||
case "FILE": return "📎";
|
||||
case "CLIP": return "✂️";
|
||||
default: return "📄";
|
||||
}
|
||||
}
|
||||
|
||||
private formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) return "Today";
|
||||
if (diffDays === 1) return "Yesterday";
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
||||
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; }
|
||||
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
|
||||
.rapp-nav__btn:hover { background: #6366f1; }
|
||||
|
||||
.search-bar {
|
||||
width: 100%; padding: 10px 14px; border-radius: 8px;
|
||||
border: 1px solid #444; background: #2a2a3e; color: #e0e0e0;
|
||||
font-size: 14px; margin-bottom: 16px;
|
||||
}
|
||||
.search-bar:focus { border-color: #6366f1; outline: none; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||||
.notebook-card {
|
||||
border-radius: 10px; padding: 16px; cursor: pointer;
|
||||
border: 2px solid transparent; transition: border-color 0.2s;
|
||||
min-height: 120px; display: flex; flex-direction: column; justify-content: space-between;
|
||||
}
|
||||
.notebook-card:hover { border-color: rgba(255,255,255,0.2); }
|
||||
.notebook-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
||||
.notebook-meta { font-size: 12px; opacity: 0.7; }
|
||||
|
||||
.note-item {
|
||||
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
|
||||
padding: 12px 16px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.2s;
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
}
|
||||
.note-item:hover { border-color: #555; }
|
||||
.note-icon { font-size: 20px; flex-shrink: 0; }
|
||||
.note-body { flex: 1; min-width: 0; }
|
||||
.note-title { font-size: 14px; font-weight: 600; }
|
||||
.note-preview { font-size: 12px; color: #888; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.note-meta { font-size: 11px; color: #666; margin-top: 4px; display: flex; gap: 8px; }
|
||||
.tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: #333; color: #aaa; font-size: 10px; }
|
||||
.pinned { color: #f59e0b; }
|
||||
|
||||
.note-content {
|
||||
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
|
||||
padding: 20px; font-size: 14px; line-height: 1.6;
|
||||
}
|
||||
.note-content[contenteditable] { outline: none; min-height: 100px; cursor: text; }
|
||||
.note-content[contenteditable]:focus { border-color: #6366f1; }
|
||||
|
||||
.editable-title {
|
||||
background: transparent; border: none; color: inherit; font: inherit;
|
||||
font-size: 18px; font-weight: 600; width: 100%; outline: none;
|
||||
padding: 0; flex: 1;
|
||||
}
|
||||
.editable-title:focus { border-bottom: 1px solid #6366f1; }
|
||||
|
||||
.sync-badge {
|
||||
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||
margin-left: 8px; vertical-align: middle;
|
||||
}
|
||||
.sync-badge.connected { background: #10b981; }
|
||||
.sync-badge.disconnected { background: #ef4444; }
|
||||
|
||||
.empty { text-align: center; color: #666; padding: 40px; }
|
||||
.loading { text-align: center; color: #888; padding: 40px; }
|
||||
.error { text-align: center; color: #ef5350; padding: 20px; }
|
||||
</style>
|
||||
|
||||
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
|
||||
${this.loading ? '<div class="loading">Loading...</div>' : ""}
|
||||
${!this.loading ? this.renderView() : ""}
|
||||
`;
|
||||
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
private renderView(): string {
|
||||
if (this.view === "note" && this.selectedNote) return this.renderNote();
|
||||
if (this.view === "notebook" && this.selectedNotebook) return this.renderNotebook();
|
||||
return this.renderNotebooks();
|
||||
}
|
||||
|
||||
private renderNotebooks(): string {
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Notebooks</span>
|
||||
<button class="rapp-nav__btn" id="create-notebook">+ New Notebook</button>
|
||||
</div>
|
||||
<input class="search-bar" type="text" placeholder="Search notes..." id="search-input" value="${this.esc(this.searchQuery)}">
|
||||
|
||||
${this.searchQuery && this.searchResults.length > 0 ? `
|
||||
<div style="margin-bottom:16px;font-size:13px;color:#888">${this.searchResults.length} results for "${this.esc(this.searchQuery)}"</div>
|
||||
${this.searchResults.map((n) => this.renderNoteItem(n)).join("")}
|
||||
` : ""}
|
||||
|
||||
${!this.searchQuery ? `
|
||||
<div class="grid">
|
||||
${this.notebooks.map((nb) => `
|
||||
<div class="notebook-card" data-notebook="${nb.id}"
|
||||
style="background:${nb.cover_color}33;border-color:${nb.cover_color}55">
|
||||
<div>
|
||||
<div class="notebook-title">${this.esc(nb.title)}</div>
|
||||
<div class="notebook-meta">${this.esc(nb.description || "")}</div>
|
||||
</div>
|
||||
<div class="notebook-meta">${nb.note_count} notes · ${this.formatDate(nb.updated_at)}</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
${this.notebooks.length === 0 ? '<div class="empty">No notebooks yet. Create one to get started.</div>' : ""}
|
||||
` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNotebook(): string {
|
||||
const nb = this.selectedNotebook!;
|
||||
const syncBadge = this.subscribedDocId
|
||||
? `<span class="sync-badge ${this.syncConnected ? "connected" : "disconnected"}" title="${this.syncConnected ? "Live sync" : "Reconnecting..."}"></span>`
|
||||
: "";
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-back="notebooks">← Notebooks</button>
|
||||
<span class="rapp-nav__title" style="color:${nb.cover_color}">${this.esc(nb.title)}${syncBadge}</span>
|
||||
<button class="rapp-nav__btn" id="create-note">+ New Note</button>
|
||||
</div>
|
||||
${nb.notes && nb.notes.length > 0
|
||||
? nb.notes.map((n) => this.renderNoteItem(n)).join("")
|
||||
: '<div class="empty">No notes in this notebook.</div>'
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNoteItem(n: Note): string {
|
||||
return `
|
||||
<div class="note-item" data-note="${n.id}">
|
||||
<span class="note-icon">${this.getNoteIcon(n.type)}</span>
|
||||
<div class="note-body">
|
||||
<div class="note-title">${n.is_pinned ? '<span class="pinned">📌</span> ' : ""}${this.esc(n.title)}</div>
|
||||
<div class="note-preview">${this.esc(n.content_plain || "")}</div>
|
||||
<div class="note-meta">
|
||||
<span>${this.formatDate(n.updated_at)}</span>
|
||||
<span>${n.type}</span>
|
||||
${n.tags ? n.tags.map((t) => `<span class="tag">${this.esc(t)}</span>`).join("") : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNote(): string {
|
||||
const n = this.selectedNote!;
|
||||
const isAutomerge = !!(this.doc?.items?.[n.id]);
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-back="${this.selectedNotebook ? "notebook" : "notebooks"}">← ${this.selectedNotebook ? this.esc(this.selectedNotebook.title) : "Notebooks"}</button>
|
||||
${isAutomerge
|
||||
? `<input class="editable-title" id="note-title-input" value="${this.esc(n.title)}" placeholder="Note title...">`
|
||||
: `<span class="rapp-nav__title">${this.getNoteIcon(n.type)} ${this.esc(n.title)}</span>`
|
||||
}
|
||||
</div>
|
||||
<div class="note-content" ${isAutomerge ? 'contenteditable="true" id="note-content-editable"' : ""}>${n.content || '<em style="color:#666">Empty note</em>'}</div>
|
||||
<div style="margin-top:12px;font-size:12px;color:#666;display:flex;gap:12px">
|
||||
<span>Type: ${n.type}</span>
|
||||
<span>Created: ${this.formatDate(n.created_at)}</span>
|
||||
<span>Updated: ${this.formatDate(n.updated_at)}</span>
|
||||
${n.tags ? n.tags.map((t) => `<span class="tag">${this.esc(t)}</span>`).join("") : ""}
|
||||
${isAutomerge ? '<span style="color:#10b981">Live</span>' : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private attachListeners() {
|
||||
// Create notebook
|
||||
this.shadow.getElementById("create-notebook")?.addEventListener("click", () => this.createNotebook());
|
||||
|
||||
// Create note (Automerge)
|
||||
this.shadow.getElementById("create-note")?.addEventListener("click", () => this.createNoteViaSync());
|
||||
|
||||
// Search
|
||||
const searchInput = this.shadow.getElementById("search-input") as HTMLInputElement;
|
||||
let searchTimeout: any;
|
||||
searchInput?.addEventListener("input", () => {
|
||||
clearTimeout(searchTimeout);
|
||||
this.searchQuery = searchInput.value;
|
||||
searchTimeout = setTimeout(() => this.searchNotes(this.searchQuery), 300);
|
||||
});
|
||||
|
||||
// Notebook cards
|
||||
this.shadow.querySelectorAll("[data-notebook]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const id = (el as HTMLElement).dataset.notebook!;
|
||||
this.view = "notebook";
|
||||
this.loadNotebook(id);
|
||||
});
|
||||
});
|
||||
|
||||
// Note items
|
||||
this.shadow.querySelectorAll("[data-note]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const id = (el as HTMLElement).dataset.note!;
|
||||
this.view = "note";
|
||||
this.loadNote(id);
|
||||
});
|
||||
});
|
||||
|
||||
// Back buttons
|
||||
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const target = (el as HTMLElement).dataset.back;
|
||||
if (target === "notebooks") {
|
||||
this.view = "notebooks";
|
||||
this.unsubscribeNotebook();
|
||||
this.selectedNotebook = null;
|
||||
this.selectedNote = null;
|
||||
this.render();
|
||||
}
|
||||
else if (target === "notebook") { this.view = "notebook"; this.render(); }
|
||||
});
|
||||
});
|
||||
|
||||
// Editable note title (debounced)
|
||||
const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement;
|
||||
if (titleInput && this.selectedNote) {
|
||||
let titleTimeout: any;
|
||||
const noteId = this.selectedNote.id;
|
||||
titleInput.addEventListener("input", () => {
|
||||
clearTimeout(titleTimeout);
|
||||
titleTimeout = setTimeout(() => {
|
||||
this.updateNoteField(noteId, "title", titleInput.value);
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
// Editable note content (debounced)
|
||||
const contentEl = this.shadow.getElementById("note-content-editable");
|
||||
if (contentEl && this.selectedNote) {
|
||||
let contentTimeout: any;
|
||||
const noteId = this.selectedNote.id;
|
||||
contentEl.addEventListener("input", () => {
|
||||
clearTimeout(contentTimeout);
|
||||
contentTimeout = setTimeout(() => {
|
||||
const html = contentEl.innerHTML;
|
||||
this.updateNoteField(noteId, "content", html);
|
||||
// Also update plain text
|
||||
const plain = contentEl.textContent?.trim() || "";
|
||||
this.updateNoteField(noteId, "contentPlain", plain);
|
||||
}, 800);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-notes-app", FolkNotesApp);
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
/**
|
||||
* rPubs landing page — community pocket press.
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
<!-- Hero -->
|
||||
<div class="rl-hero">
|
||||
<span class="rl-tagline">rPubs</span>
|
||||
<h1 class="rl-heading">(You)rPubs, your press.</h1>
|
||||
<p class="rl-subtitle">Write it. Press it. Share it.</p>
|
||||
<p class="rl-subtext">
|
||||
Drop in a markdown document, pick a pocket format, and get a print-ready PDF in seconds.
|
||||
Group up with other authors to unlock bulk pricing through collaborative print runs.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rpubs" class="rl-cta-primary" id="ml-primary">Try the Press</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">What rPubs Handles</h2>
|
||||
<div class="rl-grid-4">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">📄</div>
|
||||
<h3>Markdown → PDF</h3>
|
||||
<p>Drop in markdown or rich text. rPubs typesets it with Typst and produces a print-ready PDF in seconds.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">📖</div>
|
||||
<h3>4 Pocket Formats</h3>
|
||||
<p>A7 Pocket, Quarter Letter, A6 Booklet, and Digest. All print-ready at 300 dpi with bleeds.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🤝</div>
|
||||
<h3>Collaborative Print Runs</h3>
|
||||
<p>Pool orders across titles and authors. The more copies in a run, the cheaper each one gets.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🛒</div>
|
||||
<h3>rCart Integration</h3>
|
||||
<p>Publish your artifact to the rCart catalog with one click. Group carts, revenue splits, and fulfillment built in.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-step">
|
||||
<span class="rl-step__num">1</span>
|
||||
<h3>Write / paste</h3>
|
||||
<p>Drop in markdown or rich text. Headings, images, footnotes — it all just works.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<span class="rl-step__num">2</span>
|
||||
<h3>Press it</h3>
|
||||
<p>Pick a pocket format. rPubs typesets your document with Typst and generates a print-ready PDF.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<span class="rl-step__num">3</span>
|
||||
<h3>Print locally</h3>
|
||||
<p>Print at home, at a local shop, or list it on rCart for cosmolocal fulfillment.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pocket Formats -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Four pocket formats</h2>
|
||||
<p class="rl-subtext" style="text-align:center">From palm-sized zines to digest readers. All print-ready at 300 dpi with bleeds.</p>
|
||||
<div class="rl-grid-4" style="margin-top:2rem">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
||||
</div>
|
||||
<h3>A7 Pocket</h3>
|
||||
<p>74 × 105 mm — fits in a shirt pocket. Perfect for poems, manifestos, tiny zines.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
||||
</div>
|
||||
<h3>Quarter Letter</h3>
|
||||
<p>4.25 × 5.5" — half a half-sheet. Great for chapbooks and field guides.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
||||
</div>
|
||||
<h3>A6 Booklet</h3>
|
||||
<p>105 × 148 mm — postcard size. The workhorse format for essays and readers.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
||||
</div>
|
||||
<h3>Digest</h3>
|
||||
<p>5.5 × 8.5" — half-letter. Standard trade paperback for longer works.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Group Buys -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Group buys — better together</h2>
|
||||
<p class="rl-subtext" style="text-align:center">Pool orders across titles. The more copies in a run, the cheaper each one gets.</p>
|
||||
<div class="rl-grid-3" style="margin-top:2rem">
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>25+ copies</h3>
|
||||
<p>Saddle-stitch binding</p>
|
||||
<p style="font-size:1.5rem;font-weight:700;color:#14b8a6;margin:0.5rem 0">$8</p>
|
||||
<p style="font-size:0.75rem;color:#64748b">per copy</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>50+ copies</h3>
|
||||
<p>Perfect-bind</p>
|
||||
<p style="font-size:1.5rem;font-weight:700;color:#14b8a6;margin:0.5rem 0">$6</p>
|
||||
<span class="rl-badge">Save 25%</span>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin-top:0.5rem">per copy</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>100+ copies</h3>
|
||||
<p>Trade edition</p>
|
||||
<p style="font-size:1.5rem;font-weight:700;color:#14b8a6;margin:0.5rem 0">$4.50</p>
|
||||
<span class="rl-badge">Save 44%</span>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin-top:0.5rem">per copy</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How collaborative print runs work -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">How collaborative print runs work</h2>
|
||||
<div class="rl-grid-4" style="margin-top:2rem">
|
||||
<div class="rl-step">
|
||||
<span class="rl-step__num">1</span>
|
||||
<h3>Authors list titles</h3>
|
||||
<p>Publish your print-ready artifact to the rCart catalog.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<span class="rl-step__num">2</span>
|
||||
<h3>Readers pre-order</h3>
|
||||
<p>Buyers add copies to a shared cart. Orders accumulate toward tier thresholds.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<span class="rl-step__num">3</span>
|
||||
<h3>Threshold met</h3>
|
||||
<p>When the combined order hits a tier, the print run triggers automatically.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<span class="rl-step__num">4</span>
|
||||
<h3>Local fulfillment</h3>
|
||||
<p>The nearest cosmolocal provider prints and ships. Revenue splits to creator, community, and provider.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cross-title batching -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Cross-title batching</h2>
|
||||
<p class="rl-subtext" style="text-align:center">
|
||||
Orders from <em>different</em> titles count toward the same tier. A community reading list
|
||||
can hit trade-edition pricing even if no single title has 100 orders.
|
||||
</p>
|
||||
<div class="rl-card" style="max-width:640px;margin:2rem auto 0">
|
||||
<h3 style="margin-bottom:1rem">Example batch</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:0.5rem">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.875rem">
|
||||
<span style="color:#e2e8f0">The Commons</span><span style="color:#14b8a6;font-weight:600">35 copies</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.875rem">
|
||||
<span style="color:#e2e8f0">Mycelial Networks</span><span style="color:#14b8a6;font-weight:600">40 copies</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.875rem">
|
||||
<span style="color:#e2e8f0">Cosmolocal Reader</span><span style="color:#14b8a6;font-weight:600">30 copies</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-divider"><span>combined</span></div>
|
||||
<div style="text-align:center">
|
||||
<span style="font-size:1.5rem;font-weight:700;color:#14b8a6">105 copies</span>
|
||||
<span style="display:block;font-size:0.8rem;color:#64748b;margin-top:0.25rem">= Trade edition @ $4.50/copy</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- rCart integration -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<div class="rl-grid-2">
|
||||
<div>
|
||||
<h2 class="rl-heading">rCart integration</h2>
|
||||
<p class="rl-subtext" style="margin-bottom:1.5rem">
|
||||
Group purchasing is built right into the shop. Every rPubs artifact can be listed,
|
||||
batched, and fulfilled through rCart.
|
||||
</p>
|
||||
<ul class="rl-check-list">
|
||||
<li><strong>One-click listing</strong> — publish to the rCart catalog straight from the press</li>
|
||||
<li><strong>Group carts</strong> — space members pool orders automatically</li>
|
||||
<li><strong>Revenue splits</strong> — creator, community, and provider shares via <a href="/rfunds" style="color:#14b8a6">rFunds</a></li>
|
||||
<li><strong>Cosmolocal fulfillment</strong> — nearest provider prints and ships</li>
|
||||
<li><strong>Order tracking</strong> — real-time status from press to doorstep</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center" style="display:flex;align-items:center;justify-content:center">
|
||||
<div>
|
||||
<div class="rl-icon-box" style="margin:0 auto 1rem">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6"/></svg>
|
||||
</div>
|
||||
<h3>Shop + Press</h3>
|
||||
<p>rPubs creates the artifact.<br>rCart sells and fulfills it.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cosmolocal -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<span class="rl-tagline">rPubs × Cosmolocal</span>
|
||||
<h2 class="rl-heading">Design global, manufacture local</h2>
|
||||
<p class="rl-subtext">
|
||||
Every print run is routed to the nearest capable provider. Reduce shipping emissions,
|
||||
support local economies, and still benefit from shared design and pooled demand.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Built on Open Source -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
|
||||
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rPubs.</p>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">⚙</div>
|
||||
<h3>Typst</h3>
|
||||
<p>Document compilation engine. Transforms markdown into beautifully typeset, print-ready PDFs.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔥</div>
|
||||
<h3>Hono</h3>
|
||||
<p>Ultrafast web framework for the API layer. Lightweight, edge-ready, and built for speed.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🗃</div>
|
||||
<h3>PostgreSQL</h3>
|
||||
<p>Rock-solid relational database for catalog storage, orders, and artifact metadata.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Your Data, Protected -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">Your Data, Protected</h2>
|
||||
<p class="rl-subtext">How rPubs keeps your information safe.</p>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔒</div>
|
||||
<h3>End-to-End Encryption</h3>
|
||||
<span class="rl-badge">Coming Soon</span>
|
||||
<p>All content encrypted before it leaves your device. Not even the server can read it.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🕵</div>
|
||||
<h3>Zero-Knowledge Architecture</h3>
|
||||
<span class="rl-badge">Coming Soon</span>
|
||||
<p>The server processes your requests without ever seeing your data in the clear.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🏠</div>
|
||||
<h3>Self-Hosted</h3>
|
||||
<p>Run on your own infrastructure. Your server, your rules, your data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">(You)rPubs, your press.</h2>
|
||||
<p class="rl-subtext">Try the demo or create a space to get started.</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rpubs" class="rl-cta-primary">Try the Press</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rl-back">
|
||||
<a href="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -49,6 +49,18 @@ export class FolkBookShelf extends HTMLElement {
|
|||
connectedCallback() {
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.render();
|
||||
if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") { this.loadDemoBooks(); }
|
||||
}
|
||||
|
||||
private loadDemoBooks() {
|
||||
this.books = [
|
||||
{ id: "b1", slug: "amusing-ourselves-to-death", title: "Amusing Ourselves to Death", author: "Neil Postman", description: "A prophetic look at what happens when politics, journalism, education, and even religion become subject to the demands of entertainment. Originally published in 1985, this book remains incredibly relevant in the age of social media and constant digital distraction.", pdf_size_bytes: 2457600, page_count: 231, tags: ["media criticism", "society", "technology", "culture"], cover_color: "#1e3a5f", contributor_name: null, featured: true, view_count: 523, created_at: "2025-11-10" },
|
||||
{ id: "b2", slug: "interference", title: "Interference: A Grand Scientific Musical Theory", author: "Richard Merrick", description: "A groundbreaking exploration of Harmonic Interference Theory - a set of principles explaining music perception using the physics of harmonic standing waves, connecting music theory to cymatics, Fibonacci sequences, and the fundamental patterns found throughout nature.", pdf_size_bytes: 8388608, page_count: 524, tags: ["music theory", "harmonics", "cymatics", "science", "perception"], cover_color: "#7c3aed", contributor_name: null, featured: true, view_count: 312, created_at: "2025-12-05" },
|
||||
{ id: "b3", slug: "governing-the-commons", title: "Governing the Commons", author: "Elinor Ostrom", description: "Analysis of collective action and the governance of common-pool resources", pdf_size_bytes: 2457600, page_count: 280, tags: ["economics", "governance"], cover_color: "#2563eb", contributor_name: "Community Library", featured: true, view_count: 342, created_at: "2026-01-15" },
|
||||
{ id: "b4", slug: "the-mushroom-at-the-end-of-the-world", title: "The Mushroom at the End of the World", author: "Anna Lowenhaupt Tsing", description: "On the possibility of life in capitalist ruins", pdf_size_bytes: 3145728, page_count: 352, tags: ["ecology", "anthropology"], cover_color: "#059669", contributor_name: null, featured: false, view_count: 187, created_at: "2026-01-20" },
|
||||
{ id: "b5", slug: "doughnut-economics", title: "Doughnut Economics", author: "Kate Raworth", description: "Seven ways to think like a 21st-century economist", pdf_size_bytes: 1887436, page_count: 320, tags: ["economics"], cover_color: "#d97706", contributor_name: "Reading Circle", featured: false, view_count: 256, created_at: "2026-02-01" },
|
||||
{ id: "b6", slug: "entangled-life", title: "Entangled Life", author: "Merlin Sheldrake", description: "How fungi make our worlds, change our minds, and shape our futures", pdf_size_bytes: 2621440, page_count: 368, tags: ["ecology", "science"], cover_color: "#0891b2", contributor_name: "Mycofi Lab", featured: false, view_count: 431, created_at: "2026-02-10" },
|
||||
];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||
|
|
@ -212,8 +212,8 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-book-shelf space-slug="${spaceSlug}"></folk-book-shelf>`,
|
||||
scripts: `<script type="module" src="/modules/books/folk-book-shelf.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/books/books.css">`,
|
||||
scripts: `<script type="module" src="/modules/rbooks/folk-book-shelf.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rbooks/books.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -264,10 +264,10 @@ routes.get("/read/:id", async (c) => {
|
|||
`,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
head: `<link rel="stylesheet" href="/modules/books/books.css">`,
|
||||
head: `<link rel="stylesheet" href="/modules/rbooks/books.css">`,
|
||||
scripts: `
|
||||
<script type="module">
|
||||
import { FolkBookReader } from '/modules/books/folk-book-reader.js';
|
||||
import { FolkBookReader } from '/modules/rbooks/folk-book-reader.js';
|
||||
</script>
|
||||
`,
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* rCal demo — tab switching and zoom controls (local state only, no WebSocket).
|
||||
*
|
||||
* Highlights the active tab when clicked. All tabs show the same
|
||||
* calendar grid for now; this is purely visual feedback.
|
||||
*/
|
||||
|
||||
const tabs = document.querySelectorAll<HTMLElement>("[data-cal-tab]");
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
tabs.forEach((t) => {
|
||||
t.style.background = "transparent";
|
||||
t.style.color = "#94a3b8";
|
||||
});
|
||||
tab.style.background = "rgba(99,102,241,0.15)";
|
||||
tab.style.color = "#818cf8";
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
|
|
@ -0,0 +1,822 @@
|
|||
/**
|
||||
* <folk-calendar-view> — temporal coordination calendar.
|
||||
*
|
||||
* Three views: Month grid, Week timeline, Day timeline.
|
||||
* View switcher, event dots, lunar phase overlay,
|
||||
* event creation, source filtering, and day-detail panels.
|
||||
*/
|
||||
|
||||
class FolkCalendarView extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
private currentDate = new Date();
|
||||
private viewMode: "month" | "week" | "day" = "month";
|
||||
private events: any[] = [];
|
||||
private sources: any[] = [];
|
||||
private lunarData: Record<string, { phase: string; illumination: number }> = {};
|
||||
private showLunar = true;
|
||||
private selectedDate = "";
|
||||
private selectedEvent: any = null;
|
||||
private expandedDay = ""; // mobile day-detail panel
|
||||
private error = "";
|
||||
private filteredSources = new Set<string>();
|
||||
private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e);
|
||||
document.addEventListener("keydown", this.boundKeyHandler);
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.loadMonth();
|
||||
this.render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.boundKeyHandler) {
|
||||
document.removeEventListener("keydown", this.boundKeyHandler);
|
||||
this.boundKeyHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeydown(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
switch (e.key) {
|
||||
case "ArrowLeft": e.preventDefault(); this.navigate(-1); break;
|
||||
case "ArrowRight": e.preventDefault(); this.navigate(1); break;
|
||||
case "1": this.viewMode = "day"; this.render(); break;
|
||||
case "2": this.viewMode = "week"; this.render(); break;
|
||||
case "3": this.viewMode = "month"; this.render(); break;
|
||||
case "t": case "T":
|
||||
this.currentDate = new Date(); this.expandedDay = "";
|
||||
if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
|
||||
break;
|
||||
case "l": case "L": this.showLunar = !this.showLunar; this.render(); break;
|
||||
}
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
|
||||
const sources = [
|
||||
{ name: "Work", color: "#3b82f6" },
|
||||
{ name: "Travel", color: "#f97316" },
|
||||
{ name: "Personal", color: "#10b981" },
|
||||
{ name: "Conferences", color: "#8b5cf6" },
|
||||
];
|
||||
|
||||
// Helper to create dates relative to current month
|
||||
// monthDelta: -1 = last month, 0 = this month, 1 = next month
|
||||
const rel = (monthDelta: number, day: number, hour: number, min: number) => {
|
||||
return new Date(year, month + monthDelta, day, hour, min);
|
||||
};
|
||||
|
||||
const demoEvents: { start: Date; durationMin: number; title: string; source: number; desc: string; location: string | null; virtual: boolean; lat?: number; lng?: number }[] = [
|
||||
// ── LAST MONTH ──
|
||||
{ start: rel(-1, 18, 10, 0), durationMin: 90, title: "Sprint 22 Review", source: 0, desc: "Sprint review with Berlin engineering team", location: "Factory Berlin", virtual: false, lat: 52.5030, lng: 13.3345 },
|
||||
{ start: rel(-1, 19, 14, 0), durationMin: 60, title: "1:1 with Manager", source: 0, desc: "Quarterly check-in", location: "Factory Berlin", virtual: false },
|
||||
{ start: rel(-1, 20, 9, 0), durationMin: 480, title: "EthBerlin Hackathon Day 1", source: 3, desc: "Web3 hackathon at Factory Kreuzberg", location: "Factory G\u00f6rlitzer Park", virtual: false, lat: 52.4934, lng: 13.4278 },
|
||||
{ start: rel(-1, 21, 9, 0), durationMin: 480, title: "EthBerlin Hackathon Day 2", source: 3, desc: "Judging and demos", location: "Factory G\u00f6rlitzer Park", virtual: false, lat: 52.4934, lng: 13.4278 },
|
||||
{ start: rel(-1, 22, 18, 0), durationMin: 120, title: "Birthday Dinner \u2014 Marco", source: 2, desc: "Italian dinner at La Focacceria", location: "La Focacceria, Kreuzberg", virtual: false, lat: 52.4940, lng: 13.4100 },
|
||||
{ start: rel(-1, 25, 7, 30), durationMin: 75, title: "Morning Yoga", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, lat: 52.4960, lng: 13.4088 },
|
||||
{ start: rel(-1, 26, 16, 0), durationMin: 60, title: "Client Call NYC", source: 0, desc: "Q4 API integration sync", location: null, virtual: true },
|
||||
{ start: rel(-1, 28, 8, 0), durationMin: 300, title: "Weekend Hike \u2014 Spreewald", source: 2, desc: "Kayak + hike in biosphere reserve", location: "L\u00fcbbenau, Spreewald", virtual: false, lat: 51.8644, lng: 13.7669 },
|
||||
|
||||
// ── THIS MONTH ──
|
||||
{ start: rel(0, 1, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Daily sync \u2014 Berlin engineering team", location: "Factory Berlin", virtual: false, lat: 52.5030, lng: 13.3345 },
|
||||
{ start: rel(0, 1, 14, 0), durationMin: 60, title: "Code Review Session", source: 0, desc: "Review PRs from the weekend batch", location: "Factory Berlin", virtual: false },
|
||||
{ start: rel(0, 2, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Daily sync", location: "Factory Berlin", virtual: false },
|
||||
{ start: rel(0, 2, 11, 0), durationMin: 60, title: "Design System Sync", source: 0, desc: "Component library review with design team", location: null, virtual: true },
|
||||
{ start: rel(0, 3, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Daily sync", location: "Factory Berlin", virtual: false },
|
||||
{ start: rel(0, 3, 15, 0), durationMin: 90, title: "Architecture Deep-Dive", source: 0, desc: "CRDT merge strategy for offline-first mobile", location: "Factory Berlin", virtual: false },
|
||||
{ start: rel(0, 4, 10, 0), durationMin: 90, title: "Product Review", source: 0, desc: "Quarterly product roadmap review", location: "Factory Berlin, Room 3", virtual: false },
|
||||
{ start: rel(0, 4, 12, 30), durationMin: 60, title: "Lunch with Alex", source: 2, desc: "Catch up over Vietnamese food", location: "District Mot, Berlin", virtual: false, lat: 52.5244, lng: 13.4023 },
|
||||
{ start: rel(0, 5, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Plan sprint 24 \u2014 local-first sync", location: "Factory Berlin, Room 1", virtual: false },
|
||||
{ start: rel(0, 7, 8, 0), durationMin: 300, title: "Weekend Hike \u2014 Grunewald", source: 2, desc: "Forest loop trail, ~14 km", location: "S-Bahn Grunewald", virtual: false, lat: 52.4730, lng: 13.2260 },
|
||||
{ start: rel(0, 8, 16, 0), durationMin: 60, title: "Client Call NYC", source: 0, desc: "Sync with NYC partner team on API integration", location: null, virtual: true },
|
||||
{ start: rel(0, 9, 7, 30), durationMin: 75, title: "Morning Yoga", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false },
|
||||
{ start: rel(0, 10, 11, 0), durationMin: 45, title: "1:1 with Manager", source: 0, desc: "Monthly check-in", location: "Factory Berlin", virtual: false },
|
||||
{ start: rel(0, 10, 15, 30), durationMin: 60, title: "Deploy Prep", source: 0, desc: "Pre-release checklist and staging verification", location: null, virtual: true },
|
||||
{ start: rel(0, 12, 7, 15), durationMin: 390, title: "Train Berlin \u2192 Amsterdam", source: 1, desc: "ICE 643 Berlin Hbf \u2192 Amsterdam Centraal", location: "Berlin Hauptbahnhof", virtual: false, lat: 52.5251, lng: 13.3694 },
|
||||
{ start: rel(0, 12, 14, 30), durationMin: 30, title: "Hotel Check-in", source: 1, desc: "Hotel V Nesplein, Amsterdam", location: "Hotel V Nesplein", virtual: false, lat: 52.3667, lng: 4.8945 },
|
||||
{ start: rel(0, 13, 10, 0), durationMin: 180, title: "Partner Meeting", source: 0, desc: "On-site with Amsterdam design team", location: "WeWork Weteringschans", virtual: false, lat: 52.3603, lng: 4.8880 },
|
||||
{ start: rel(0, 13, 15, 0), durationMin: 120, title: "Canal District Walk", source: 2, desc: "Afternoon along Prinsengracht and Jordaan", location: "Prinsengracht, Amsterdam", virtual: false, lat: 52.3738, lng: 4.8820 },
|
||||
{ start: rel(0, 14, 9, 30), durationMin: 390, title: "Return Train Amsterdam \u2192 Berlin", source: 1, desc: "ICE 148 Amsterdam \u2192 Berlin", location: "Amsterdam Centraal", virtual: false, lat: 52.3791, lng: 4.9003 },
|
||||
{ start: rel(0, 15, 19, 30), durationMin: 120, title: "Dinner with Friends", source: 2, desc: "Birthday dinner for Mia", location: "Il Casolare, Kreuzberg", virtual: false, lat: 52.4900, lng: 13.4200 },
|
||||
{ start: rel(0, 16, 7, 30), durationMin: 75, title: "Morning Yoga", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false },
|
||||
{ start: rel(0, 17, 10, 0), durationMin: 60, title: "Sprint Retro", source: 0, desc: "Sprint 23 retrospective", location: "Factory Berlin, Room 2", virtual: false },
|
||||
{ start: rel(0, 17, 14, 0), durationMin: 120, title: "Release Deploy", source: 0, desc: "Push v2.4.0 to production", location: null, virtual: true },
|
||||
{ start: rel(0, 18, 14, 0), durationMin: 90, title: "Demo Day", source: 0, desc: "Sprint 23 showcase for stakeholders", location: "Factory Berlin, Main Hall", virtual: false },
|
||||
{ start: rel(0, 19, 9, 0), durationMin: 45, title: "Dentist", source: 2, desc: "Regular checkup, Dr. Weber", location: "Torstr. 140, Berlin", virtual: false, lat: 52.5308, lng: 13.3970 },
|
||||
{ start: rel(0, 20, 19, 0), durationMin: 90, title: "Book Club", source: 2, desc: "\"The Mushroom at the End of the World\"", location: "Shakespeare & Sons, Berlin", virtual: false, lat: 52.4925, lng: 13.4310 },
|
||||
{ start: rel(0, 21, 14, 0), durationMin: 60, title: "c-base Open Tuesday", source: 2, desc: "Weekly open hackerspace session", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200 },
|
||||
{ start: rel(0, 22, 6, 45), durationMin: 195, title: "Flight \u2192 Lisbon", source: 1, desc: "TAP TP 571 BER \u2192 LIS", location: "BER Airport", virtual: false, lat: 52.3667, lng: 13.5033 },
|
||||
{ start: rel(0, 23, 9, 0), durationMin: 540, title: "Web Summit Day 1", source: 3, desc: "Opening keynotes, startup pavilion", location: "Altice Arena, Lisbon", virtual: false, lat: 38.7685, lng: -9.0943 },
|
||||
{ start: rel(0, 24, 9, 0), durationMin: 540, title: "Web Summit Day 2", source: 3, desc: "Panel: Local-First Software", location: "Altice Arena, Lisbon", virtual: false, lat: 38.7685, lng: -9.0943 },
|
||||
{ start: rel(0, 25, 10, 0), durationMin: 360, title: "Lisbon City Tour", source: 2, desc: "Alfama, Tram 28, Past\u00e9is de Bel\u00e9m", location: "Alfama, Lisbon", virtual: false, lat: 38.7118, lng: -9.1300 },
|
||||
{ start: rel(0, 25, 19, 30), durationMin: 195, title: "Flight \u2192 Berlin", source: 1, desc: "TAP TP 572 LIS \u2192 BER", location: "Lisbon Airport", virtual: false, lat: 38.7756, lng: -9.1354 },
|
||||
{ start: rel(0, 26, 18, 0), durationMin: 180, title: "Hackathon \u2014 c-base", source: 2, desc: "Local-first data sync hackathon", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200 },
|
||||
{ start: rel(0, 27, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Post-travel sync", location: "Factory Berlin", virtual: false },
|
||||
{ start: rel(0, 28, 15, 0), durationMin: 90, title: "Architecture Review", source: 0, desc: "Review local-first sync architecture", location: "Factory Berlin", virtual: false },
|
||||
|
||||
// ── NEXT MONTH ──
|
||||
{ start: rel(1, 2, 10, 0), durationMin: 120, title: "Sprint 25 Planning", source: 0, desc: "Plan next sprint", location: "Factory Berlin", virtual: false },
|
||||
{ start: rel(1, 3, 6, 0), durationMin: 180, title: "Flight \u2192 Barcelona", source: 1, desc: "VY 1862 BER \u2192 BCN", location: "BER Airport", virtual: false, lat: 52.3667, lng: 13.5033 },
|
||||
{ start: rel(1, 3, 14, 0), durationMin: 240, title: "Team Retreat Day 1", source: 0, desc: "Strategy offsite at Barcel\u00f3 Sants", location: "Barcel\u00f3 Sants, Barcelona", virtual: false, lat: 41.3795, lng: 2.1405 },
|
||||
{ start: rel(1, 4, 9, 0), durationMin: 480, title: "Team Retreat Day 2", source: 0, desc: "Workshops + Sagrada Familia visit", location: "Barcelona", virtual: false, lat: 41.4036, lng: 2.1744 },
|
||||
{ start: rel(1, 5, 15, 0), durationMin: 180, title: "Flight \u2192 Berlin", source: 1, desc: "VY 1863 BCN \u2192 BER", location: "BCN Airport", virtual: false, lat: 41.2974, lng: 2.0833 },
|
||||
{ start: rel(1, 8, 19, 0), durationMin: 120, title: "Berlin Philharmonic", source: 2, desc: "Brahms Symphony No. 4", location: "Berliner Philharmonie", virtual: false, lat: 52.5103, lng: 13.3699 },
|
||||
{ start: rel(1, 12, 10, 0), durationMin: 120, title: "Brussels Workshop", source: 3, desc: "EU Digital Commons Working Group", location: "Brussels", virtual: false, lat: 50.8503, lng: 4.3517 },
|
||||
];
|
||||
|
||||
this.events = demoEvents.map((e, i) => {
|
||||
const endDate = new Date(e.start.getTime() + e.durationMin * 60000);
|
||||
const src = sources[e.source];
|
||||
return {
|
||||
id: `demo-${i + 1}`,
|
||||
title: e.title,
|
||||
start_time: e.start.toISOString(),
|
||||
end_time: endDate.toISOString(),
|
||||
source_color: src.color,
|
||||
source_name: src.name,
|
||||
description: e.desc,
|
||||
location_name: e.location || undefined,
|
||||
is_virtual: e.virtual,
|
||||
virtual_platform: e.virtual ? "Jitsi" : undefined,
|
||||
virtual_url: e.virtual ? "#" : undefined,
|
||||
latitude: e.lat,
|
||||
longitude: e.lng,
|
||||
};
|
||||
});
|
||||
|
||||
this.sources = sources;
|
||||
|
||||
// Compute lunar phases for all 3 months
|
||||
const knownNewMoon = new Date(2026, 0, 29).getTime();
|
||||
const cycle = 29.53;
|
||||
const phaseNames: [string, number][] = [
|
||||
["new_moon", 1.85], ["waxing_crescent", 7.38], ["first_quarter", 11.07],
|
||||
["waxing_gibbous", 14.76], ["full_moon", 16.62], ["waning_gibbous", 22.15],
|
||||
["last_quarter", 25.84], ["waning_crescent", 29.53],
|
||||
];
|
||||
|
||||
const lunar: Record<string, { phase: string; illumination: number }> = {};
|
||||
for (let m = month - 1; m <= month + 1; m++) {
|
||||
const actualYear = m < 0 ? year - 1 : (m > 11 ? year + 1 : year);
|
||||
const actualMonth = ((m % 12) + 12) % 12;
|
||||
const daysInMonth = new Date(actualYear, actualMonth + 1, 0).getDate();
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${actualYear}-${String(actualMonth + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
const dayTime = new Date(actualYear, actualMonth, d).getTime();
|
||||
const daysSinceNew = ((dayTime - knownNewMoon) / 86400000) % cycle;
|
||||
const normalizedDays = daysSinceNew < 0 ? daysSinceNew + cycle : daysSinceNew;
|
||||
let phaseName = "new_moon";
|
||||
for (const [name, threshold] of phaseNames) {
|
||||
if (normalizedDays < threshold) { phaseName = name; break; }
|
||||
}
|
||||
const illumination = Math.round((1 - Math.cos(2 * Math.PI * normalizedDays / cycle)) / 2 * 100) / 100;
|
||||
lunar[dateStr] = { phase: phaseName, illumination };
|
||||
}
|
||||
}
|
||||
this.lunarData = lunar;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/cal/);
|
||||
return match ? `/${match[1]}/cal` : "";
|
||||
}
|
||||
|
||||
private async loadMonth() {
|
||||
const year = this.currentDate.getFullYear();
|
||||
const month = this.currentDate.getMonth();
|
||||
const start = `${year}-${String(month + 1).padStart(2, "0")}-01`;
|
||||
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||
const end = `${year}-${String(month + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
|
||||
const base = this.getApiBase();
|
||||
try {
|
||||
const [eventsRes, sourcesRes, lunarRes] = await Promise.all([
|
||||
fetch(`${base}/api/events?start=${start}&end=${end}`),
|
||||
fetch(`${base}/api/sources`),
|
||||
fetch(`${base}/api/lunar?start=${start}&end=${end}`),
|
||||
]);
|
||||
if (eventsRes.ok) { const data = await eventsRes.json(); this.events = data.results || []; }
|
||||
if (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; }
|
||||
if (lunarRes.ok) { this.lunarData = await lunarRes.json(); }
|
||||
} catch { /* offline fallback */ }
|
||||
this.render();
|
||||
}
|
||||
|
||||
private navigate(delta: number) {
|
||||
if (this.viewMode === "day") {
|
||||
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + delta);
|
||||
} else if (this.viewMode === "week") {
|
||||
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + delta * 7);
|
||||
} else {
|
||||
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1);
|
||||
}
|
||||
this.expandedDay = "";
|
||||
if (this.space !== "demo") { this.loadMonth(); } else { this.render(); }
|
||||
}
|
||||
|
||||
private getMoonEmoji(phase: string): string {
|
||||
const map: Record<string, string> = {
|
||||
new_moon: "\u{1F311}", waxing_crescent: "\u{1F312}", first_quarter: "\u{1F313}",
|
||||
waxing_gibbous: "\u{1F314}", full_moon: "\u{1F315}", waning_gibbous: "\u{1F316}",
|
||||
last_quarter: "\u{1F317}", waning_crescent: "\u{1F318}",
|
||||
};
|
||||
return map[phase] || "";
|
||||
}
|
||||
|
||||
private formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
private dateStr(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
private getEventsForDate(dateStr: string): any[] {
|
||||
return this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr)
|
||||
&& !this.filteredSources.has(e.source_name));
|
||||
}
|
||||
|
||||
private toggleSource(name: string) {
|
||||
if (this.filteredSources.has(name)) { this.filteredSources.delete(name); }
|
||||
else { this.filteredSources.add(name); }
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
const viewLabel = this.getViewLabel();
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; padding: 0.5rem; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.nav { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; min-height: 36px; flex-wrap: wrap; }
|
||||
.nav-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.12); background: transparent; color: #94a3b8; cursor: pointer; font-size: 14px; -webkit-tap-highlight-color: transparent; }
|
||||
.nav-btn:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
||||
.nav-btn.active { border-color: #6366f1; color: #6366f1; }
|
||||
.nav-title { font-size: 15px; font-weight: 600; flex: 1; text-align: center; color: #e2e8f0; }
|
||||
.nav-primary { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 12px; }
|
||||
|
||||
/* View switcher */
|
||||
.view-switch { display: flex; gap: 2px; margin-bottom: 12px; background: #16161e; border-radius: 8px; padding: 3px; border: 1px solid #222; }
|
||||
.view-switch-btn { flex: 1; padding: 5px 10px; border-radius: 6px; border: none; background: transparent; color: #64748b; cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.15s; }
|
||||
.view-switch-btn:hover { color: #94a3b8; }
|
||||
.view-switch-btn.active { background: #4f46e5; color: #fff; }
|
||||
|
||||
.sources { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.src-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; cursor: pointer; transition: opacity 0.15s; user-select: none; }
|
||||
.src-badge:hover { filter: brightness(1.2); }
|
||||
.src-badge.filtered { opacity: 0.3; text-decoration: line-through; }
|
||||
|
||||
.weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-bottom: 4px; }
|
||||
.wd { text-align: center; font-size: 11px; color: #64748b; padding: 4px; font-weight: 600; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
|
||||
.day {
|
||||
background: #16161e; border: 1px solid #222; border-radius: 6px;
|
||||
min-height: 80px; padding: 6px; cursor: pointer; position: relative;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.day:hover { border-color: #444; }
|
||||
.day.today { border-color: #6366f1; background: rgba(99,102,241,0.06); }
|
||||
.day.expanded { border-color: #6366f1; background: rgba(99,102,241,0.1); }
|
||||
.day.other { opacity: 0.3; }
|
||||
.day-num { font-size: 12px; font-weight: 600; margin-bottom: 2px; display: flex; justify-content: space-between; }
|
||||
.moon { font-size: 10px; opacity: 0.7; }
|
||||
.dots { display: flex; flex-wrap: wrap; gap: 1px; }
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin: 1px; }
|
||||
.ev-label { font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #aaa; line-height: 1.4; padding: 1px 3px; border-radius: 3px; cursor: pointer; }
|
||||
.ev-label:hover { background: rgba(255,255,255,0.08); }
|
||||
.ev-time { color: #666; font-size: 8px; margin-right: 2px; }
|
||||
|
||||
/* Day detail panel */
|
||||
.day-detail { grid-column: 1 / -1; background: #1a1a2e; border: 1px solid #334155; border-radius: 8px; padding: 12px; }
|
||||
.dd-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.dd-date { font-size: 14px; font-weight: 600; color: #e2e8f0; }
|
||||
.dd-close { background: none; border: none; color: #64748b; font-size: 18px; cursor: pointer; padding: 4px 8px; }
|
||||
.dd-event { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border-radius: 6px; margin-bottom: 4px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
.dd-event:hover { background: rgba(255,255,255,0.05); }
|
||||
.dd-color { width: 4px; border-radius: 2px; align-self: stretch; flex-shrink: 0; }
|
||||
.dd-info { flex: 1; min-width: 0; }
|
||||
.dd-title { font-size: 13px; font-weight: 500; color: #e2e8f0; }
|
||||
.dd-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; }
|
||||
.dd-empty { font-size: 12px; color: #64748b; padding: 8px 0; }
|
||||
|
||||
/* Event modal */
|
||||
.modal-bg { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.modal { background: #1e1e2e; border: 1px solid #333; border-radius: 12px; padding: 20px; max-width: 400px; width: 90%; }
|
||||
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
|
||||
.modal-field { font-size: 13px; color: #aaa; margin-bottom: 6px; }
|
||||
.modal-close { float: right; background: none; border: none; color: #888; font-size: 18px; cursor: pointer; }
|
||||
|
||||
/* ── Day View ── */
|
||||
.day-view { position: relative; }
|
||||
.day-view-header { font-size: 13px; color: #94a3b8; margin-bottom: 8px; font-weight: 500; }
|
||||
.day-allday { background: #16161e; border: 1px solid #222; border-radius: 8px; padding: 8px; margin-bottom: 8px; }
|
||||
.day-allday-label { font-size: 10px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
|
||||
.timeline { position: relative; border-left: 1px solid #222; margin-left: 44px; }
|
||||
.hour-row { display: flex; min-height: 48px; border-bottom: 1px solid rgba(255,255,255,0.04); position: relative; }
|
||||
.hour-label { position: absolute; left: -48px; top: -7px; width: 40px; text-align: right; font-size: 10px; color: #4a5568; font-variant-numeric: tabular-nums; }
|
||||
.hour-content { flex: 1; position: relative; padding-left: 8px; }
|
||||
.tl-event {
|
||||
position: absolute; left: 8px; right: 8px; border-radius: 6px;
|
||||
padding: 4px 8px; font-size: 11px; overflow: hidden; cursor: pointer;
|
||||
border-left: 3px solid; z-index: 1; transition: opacity 0.15s;
|
||||
}
|
||||
.tl-event:hover { opacity: 0.85; }
|
||||
.tl-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tl-event-time { font-size: 10px; color: #94a3b8; }
|
||||
.tl-event-loc { font-size: 10px; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.now-line { position: absolute; left: 0; right: 0; height: 2px; background: #ef4444; z-index: 5; }
|
||||
.now-dot { position: absolute; left: -5px; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; }
|
||||
|
||||
/* ── Week View ── */
|
||||
.week-view { overflow-x: auto; }
|
||||
.week-header { display: grid; grid-template-columns: 44px repeat(7, 1fr); gap: 0; margin-bottom: 0; }
|
||||
.week-day-header {
|
||||
text-align: center; padding: 8px 4px; font-size: 11px; color: #64748b; font-weight: 600;
|
||||
border-bottom: 1px solid #222; cursor: pointer;
|
||||
}
|
||||
.week-day-header:hover { color: #e2e8f0; }
|
||||
.week-day-header.today { color: #6366f1; border-bottom-color: #6366f1; }
|
||||
.week-day-num { font-size: 16px; font-weight: 700; display: block; }
|
||||
.week-day-name { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.week-grid { display: grid; grid-template-columns: 44px repeat(7, 1fr); }
|
||||
.week-time-label { font-size: 10px; color: #4a5568; text-align: right; padding-right: 6px; padding-top: 0; font-variant-numeric: tabular-nums; height: 48px; }
|
||||
.week-cell { border-left: 1px solid rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.04); min-height: 48px; position: relative; }
|
||||
.week-cell.today { background: rgba(99,102,241,0.04); }
|
||||
.week-event {
|
||||
position: absolute; left: 2px; right: 2px; border-radius: 4px;
|
||||
padding: 2px 4px; font-size: 10px; overflow: hidden; cursor: pointer;
|
||||
border-left: 2px solid; z-index: 1;
|
||||
}
|
||||
.week-event:hover { opacity: 0.85; }
|
||||
.week-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
:host { padding: 0.25rem; }
|
||||
.day { min-height: 52px; padding: 4px; }
|
||||
.day-num { font-size: 11px; }
|
||||
.ev-label { display: none; }
|
||||
.dot { width: 5px; height: 5px; }
|
||||
.moon { font-size: 8px; }
|
||||
.nav-title { font-size: 13px; }
|
||||
.nav { gap: 4px; }
|
||||
.sources { gap: 4px; }
|
||||
.src-badge { font-size: 9px; padding: 2px 6px; }
|
||||
.wd { font-size: 10px; padding: 3px; }
|
||||
.week-view { font-size: 10px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.day { min-height: 44px; padding: 3px; }
|
||||
.day-num { font-size: 10px; }
|
||||
.wd { font-size: 9px; padding: 2px; }
|
||||
.nav { flex-wrap: wrap; justify-content: center; }
|
||||
.nav-title { width: 100%; order: -1; margin-bottom: 4px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
|
||||
|
||||
<div class="nav">
|
||||
<button class="nav-btn" id="prev">\u2190</button>
|
||||
<button class="nav-btn" id="today">Today</button>
|
||||
<span class="nav-title">${viewLabel}</span>
|
||||
<button class="nav-btn ${this.showLunar ? "active" : ""}" id="toggle-lunar">\u{1F319}</button>
|
||||
<button class="nav-btn" id="next">\u2192</button>
|
||||
</div>
|
||||
|
||||
<div class="view-switch">
|
||||
<button class="view-switch-btn ${this.viewMode === "day" ? "active" : ""}" data-view="day">Day</button>
|
||||
<button class="view-switch-btn ${this.viewMode === "week" ? "active" : ""}" data-view="week">Week</button>
|
||||
<button class="view-switch-btn ${this.viewMode === "month" ? "active" : ""}" data-view="month">Month</button>
|
||||
</div>
|
||||
|
||||
${this.sources.length > 0 ? `<div class="sources">
|
||||
${this.sources.map(s => `<span class="src-badge ${this.filteredSources.has(s.name) ? "filtered" : ""}" data-source="${this.esc(s.name)}" style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
|
||||
</div>` : ""}
|
||||
|
||||
${this.viewMode === "month" ? this.renderMonth() : ""}
|
||||
${this.viewMode === "week" ? this.renderWeek() : ""}
|
||||
${this.viewMode === "day" ? this.renderDay() : ""}
|
||||
|
||||
${this.selectedEvent ? this.renderEventModal() : ""}
|
||||
`;
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
private getViewLabel(): string {
|
||||
const d = this.currentDate;
|
||||
if (this.viewMode === "day") {
|
||||
return d.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
}
|
||||
if (this.viewMode === "week") {
|
||||
const weekStart = new Date(d);
|
||||
weekStart.setDate(d.getDate() - d.getDay());
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
const startLabel = weekStart.toLocaleDateString("default", { month: "short", day: "numeric" });
|
||||
const endLabel = weekEnd.toLocaleDateString("default", { month: "short", day: "numeric", year: "numeric" });
|
||||
return `${startLabel} \u2013 ${endLabel}`;
|
||||
}
|
||||
return d.toLocaleString("default", { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
// ── Month View ──
|
||||
|
||||
private renderMonth(): string {
|
||||
const year = this.currentDate.getFullYear();
|
||||
const month = this.currentDate.getMonth();
|
||||
return `
|
||||
<div class="weekdays">
|
||||
${["S", "M", "T", "W", "T", "F", "S"].map(d => `<div class="wd">${d}</div>`).join("")}
|
||||
</div>
|
||||
<div class="grid">
|
||||
${this.renderDays(year, month)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderDays(year: number, month: number): string {
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayStr = this.dateStr(today);
|
||||
|
||||
let html = "";
|
||||
const prevDays = new Date(year, month, 0).getDate();
|
||||
for (let i = firstDay - 1; i >= 0; i--) {
|
||||
html += `<div class="day other"><div class="day-num">${prevDays - i}</div></div>`;
|
||||
}
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
const isToday = ds === todayStr;
|
||||
const isExpanded = ds === this.expandedDay;
|
||||
const dayEvents = this.getEventsForDate(ds);
|
||||
const lunar = this.lunarData[ds];
|
||||
|
||||
html += `<div class="day ${isToday ? "today" : ""} ${isExpanded ? "expanded" : ""}" data-date="${ds}">
|
||||
<div class="day-num">
|
||||
<span>${d}</span>
|
||||
${this.showLunar && lunar ? `<span class="moon">${this.getMoonEmoji(lunar.phase)}</span>` : ""}
|
||||
</div>
|
||||
${dayEvents.length > 0 ? `
|
||||
<div class="dots">
|
||||
${dayEvents.slice(0, 4).map(e => `<span class="dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")}
|
||||
${dayEvents.length > 4 ? `<span style="font-size:8px;color:#888">+${dayEvents.length - 4}</span>` : ""}
|
||||
</div>
|
||||
${dayEvents.slice(0, 2).map(e => {
|
||||
return `<div class="ev-label" style="border-left:2px solid ${e.source_color || "#6366f1"}" data-event-id="${e.id}"><span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}</div>`;
|
||||
}).join("")}
|
||||
` : ""}
|
||||
</div>`;
|
||||
|
||||
if (isExpanded) {
|
||||
const cellIndex = firstDay + d - 1;
|
||||
const posInRow = cellIndex % 7;
|
||||
if (posInRow === 6 || d === daysInMonth) {
|
||||
html += this.renderDayDetail(ds, dayEvents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.expandedDay) {
|
||||
const expD = parseInt(this.expandedDay.split("-")[2]);
|
||||
const cellIndex = firstDay + expD - 1;
|
||||
const posInRow = cellIndex % 7;
|
||||
if (posInRow !== 6 && expD <= daysInMonth) { /* detail already appended below */ }
|
||||
}
|
||||
|
||||
const totalCells = firstDay + daysInMonth;
|
||||
const remaining = (7 - (totalCells % 7)) % 7;
|
||||
for (let i = 1; i <= remaining; i++) {
|
||||
html += `<div class="day other"><div class="day-num">${i}</div></div>`;
|
||||
}
|
||||
|
||||
if (this.expandedDay) {
|
||||
const dayEvents = this.getEventsForDate(this.expandedDay);
|
||||
html += this.renderDayDetail(this.expandedDay, dayEvents);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Day View ──
|
||||
|
||||
private renderDay(): string {
|
||||
const ds = this.dateStr(this.currentDate);
|
||||
const dayEvents = this.getEventsForDate(ds);
|
||||
const lunar = this.lunarData[ds];
|
||||
const now = new Date();
|
||||
const isToday = this.dateStr(now) === ds;
|
||||
|
||||
const HOUR_HEIGHT = 48;
|
||||
const START_HOUR = 6;
|
||||
const END_HOUR = 23;
|
||||
|
||||
// Separate all-day vs timed events
|
||||
const allDay = dayEvents.filter(e => {
|
||||
const start = new Date(e.start_time);
|
||||
const end = new Date(e.end_time);
|
||||
return (end.getTime() - start.getTime()) >= 86400000;
|
||||
});
|
||||
const timed = dayEvents.filter(e => {
|
||||
const start = new Date(e.start_time);
|
||||
const end = new Date(e.end_time);
|
||||
return (end.getTime() - start.getTime()) < 86400000;
|
||||
}).sort((a, b) => a.start_time.localeCompare(b.start_time));
|
||||
|
||||
// Hour rows
|
||||
let hoursHtml = "";
|
||||
for (let h = START_HOUR; h <= END_HOUR; h++) {
|
||||
const label = h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`;
|
||||
hoursHtml += `<div class="hour-row">
|
||||
<span class="hour-label">${label}</span>
|
||||
<div class="hour-content"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Position timed events
|
||||
let eventsHtml = "";
|
||||
for (const ev of timed) {
|
||||
const start = new Date(ev.start_time);
|
||||
const end = new Date(ev.end_time);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const endMin = end.getHours() * 60 + end.getMinutes();
|
||||
const duration = Math.max(endMin - startMin, 30);
|
||||
const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
|
||||
const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 24);
|
||||
const bgColor = ev.source_color ? `${ev.source_color}18` : "#6366f118";
|
||||
|
||||
eventsHtml += `<div class="tl-event" data-event-id="${ev.id}" style="
|
||||
top: ${topPx}px; height: ${heightPx}px;
|
||||
background: ${bgColor}; border-left-color: ${ev.source_color || "#6366f1"};
|
||||
">
|
||||
<div class="tl-event-title">${this.esc(ev.title)}</div>
|
||||
<div class="tl-event-time">${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}</div>
|
||||
${ev.location_name ? `<div class="tl-event-loc">${this.esc(ev.location_name)}</div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Now indicator
|
||||
let nowHtml = "";
|
||||
if (isToday) {
|
||||
const nowMin = now.getHours() * 60 + now.getMinutes();
|
||||
const nowPx = ((nowMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
|
||||
if (nowPx >= 0 && nowPx <= (END_HOUR - START_HOUR + 1) * HOUR_HEIGHT) {
|
||||
nowHtml = `<div class="now-line" style="top:${nowPx}px"><div class="now-dot"></div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="day-view">
|
||||
<div class="day-view-header">
|
||||
${this.showLunar && lunar ? `${this.getMoonEmoji(lunar.phase)} ${Math.round(lunar.illumination * 100)}% illuminated \u00B7 ` : ""}
|
||||
${dayEvents.length} event${dayEvents.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
${allDay.length > 0 ? `<div class="day-allday">
|
||||
<div class="day-allday-label">All Day</div>
|
||||
${allDay.map(e => `<div class="dd-event" data-event-id="${e.id}">
|
||||
<div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div>
|
||||
<div class="dd-info"><div class="dd-title">${this.esc(e.title)}</div></div>
|
||||
</div>`).join("")}
|
||||
</div>` : ""}
|
||||
<div class="timeline" style="height:${(END_HOUR - START_HOUR + 1) * HOUR_HEIGHT}px">
|
||||
${hoursHtml}
|
||||
${eventsHtml}
|
||||
${nowHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Week View ──
|
||||
|
||||
private renderWeek(): string {
|
||||
const d = this.currentDate;
|
||||
const weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate() - d.getDay());
|
||||
const today = new Date();
|
||||
const todayStr = this.dateStr(today);
|
||||
|
||||
const HOUR_HEIGHT = 48;
|
||||
const START_HOUR = 7;
|
||||
const END_HOUR = 22;
|
||||
const totalHeight = (END_HOUR - START_HOUR + 1) * HOUR_HEIGHT;
|
||||
|
||||
// Header row
|
||||
let headerHtml = `<div class="week-day-header" style="border-bottom:none"></div>`;
|
||||
const days: Date[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(weekStart);
|
||||
day.setDate(weekStart.getDate() + i);
|
||||
days.push(day);
|
||||
const ds = this.dateStr(day);
|
||||
const isToday = ds === todayStr;
|
||||
headerHtml += `<div class="week-day-header ${isToday ? "today" : ""}" data-date="${ds}" data-day-click="true">
|
||||
<span class="week-day-name">${day.toLocaleDateString("default", { weekday: "short" })}</span>
|
||||
<span class="week-day-num">${day.getDate()}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Time grid
|
||||
let gridHtml = "";
|
||||
for (let h = START_HOUR; h <= END_HOUR; h++) {
|
||||
const label = h === 0 ? "12a" : h < 12 ? `${h}a` : h === 12 ? "12p" : `${h - 12}p`;
|
||||
gridHtml += `<div class="week-time-label">${label}</div>`;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const ds = this.dateStr(days[i]);
|
||||
const isToday = ds === todayStr;
|
||||
gridHtml += `<div class="week-cell ${isToday ? "today" : ""}" data-col="${i}" data-hour="${h}"></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay events onto week grid
|
||||
let eventsOverlay = "";
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const ds = this.dateStr(days[i]);
|
||||
const dayEvents = this.getEventsForDate(ds);
|
||||
for (const ev of dayEvents) {
|
||||
const start = new Date(ev.start_time);
|
||||
const end = new Date(ev.end_time);
|
||||
if ((end.getTime() - start.getTime()) >= 86400000) continue; // skip all-day
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const endMin = end.getHours() * 60 + end.getMinutes();
|
||||
const duration = Math.max(endMin - startMin, 20);
|
||||
const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
|
||||
const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 18);
|
||||
const bgColor = ev.source_color ? `${ev.source_color}20` : "#6366f120";
|
||||
// Column position: each column is 1/7 of the remaining width after the time label
|
||||
const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px)`;
|
||||
const colWidth = `calc((100% - 44px) / 7 - 4px)`;
|
||||
|
||||
eventsOverlay += `<div class="week-event" data-event-id="${ev.id}" style="
|
||||
top: ${topPx}px; height: ${heightPx}px; left: ${colLeft}; width: ${colWidth};
|
||||
background: ${bgColor}; border-left-color: ${ev.source_color || "#6366f1"};
|
||||
">
|
||||
<div class="week-event-title">${this.esc(ev.title)}</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Now indicator for week view
|
||||
let nowHtml = "";
|
||||
const nowDay = days.findIndex(day => this.dateStr(day) === todayStr);
|
||||
if (nowDay >= 0) {
|
||||
const nowMin = today.getHours() * 60 + today.getMinutes();
|
||||
const nowPx = ((nowMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
|
||||
if (nowPx >= 0 && nowPx <= totalHeight) {
|
||||
nowHtml = `<div class="now-line" style="top:${nowPx}px;left:44px;right:0"><div class="now-dot"></div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="week-view">
|
||||
<div class="week-header">${headerHtml}</div>
|
||||
<div style="position:relative;overflow-y:auto;max-height:600px;">
|
||||
<div class="week-grid" style="position:relative;height:${totalHeight}px">
|
||||
${gridHtml}
|
||||
</div>
|
||||
${eventsOverlay}
|
||||
${nowHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderDayDetail(dateStr: string, dayEvents: any[]): string {
|
||||
const d = new Date(dateStr + "T00:00:00");
|
||||
const label = d.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric" });
|
||||
|
||||
return `<div class="day-detail">
|
||||
<div class="dd-header">
|
||||
<span class="dd-date">${label}</span>
|
||||
<button class="dd-close" id="dd-close">\u2715</button>
|
||||
</div>
|
||||
${dayEvents.length === 0 ? `<div class="dd-empty">No events</div>` :
|
||||
dayEvents.sort((a, b) => a.start_time.localeCompare(b.start_time)).map(e => `
|
||||
<div class="dd-event" data-event-id="${e.id}">
|
||||
<div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div>
|
||||
<div class="dd-info">
|
||||
<div class="dd-title">${this.esc(e.title)}</div>
|
||||
<div class="dd-meta">${this.formatTime(e.start_time)}${e.end_time ? ` \u2013 ${this.formatTime(e.end_time)}` : ""}${e.location_name ? ` \u00B7 ${this.esc(e.location_name)}` : ""}${e.is_virtual ? " \u00B7 Virtual" : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderEventModal(): string {
|
||||
const e = this.selectedEvent;
|
||||
return `
|
||||
<div class="modal-bg" id="modal-overlay">
|
||||
<div class="modal">
|
||||
<button class="modal-close" id="modal-close">\u2715</button>
|
||||
<div class="modal-title">${this.esc(e.title)}</div>
|
||||
${e.description ? `<div class="modal-field">${this.esc(e.description)}</div>` : ""}
|
||||
<div class="modal-field">${new Date(e.start_time).toLocaleString()}${e.end_time ? ` \u2013 ${new Date(e.end_time).toLocaleString()}` : ""}</div>
|
||||
${e.location_name ? `<div class="modal-field">\u{1F4CD} ${this.esc(e.location_name)}</div>` : ""}
|
||||
${e.source_name ? `<div class="modal-field" style="margin-top:8px"><span class="src-badge" style="border-color:${e.source_color || "#666"};color:${e.source_color || "#aaa"}">${this.esc(e.source_name)}</span></div>` : ""}
|
||||
${e.is_virtual ? `<div class="modal-field">\u{1F4BB} ${this.esc(e.virtual_platform || "Virtual")} ${e.virtual_url ? `<a href="${e.virtual_url}" target="_blank" style="color:#6366f1">Join</a>` : ""}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private attachListeners() {
|
||||
this.shadow.getElementById("prev")?.addEventListener("click", () => this.navigate(-1));
|
||||
this.shadow.getElementById("next")?.addEventListener("click", () => this.navigate(1));
|
||||
this.shadow.getElementById("today")?.addEventListener("click", () => {
|
||||
this.currentDate = new Date();
|
||||
this.expandedDay = "";
|
||||
if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
|
||||
});
|
||||
this.shadow.getElementById("toggle-lunar")?.addEventListener("click", () => {
|
||||
this.showLunar = !this.showLunar;
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Source filter toggles
|
||||
this.shadow.querySelectorAll("[data-source]").forEach(el => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleSource((el as HTMLElement).dataset.source!);
|
||||
});
|
||||
});
|
||||
|
||||
// View switcher
|
||||
this.shadow.querySelectorAll("[data-view]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
this.viewMode = (el as HTMLElement).dataset.view as any;
|
||||
this.expandedDay = "";
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Day cell tap → expand day detail panel (month view)
|
||||
this.shadow.querySelectorAll(".day:not(.other)").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
const date = (el as HTMLElement).dataset.date;
|
||||
if (!date) return;
|
||||
this.expandedDay = this.expandedDay === date ? "" : date;
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Week day header click → switch to day view
|
||||
this.shadow.querySelectorAll("[data-day-click]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
const ds = (el as HTMLElement).dataset.date;
|
||||
if (!ds) return;
|
||||
const parts = ds.split("-");
|
||||
this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
|
||||
this.viewMode = "day";
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Event clicks → open modal
|
||||
this.shadow.querySelectorAll("[data-event-id]").forEach(el => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const id = (el as HTMLElement).dataset.eventId;
|
||||
this.selectedEvent = this.events.find(ev => ev.id === id);
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Close day detail
|
||||
this.shadow.getElementById("dd-close")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.expandedDay = "";
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Modal close
|
||||
this.shadow.getElementById("modal-overlay")?.addEventListener("click", (e) => {
|
||||
if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); }
|
||||
});
|
||||
this.shadow.getElementById("modal-close")?.addEventListener("click", () => {
|
||||
this.selectedEvent = null; this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-calendar-view", FolkCalendarView);
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
/**
|
||||
* rCal demo page — server-rendered HTML body.
|
||||
*
|
||||
* Static July 2026 calendar grid with Alpine Explorer trip events,
|
||||
* tab switching (Temporal/Spatial/Lunar/Context), zoom panel,
|
||||
* and feature cards. Entirely local state, no WebSocket.
|
||||
*/
|
||||
|
||||
/* ─── Event Data ──────────────────────────────────────────── */
|
||||
|
||||
interface CalEvent {
|
||||
day: number;
|
||||
emoji: string;
|
||||
label: string;
|
||||
color: string;
|
||||
bg: string;
|
||||
}
|
||||
|
||||
const TRIP_EVENTS: CalEvent[] = [
|
||||
{ day: 6, emoji: "\u2708\uFE0F", label: "Arrive Chamonix", color: "#2dd4bf", bg: "rgba(20,184,166,0.15)" },
|
||||
{ day: 7, emoji: "\u{1F97E}", label: "Lac Blanc Hike", color: "#34d399", bg: "rgba(16,185,129,0.15)" },
|
||||
{ day: 8, emoji: "\u{1F9D7}", label: "Aiguille du Midi", color: "#fbbf24", bg: "rgba(245,158,11,0.15)" },
|
||||
{ day: 9, emoji: "\u{1F97E}", label: "Mer de Glace", color: "#34d399", bg: "rgba(16,185,129,0.15)" },
|
||||
{ day: 10, emoji: "\u{1F682}", label: "Train to Zermatt", color: "#22d3ee", bg: "rgba(6,182,212,0.15)" },
|
||||
{ day: 11, emoji: "\u{1F97E}", label: "Five Lakes Walk", color: "#34d399", bg: "rgba(16,185,129,0.15)" },
|
||||
{ day: 12, emoji: "\u26F7", label: "Glacier Paradise", color: "#fbbf24", bg: "rgba(245,158,11,0.15)" },
|
||||
{ day: 13, emoji: "\u{1F3DB}", label: "Alpine Museum", color: "#a78bfa", bg: "rgba(139,92,246,0.15)" },
|
||||
{ day: 14, emoji: "\u{1F68C}", label: "Bus to Dolomites", color: "#22d3ee", bg: "rgba(6,182,212,0.15)" },
|
||||
{ day: 15, emoji: "\u{1F97E}", label: "Tre Cime Circuit", color: "#34d399", bg: "rgba(16,185,129,0.15)" },
|
||||
{ day: 16, emoji: "\u{1FA82}", label: "Paragliding", color: "#fbbf24", bg: "rgba(245,158,11,0.15)" },
|
||||
{ day: 17, emoji: "\u{1F6F6}", label: "Lago di Braies", color: "#fbbf24", bg: "rgba(245,158,11,0.15)" },
|
||||
{ day: 18, emoji: "\u{1F97E}", label: "Seceda Ridge", color: "#34d399", bg: "rgba(16,185,129,0.15)" },
|
||||
{ day: 19, emoji: "\u{1F4F8}", label: "Rest & Photos", color: "#94a3b8", bg: "rgba(100,116,139,0.15)" },
|
||||
{ day: 20, emoji: "\u2708\uFE0F", label: "Depart", color: "#2dd4bf", bg: "rgba(20,184,166,0.15)" },
|
||||
];
|
||||
|
||||
const TABS = ["Temporal", "Spatial", "Lunar", "Context"];
|
||||
|
||||
const ZOOM_LEVELS = [
|
||||
"Era", "Century", "Decade", "Year", "Quarter",
|
||||
"Month", "Week", "Day", "Hour", "Minute",
|
||||
];
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: "\u{1F50D}",
|
||||
title: "Temporal Zoom",
|
||||
desc: "Navigate seamlessly from geological eras down to individual minutes. The calendar adapts its grid density and label fidelity at every level.",
|
||||
},
|
||||
{
|
||||
icon: "\u{1F30D}",
|
||||
title: "Spatial Context",
|
||||
desc: "Events are location-aware. Zoom the map and the calendar filters to show only events within the visible region.",
|
||||
},
|
||||
{
|
||||
icon: "\u{1F319}",
|
||||
title: "Lunar Cycles",
|
||||
desc: "Overlay moon phases, tidal patterns, and seasonal markers. Useful for agriculture, ceremony, and natural rhythm tracking.",
|
||||
},
|
||||
{
|
||||
icon: "\u{1F4C5}",
|
||||
title: "Multi-Calendar",
|
||||
desc: "Layer Gregorian, Islamic, Hebrew, Chinese, and custom community calendars. Cross-reference events across time systems.",
|
||||
},
|
||||
];
|
||||
|
||||
const LEGEND = [
|
||||
{ color: "#2dd4bf", label: "Travel" },
|
||||
{ color: "#34d399", label: "Hike" },
|
||||
{ color: "#fbbf24", label: "Adventure" },
|
||||
{ color: "#22d3ee", label: "Transit" },
|
||||
{ color: "#a78bfa", label: "Culture" },
|
||||
{ color: "#94a3b8", label: "Rest" },
|
||||
];
|
||||
|
||||
/* ─── Helpers ─────────────────────────────────────────────── */
|
||||
|
||||
function eventForDay(day: number): CalEvent | undefined {
|
||||
return TRIP_EVENTS.find((e) => e.day === day);
|
||||
}
|
||||
|
||||
function isTripDay(day: number): boolean {
|
||||
return day >= 6 && day <= 20;
|
||||
}
|
||||
|
||||
/* ─── Render ──────────────────────────────────────────────── */
|
||||
|
||||
export function renderDemo(): string {
|
||||
// July 2026: starts on Wednesday (offset 2 for Mon-based grid), 31 days
|
||||
const firstDayOffset = 2; // Mon=0, Tue=1, Wed=2
|
||||
const totalDays = 31;
|
||||
const dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
// Build calendar cells
|
||||
const calendarCells: string[] = [];
|
||||
|
||||
// Empty offset cells
|
||||
for (let i = 0; i < firstDayOffset; i++) {
|
||||
calendarCells.push(`<div class="rcal-cell rcal-cell--empty"></div>`);
|
||||
}
|
||||
|
||||
// Day cells
|
||||
for (let d = 1; d <= totalDays; d++) {
|
||||
const ev = eventForDay(d);
|
||||
const trip = isTripDay(d);
|
||||
const todayClass = d === 15 ? " rcal-cell--today" : "";
|
||||
const tripClass = trip ? " rcal-cell--trip" : "";
|
||||
|
||||
let pill = "";
|
||||
if (ev) {
|
||||
pill = `<div class="rcal-pill" style="background:${ev.bg};color:${ev.color};border:1px solid ${ev.color}22;">
|
||||
<span class="rcal-pill__emoji">${ev.emoji}</span>
|
||||
<span class="rcal-pill__label">${ev.label}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
calendarCells.push(`<div class="rcal-cell${tripClass}${todayClass}">
|
||||
<span class="rcal-cell__num${trip ? " rcal-cell__num--trip" : ""}">${d}</span>
|
||||
${pill}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="rd-root" style="--rd-accent-from:#6366f1; --rd-accent-to:#a78bfa;">
|
||||
|
||||
<!-- ── Hero ── -->
|
||||
<section class="rd-hero">
|
||||
<div style="display:inline-block;padding:0.375rem 1rem;background:rgba(99,102,241,0.1);border:1px solid rgba(99,102,241,0.2);border-radius:9999px;font-size:0.875rem;color:#a5b4fc;font-weight:500;margin-bottom:1.5rem;">
|
||||
Multi-Dimensional Calendar
|
||||
</div>
|
||||
<h1>rCal Demo</h1>
|
||||
<p class="rd-subtitle">Multi-dimensional calendar with temporal zoom</p>
|
||||
<div class="rd-meta">
|
||||
<span>\u{1F50D} Temporal Zoom</span>
|
||||
<span style="color:#475569">|</span>
|
||||
<span>\u{1F30D} Spatial Context</span>
|
||||
<span style="color:#475569">|</span>
|
||||
<span>\u{1F319} Lunar Cycles</span>
|
||||
<span style="color:#475569">|</span>
|
||||
<span>\u{1F4C5} Multi-Calendar</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Calendar Section ── -->
|
||||
<section class="rd-section rd-section--narrow">
|
||||
|
||||
<!-- Header bar -->
|
||||
<div class="rd-card" style="margin-bottom:0;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid rgba(51,65,85,0.3);flex-wrap:wrap;gap:0.75rem;">
|
||||
<h2 style="font-size:1.25rem;font-weight:700;color:#f1f5f9;margin:0;display:flex;align-items:center;gap:0.5rem;">
|
||||
\u{1F4C5} July 2026
|
||||
</h2>
|
||||
<div style="display:flex;gap:0.25rem;" id="rcal-tabs">
|
||||
${TABS.map(
|
||||
(tab, i) => `<button data-cal-tab="${tab.toLowerCase()}" style="
|
||||
padding:0.375rem 0.875rem;
|
||||
border-radius:0.5rem;
|
||||
font-size:0.8rem;
|
||||
font-weight:500;
|
||||
border:none;
|
||||
cursor:pointer;
|
||||
transition:all 0.15s;
|
||||
${i === 0 ? "background:rgba(99,102,241,0.15);color:#818cf8;" : "background:transparent;color:#94a3b8;"}
|
||||
">${tab}</button>`,
|
||||
).join("\n ")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Day header row -->
|
||||
<div class="rcal-grid rcal-grid--header">
|
||||
${dayNames.map((d) => `<div class="rcal-day-header">${d}</div>`).join("\n ")}
|
||||
</div>
|
||||
|
||||
<!-- Calendar grid -->
|
||||
<div class="rcal-grid">
|
||||
${calendarCells.join("\n ")}
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div style="display:flex;align-items:center;gap:1rem;padding:0.75rem 1.25rem;border-top:1px solid rgba(51,65,85,0.3);flex-wrap:wrap;">
|
||||
<span style="font-size:0.75rem;color:#64748b;font-weight:500;">Legend:</span>
|
||||
${LEGEND.map(
|
||||
(l) => `<span style="display:flex;align-items:center;gap:0.375rem;font-size:0.75rem;color:#94a3b8;">
|
||||
<span style="width:0.5rem;height:0.5rem;border-radius:9999px;background:${l.color};display:inline-block;"></span>
|
||||
${l.label}
|
||||
</span>`,
|
||||
).join("\n ")}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Zoom Panel ── -->
|
||||
<section class="rd-section rd-section--narrow">
|
||||
<div class="rd-card" style="padding:1.5rem;">
|
||||
<h2 style="font-size:1.125rem;font-weight:600;color:#f1f5f9;margin:0 0 1rem;display:flex;align-items:center;gap:0.5rem;">
|
||||
\u{1F50D} Temporal Zoom
|
||||
</h2>
|
||||
<p style="font-size:0.875rem;color:#94a3b8;margin:0 0 1rem;">
|
||||
Navigate across temporal granularities. The calendar grid adapts at each zoom level.
|
||||
</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">
|
||||
${ZOOM_LEVELS.map(
|
||||
(level) => {
|
||||
const isActive = level === "Month";
|
||||
return `<div style="
|
||||
padding:0.5rem 1rem;
|
||||
border-radius:0.5rem;
|
||||
font-size:0.8rem;
|
||||
font-weight:500;
|
||||
border:1px solid ${isActive ? "rgba(99,102,241,0.4)" : "rgba(51,65,85,0.4)"};
|
||||
background:${isActive ? "rgba(99,102,241,0.15)" : "rgba(30,41,59,0.5)"};
|
||||
color:${isActive ? "#818cf8" : "#64748b"};
|
||||
${isActive ? "box-shadow:0 0 12px rgba(99,102,241,0.2);" : ""}
|
||||
">${level}${isActive ? " \u25C0" : ""}</div>`;
|
||||
},
|
||||
).join("\n ")}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Features ── -->
|
||||
<section class="rd-section">
|
||||
<div class="rd-grid rd-grid--2">
|
||||
${FEATURES.map(
|
||||
(f) => `
|
||||
<div class="rd-card" style="padding:1.5rem;">
|
||||
<div style="font-size:1.75rem;margin-bottom:0.75rem;">${f.icon}</div>
|
||||
<h3 style="font-size:1rem;font-weight:600;color:#e2e8f0;margin:0 0 0.5rem;">${f.title}</h3>
|
||||
<p style="font-size:0.875rem;color:#94a3b8;margin:0;line-height:1.5;">${f.desc}</p>
|
||||
</div>`,
|
||||
).join("")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── CTA ── -->
|
||||
<section class="rd-section rd-section--narrow">
|
||||
<div class="rd-cta">
|
||||
<h2>Coordinate in Time & Space</h2>
|
||||
<p>
|
||||
rCal layers temporal zoom, spatial context, and lunar cycles into a single calendar.
|
||||
Plan events that respect natural rhythms and local conditions.
|
||||
</p>
|
||||
<a href="/create-space" style="background:linear-gradient(135deg,#6366f1,#a78bfa);box-shadow:0 8px 24px rgba(99,102,241,0.25);">
|
||||
Create Your Space
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── rCal demo grid ── */
|
||||
.rcal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
background: rgba(51,65,85,0.2);
|
||||
padding: 0 1px 1px;
|
||||
}
|
||||
.rcal-grid--header {
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid rgba(51,65,85,0.3);
|
||||
}
|
||||
.rcal-day-header {
|
||||
padding: 0.5rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.rcal-cell {
|
||||
min-height: 3.5rem;
|
||||
padding: 0.375rem;
|
||||
background: rgba(15,23,42,0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.rcal-cell:hover {
|
||||
background: rgba(30,41,59,0.8);
|
||||
}
|
||||
.rcal-cell--empty {
|
||||
background: rgba(15,23,42,0.3);
|
||||
}
|
||||
.rcal-cell--trip {
|
||||
background: rgba(99,102,241,0.04);
|
||||
}
|
||||
.rcal-cell--today {
|
||||
outline: 2px solid rgba(99,102,241,0.5);
|
||||
outline-offset: -2px;
|
||||
background: rgba(99,102,241,0.08);
|
||||
}
|
||||
.rcal-cell__num {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
line-height: 1;
|
||||
}
|
||||
.rcal-cell__num--trip {
|
||||
color: #a5b4fc;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rcal-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rcal-pill__emoji {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.rcal-pill__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive: stack pill text on small screens */
|
||||
@media (max-width: 640px) {
|
||||
.rcal-cell {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.rcal-pill__label {
|
||||
display: none;
|
||||
}
|
||||
.rcal-pill {
|
||||
justify-content: center;
|
||||
padding: 0.125rem;
|
||||
}
|
||||
}
|
||||
</style>`;
|
||||
}
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
/**
|
||||
* rCal landing page — relational calendar.
|
||||
* Ported from rcal-online Next.js page.tsx (318 lines).
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
<!-- Hero -->
|
||||
<div class="rl-hero">
|
||||
<span class="rl-tagline" style="color:#60a5fa;background:rgba(96,165,250,0.1);border-color:rgba(96,165,250,0.2)">
|
||||
Relational Calendar
|
||||
</span>
|
||||
<h1 class="rl-heading" style="background:linear-gradient(to right,#60a5fa,#818cf8,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||
Time is shared. Your calendar should be too.
|
||||
</h1>
|
||||
<p class="rl-subtitle">
|
||||
A collaborative calendar for communities, cooperatives, and coordinated groups.
|
||||
</p>
|
||||
<p class="rl-subtext">
|
||||
rCal rethinks the calendar as a <span style="color:#60a5fa;font-weight:600">shared, spatial, and cyclical</span> tool.
|
||||
See events across time and place, overlay lunar cycles, zoom from a single hour to a whole decade,
|
||||
and keep everyone on the same page — without the back-and-forth.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rcal" class="rl-cta-primary" id="ml-primary"
|
||||
style="background:linear-gradient(to right,#60a5fa,#6366f1);color:#0b1120">
|
||||
Try the Demo
|
||||
</a>
|
||||
<a href="#features" class="rl-cta-secondary">Learn More</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Principles (4-card grid) -->
|
||||
<section class="rl-section" style="border-top:none">
|
||||
<div class="rl-container">
|
||||
<div class="rl-grid-4">
|
||||
<div class="rl-card rl-card--center" style="padding:2rem">
|
||||
<div class="rl-icon-box" style="background:rgba(96,165,250,0.12);font-size:1.5rem">
|
||||
<span style="font-size:1.5rem">🤝</span>
|
||||
</div>
|
||||
<h3>Shared by Default</h3>
|
||||
<p>One calendar for the whole group. Everyone sees the same context — no more fragmented schedules.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center" style="padding:2rem">
|
||||
<div class="rl-icon-box" style="background:rgba(129,140,248,0.12);font-size:1.5rem">
|
||||
<span style="font-size:1.5rem">🗺</span>
|
||||
</div>
|
||||
<h3>Spatiotemporal</h3>
|
||||
<p>Events have a where, not just a when. See your schedule on a map and a timeline simultaneously.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center" style="padding:2rem">
|
||||
<div class="rl-icon-box" style="background:rgba(167,139,250,0.12);font-size:1.5rem">
|
||||
<span style="font-size:1.5rem">🌙</span>
|
||||
</div>
|
||||
<h3>Natural Cycles</h3>
|
||||
<p>Lunar phases, eclipses, and solstices built in. Reconnect your planning to the rhythms of the natural world.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center" style="padding:2rem">
|
||||
<div class="rl-icon-box" style="background:rgba(52,211,153,0.12);font-size:1.5rem">
|
||||
<span style="font-size:1.5rem">🔭</span>
|
||||
</div>
|
||||
<h3>Multi-Scale Zoom</h3>
|
||||
<p>Ten levels of time — from a 30-second moment to a cosmic era. See today or plan a decade ahead.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Why rCal -->
|
||||
<section id="features" class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<span class="rl-tagline" style="color:#60a5fa;background:rgba(96,165,250,0.1);border-color:rgba(96,165,250,0.2)">
|
||||
Why rCal?
|
||||
</span>
|
||||
<h2 class="rl-heading" style="background:linear-gradient(135deg,#60a5fa,#818cf8);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||
Calendars were never meant to be personal silos
|
||||
</h2>
|
||||
<p class="rl-subtext" style="margin-bottom:2.5rem">
|
||||
Mainstream calendars treat time as private property. rCal treats it as a <span style="color:#60a5fa;font-weight:600">commons</span> —
|
||||
something groups navigate together. Here’s what makes it different.
|
||||
</p>
|
||||
<div class="rl-grid-2">
|
||||
<div class="rl-card" style="border-color:rgba(96,165,250,0.12)">
|
||||
<div style="font-size:1.75rem;margin-bottom:1rem">📍</div>
|
||||
<h3>Where + When, Together</h3>
|
||||
<p>Every event lives on both a timeline and a map. rCal’s split view lets you see where everyone is meeting and when — with nine spatial zoom levels from planet to street address.</p>
|
||||
</div>
|
||||
<div class="rl-card" style="border-color:rgba(96,165,250,0.12)">
|
||||
<div style="font-size:1.75rem;margin-bottom:1rem">🔗</div>
|
||||
<h3>Coupled Zoom</h3>
|
||||
<p>Lock temporal and spatial zoom together: zoom out in time and the map zooms out to match. Planning a week? See the city. Planning a decade? See the continent.</p>
|
||||
</div>
|
||||
<div class="rl-card" style="border-color:rgba(96,165,250,0.12)">
|
||||
<div style="font-size:1.75rem;margin-bottom:1rem">📡</div>
|
||||
<h3>Multi-Source Sync</h3>
|
||||
<p>Import from Google, Outlook, Apple, CalDAV, ICS feeds, and Obsidian. Layer multiple sources with per-source color coding and visibility controls.</p>
|
||||
</div>
|
||||
<div class="rl-card" style="border-color:rgba(96,165,250,0.12)">
|
||||
<div style="font-size:1.75rem;margin-bottom:1rem">🌑</div>
|
||||
<h3>Lunar Overlay</h3>
|
||||
<p>Eight moon phases rendered on every calendar view with illumination percentages and eclipse detection. Plan gatherings, gardens, and ceremonies around natural cycles.</p>
|
||||
</div>
|
||||
<div class="rl-card" style="border-color:rgba(96,165,250,0.12)">
|
||||
<div style="font-size:1.75rem;margin-bottom:1rem">🧩</div>
|
||||
<h3>r* Ecosystem Embeds</h3>
|
||||
<p>rTrips, rMaps, rNetwork, rCart, and rNotes can all embed a calendar view through the context API. One calendar, surfaced everywhere it’s needed.</p>
|
||||
</div>
|
||||
<div class="rl-card" style="border-color:rgba(96,165,250,0.12)">
|
||||
<div style="font-size:1.75rem;margin-bottom:1rem">🏠</div>
|
||||
<h3>Self-Hosted & Sovereign</h3>
|
||||
<p>Open source and Dockerized. Your events live on your infrastructure — not in a corporate cloud. Full data sovereignty with rIDs authentication.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Temporal Zoom Levels -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<span class="rl-tagline" style="color:#818cf8;background:rgba(129,140,248,0.1);border-color:rgba(129,140,248,0.2)">
|
||||
Temporal Navigation
|
||||
</span>
|
||||
<h2 class="rl-heading" style="background:linear-gradient(135deg,#818cf8,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||
Ten levels of time
|
||||
</h2>
|
||||
<p class="rl-subtext">
|
||||
Most calendars show you a month. rCal lets you zoom from a single moment to a cosmic era —
|
||||
each level revealing a different kind of pattern.
|
||||
</p>
|
||||
<div class="rl-card" style="max-width:700px;margin:2rem auto 0;overflow-x:auto">
|
||||
<div class="rl-zoom-bar" style="min-width:460px">
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">0</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:4%;background:rgba(59,130,246,0.25)">
|
||||
<span class="rl-zoom-bar__name">Moment</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">30 seconds</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">1</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:8%;background:rgba(96,165,250,0.25)">
|
||||
<span class="rl-zoom-bar__name">Hour</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">60 minutes</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">2</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:14%;background:rgba(96,165,250,0.22)">
|
||||
<span class="rl-zoom-bar__name">Day</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">24 hours</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">3</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:22%;background:rgba(129,140,248,0.22)">
|
||||
<span class="rl-zoom-bar__name">Week</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">7 days</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">4</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:32%;background:rgba(129,140,248,0.20)">
|
||||
<span class="rl-zoom-bar__name">Month</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">~30 days</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">5</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:44%;background:rgba(167,139,250,0.20)">
|
||||
<span class="rl-zoom-bar__name">Season</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">~3 months</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">6</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:58%;background:rgba(167,139,250,0.18)">
|
||||
<span class="rl-zoom-bar__name">Year</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">365 days</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">7</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:72%;background:rgba(192,132,252,0.18)">
|
||||
<span class="rl-zoom-bar__name">Decade</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">10 years</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">8</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:86%;background:rgba(168,85,247,0.16)">
|
||||
<span class="rl-zoom-bar__name">Century</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">100 years</span>
|
||||
</div>
|
||||
<div class="rl-zoom-bar__row">
|
||||
<span class="rl-zoom-bar__label">9</span>
|
||||
<div class="rl-zoom-bar__bar" style="width:100%;background:rgba(147,51,234,0.15)">
|
||||
<span class="rl-zoom-bar__name">Cosmic</span>
|
||||
</div>
|
||||
<span class="rl-zoom-bar__span">Geological</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Calendar Views -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<span class="rl-tagline" style="color:#a78bfa;background:rgba(167,139,250,0.1);border-color:rgba(167,139,250,0.2)">
|
||||
Four Views
|
||||
</span>
|
||||
<h2 class="rl-heading" style="background:linear-gradient(135deg,#a78bfa,#c084fc);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||
One calendar, four perspectives
|
||||
</h2>
|
||||
<p class="rl-subtext">
|
||||
Switch between views with keyboard shortcuts (1–4) to see your events from the angle that matters most right now.
|
||||
</p>
|
||||
<div class="rl-grid-2" style="margin-top:2rem">
|
||||
<div class="rl-card">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
|
||||
<div style="width:2.5rem;height:2.5rem;border-radius:0.75rem;background:rgba(59,130,246,0.12);display:flex;align-items:center;justify-content:center;font-size:1.1rem">
|
||||
📅
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin-bottom:0">Temporal</h3>
|
||||
<span style="font-size:0.7rem;color:#64748b;font-family:monospace">Press 1</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>The classic calendar view — month, week, day, year, and season — enhanced with multi-granularity zoom and event indicators.</p>
|
||||
</div>
|
||||
<div class="rl-card">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
|
||||
<div style="width:2.5rem;height:2.5rem;border-radius:0.75rem;background:rgba(52,211,153,0.12);display:flex;align-items:center;justify-content:center;font-size:1.1rem">
|
||||
🗺
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin-bottom:0">Spatial</h3>
|
||||
<span style="font-size:0.7rem;color:#64748b;font-family:monospace">Press 2</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>Interactive map powered by Leaflet. Events cluster by location with nine spatial granularity levels from planet to GPS coordinates.</p>
|
||||
</div>
|
||||
<div class="rl-card">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
|
||||
<div style="width:2.5rem;height:2.5rem;border-radius:0.75rem;background:rgba(245,158,11,0.12);display:flex;align-items:center;justify-content:center;font-size:1.1rem">
|
||||
🌙
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin-bottom:0">Lunar</h3>
|
||||
<span style="font-size:0.7rem;color:#64748b;font-family:monospace">Press 3</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>Moon phase overlay with illumination percentages, eclipse detection, and phase-colored day cells. Plan around the eight phases of the lunar cycle.</p>
|
||||
</div>
|
||||
<div class="rl-card">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
|
||||
<div style="width:2.5rem;height:2.5rem;border-radius:0.75rem;background:rgba(34,211,238,0.12);display:flex;align-items:center;justify-content:center;font-size:1.1rem">
|
||||
🧩
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin-bottom:0">Context</h3>
|
||||
<span style="font-size:0.7rem;color:#64748b;font-family:monospace">Press 4</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>When embedded inside another r* tool, this view shows calendar data filtered for that tool’s entity — a trip, a network, a map layer.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Ecosystem Integration -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<span class="rl-tagline" style="color:#34d399;background:rgba(52,211,153,0.1);border-color:rgba(52,211,153,0.2)">
|
||||
Ecosystem
|
||||
</span>
|
||||
<h2 class="rl-heading" style="background:linear-gradient(135deg,#34d399,#22d3ee);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||
Part of the r* stack
|
||||
</h2>
|
||||
<p class="rl-subtext">
|
||||
rCal connects to the full suite of community tools. Any r* app can display or create calendar events through the shared context API.
|
||||
</p>
|
||||
<div class="rl-grid-3" style="margin-top:2rem">
|
||||
<div class="rl-integration" style="border-color:rgba(52,211,153,0.15)">
|
||||
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">🗺</span></div>
|
||||
<div>
|
||||
<h3>rTrips</h3>
|
||||
<p>Trip itineraries auto-populate with calendar events for departure, accommodation, and activities.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-integration" style="border-color:rgba(52,211,153,0.15)">
|
||||
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">📍</span></div>
|
||||
<div>
|
||||
<h3>rMaps</h3>
|
||||
<p>Location-tagged events appear on shared community maps with time-filtered layers.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-integration" style="border-color:rgba(52,211,153,0.15)">
|
||||
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">👥</span></div>
|
||||
<div>
|
||||
<h3>rNetwork</h3>
|
||||
<p>See when your community members are available and schedule group meetings.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-integration" style="border-color:rgba(52,211,153,0.15)">
|
||||
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">🛒</span></div>
|
||||
<div>
|
||||
<h3>rCart</h3>
|
||||
<p>Product launches, market days, and delivery windows sync to your calendar.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-integration" style="border-color:rgba(52,211,153,0.15)">
|
||||
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">📝</span></div>
|
||||
<div>
|
||||
<h3>rNotes</h3>
|
||||
<p>Meeting notes link back to calendar events. Transcriptions attach to the moment they happened.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-integration" style="border-color:rgba(52,211,153,0.15)">
|
||||
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">🌐</span></div>
|
||||
<div>
|
||||
<h3>rSpace</h3>
|
||||
<p>Each space gets its own calendar. Subdomain routing means each community has a dedicated view.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading" style="background:linear-gradient(to right,#60a5fa,#818cf8,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||
See time differently
|
||||
</h2>
|
||||
<p class="rl-subtext">
|
||||
Try the spatiotemporal calendar with lunar overlays, multi-source sync, and community sharing.
|
||||
No account needed for the demo.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rcal" class="rl-cta-primary"
|
||||
style="background:linear-gradient(to right,#60a5fa,#6366f1);color:#0b1120">
|
||||
Open the Demo
|
||||
</a>
|
||||
<a href="https://rstack.online" class="rl-cta-secondary">Explore rStack</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rl-back">
|
||||
<a href="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -9,11 +9,12 @@ import { Hono } from "hono";
|
|||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { renderShell, renderDemoShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
import { renderLanding } from "./landing";
|
||||
import { renderDemo } from "./demo";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -376,6 +377,18 @@ routes.get("/api/context/:tool", async (c) => {
|
|||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
if (space === "demo") {
|
||||
return c.html(renderDemoShell({
|
||||
title: "rCal Demo — rSpace",
|
||||
moduleId: "rcal",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: renderDemo(),
|
||||
scripts: `<script type="module" src="/modules/rcal/cal-demo.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css">`,
|
||||
}));
|
||||
}
|
||||
return c.html(renderShell({
|
||||
title: `${space} — Calendar | rSpace`,
|
||||
moduleId: "rcal",
|
||||
|
|
@ -383,8 +396,8 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
|
||||
scripts: `<script type="module" src="/modules/cal/folk-calendar-view.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/cal/cal.css">`,
|
||||
scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js?v=3"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css?v=2">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -396,6 +409,7 @@ export const calModule: RSpaceModule = {
|
|||
routes,
|
||||
standaloneDomain: "rcal.online",
|
||||
landingPage: renderLanding,
|
||||
demoPage: renderDemo,
|
||||
feeds: [
|
||||
{
|
||||
id: "events",
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
// rCart demo — static display, no client interactivity needed
|
||||
export {};
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
class FolkCartShop extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "default";
|
||||
private catalog: any[] = [];
|
||||
private orders: any[] = [];
|
||||
private view: "catalog" | "orders" = "catalog";
|
||||
|
|
@ -16,10 +17,171 @@ class FolkCartShop extends HTMLElement {
|
|||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Resolve space from attribute or URL path
|
||||
const attr = this.getAttribute("space");
|
||||
if (attr) {
|
||||
this.space = attr;
|
||||
} else {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
this.space = parts.length >= 1 ? parts[0] : "default";
|
||||
}
|
||||
|
||||
if (this.space === "demo") {
|
||||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
|
||||
this.render();
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
const now = Date.now();
|
||||
this.catalog = [
|
||||
{
|
||||
id: "demo-cat-1",
|
||||
title: "The Commons",
|
||||
description: "A pocket book exploring shared resources and collective stewardship.",
|
||||
price: 12,
|
||||
currency: "USD",
|
||||
tags: ["books"],
|
||||
product_type: "pocket book",
|
||||
status: "active",
|
||||
created_at: new Date(now - 30 * 86400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "demo-cat-2",
|
||||
title: "Mycelium Networks",
|
||||
description: "Illustrated poster mapping underground fungal communication pathways.",
|
||||
price: 18,
|
||||
currency: "USD",
|
||||
tags: ["prints"],
|
||||
product_type: "poster",
|
||||
status: "active",
|
||||
created_at: new Date(now - 25 * 86400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "demo-cat-3",
|
||||
title: "#DefectFi",
|
||||
description: "Organic cotton tee shirt with the #DefectFi campaign logo.",
|
||||
price: 25,
|
||||
currency: "USD",
|
||||
tags: ["apparel"],
|
||||
product_type: "tee shirt",
|
||||
status: "active",
|
||||
created_at: new Date(now - 20 * 86400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "demo-cat-4",
|
||||
title: "Cosmolocal Sticker Sheet",
|
||||
description: "Die-cut sticker sheet with cosmolocal design motifs.",
|
||||
price: 5,
|
||||
currency: "USD",
|
||||
tags: ["stickers"],
|
||||
product_type: "sticker sheet",
|
||||
status: "active",
|
||||
created_at: new Date(now - 15 * 86400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "demo-cat-5",
|
||||
title: "Doughnut Economics",
|
||||
description: "A zine breaking down Kate Raworth's doughnut economics framework.",
|
||||
price: 8,
|
||||
currency: "USD",
|
||||
tags: ["books"],
|
||||
product_type: "zine",
|
||||
status: "active",
|
||||
created_at: new Date(now - 10 * 86400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "demo-cat-6",
|
||||
title: "rSpace Logo",
|
||||
description: "Embroidered patch featuring the rSpace logo on twill backing.",
|
||||
price: 6,
|
||||
currency: "USD",
|
||||
tags: ["accessories"],
|
||||
product_type: "embroidered patch",
|
||||
status: "active",
|
||||
created_at: new Date(now - 5 * 86400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "demo-cat-7",
|
||||
title: "Cosmolocal Network Tee",
|
||||
description: "Bella+Canvas 3001 tee with the Cosmolocal Network radial design. DTG printed by local providers or Printful.",
|
||||
price: 25,
|
||||
currency: "USD",
|
||||
tags: ["apparel", "cosmolocal"],
|
||||
product_type: "tee",
|
||||
required_capabilities: ["dtg-print"],
|
||||
status: "active",
|
||||
created_at: new Date(now - 3 * 86400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "demo-cat-8",
|
||||
title: "Cosmolocal Sticker Sheet",
|
||||
description: "Kiss-cut vinyl sticker sheet with cosmolocal network motifs. Weatherproof and UV-resistant.",
|
||||
price: 5,
|
||||
currency: "USD",
|
||||
tags: ["stickers", "cosmolocal"],
|
||||
product_type: "sticker-sheet",
|
||||
required_capabilities: ["vinyl-cut"],
|
||||
status: "active",
|
||||
created_at: new Date(now - 1 * 86400000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
this.orders = [
|
||||
{
|
||||
id: "demo-ord-1001",
|
||||
items: [
|
||||
{ title: "The Commons", qty: 1, price: 12 },
|
||||
{ title: "Mycelium Networks", qty: 1, price: 18 },
|
||||
],
|
||||
total: 30,
|
||||
total_price: "30.00",
|
||||
currency: "USD",
|
||||
status: "paid",
|
||||
created_at: new Date(now - 2 * 86400000).toISOString(),
|
||||
customer_email: "reader@example.com",
|
||||
artifact_title: "Order #1001",
|
||||
quantity: 2,
|
||||
},
|
||||
{
|
||||
id: "demo-ord-1002",
|
||||
items: [
|
||||
{ title: "#DefectFi", qty: 1, price: 25 },
|
||||
],
|
||||
total: 25,
|
||||
total_price: "25.00",
|
||||
currency: "USD",
|
||||
status: "pending",
|
||||
created_at: new Date(now - 1 * 86400000).toISOString(),
|
||||
customer_email: "activist@example.com",
|
||||
artifact_title: "Order #1002",
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
id: "demo-ord-1003",
|
||||
items: [
|
||||
{ title: "Cosmolocal Sticker Sheet", qty: 1, price: 5 },
|
||||
{ title: "Doughnut Economics", qty: 1, price: 8 },
|
||||
{ title: "rSpace Logo", qty: 1, price: 6 },
|
||||
],
|
||||
total: 23,
|
||||
total_price: "23.00",
|
||||
currency: "USD",
|
||||
status: "shipped",
|
||||
created_at: new Date(now - 5 * 86400000).toISOString(),
|
||||
customer_email: "maker@example.com",
|
||||
artifact_title: "Order #1003",
|
||||
quantity: 3,
|
||||
},
|
||||
];
|
||||
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
|
|
@ -70,11 +232,16 @@ class FolkCartShop extends HTMLElement {
|
|||
.status-active { background: rgba(34,197,94,0.15); color: #4ade80; }
|
||||
.status-completed { background: rgba(99,102,241,0.15); color: #a5b4fc; }
|
||||
.status-cancelled { background: rgba(239,68,68,0.15); color: #f87171; }
|
||||
.status-shipped { background: rgba(56,189,248,0.15); color: #38bdf8; }
|
||||
.price { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin-top: 0.5rem; }
|
||||
.order-card { display: flex; justify-content: space-between; align-items: center; }
|
||||
.order-info { flex: 1; }
|
||||
.order-price { color: #f1f5f9; font-weight: 600; font-size: 1.125rem; }
|
||||
.empty { text-align: center; padding: 3rem; color: #64748b; font-size: 0.875rem; }
|
||||
.loading { text-align: center; padding: 3rem; color: #94a3b8; }
|
||||
@media (max-width: 480px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="rapp-nav">
|
||||
|
|
@ -109,9 +276,11 @@ class FolkCartShop extends HTMLElement {
|
|||
<div class="card-meta">
|
||||
${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""}
|
||||
${(entry.required_capabilities || []).map((cap: string) => `<span class="tag tag-cap">${this.esc(cap)}</span>`).join("")}
|
||||
${(entry.tags || []).map((t: string) => `<span class="tag tag-cap">${this.esc(t)}</span>`).join("")}
|
||||
</div>
|
||||
${entry.description ? `<div class="card-meta">${this.esc(entry.description)}</div>` : ""}
|
||||
${entry.dimensions ? `<div class="dims">${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm</div>` : ""}
|
||||
${entry.price != null ? `<div class="price">$${parseFloat(entry.price).toFixed(2)} ${entry.currency || ""}</div>` : ""}
|
||||
<div style="margin-top:0.5rem"><span class="status status-${entry.status}">${entry.status}</span></div>
|
||||
</div>
|
||||
`).join("")}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* rCart demo page — static community garden shopping cart.
|
||||
*
|
||||
* Renders a fully server-side demo with 8 cart items, funding progress bars,
|
||||
* member activity, and summary stats. No WebSocket needed (all static data).
|
||||
*/
|
||||
|
||||
/* ─── Mock Data ─────────────────────────────────────────────── */
|
||||
|
||||
const members = [
|
||||
{ name: "Alice", color: "#10b981" },
|
||||
{ name: "Bob", color: "#0ea5e9" },
|
||||
{ name: "Carol", color: "#f59e0b" },
|
||||
{ name: "Dave", color: "#8b5cf6" },
|
||||
];
|
||||
|
||||
interface CartItem {
|
||||
name: string;
|
||||
price: number;
|
||||
requestedBy: string;
|
||||
funded: number;
|
||||
status: "Funded" | "In Cart" | "Needs Funding";
|
||||
}
|
||||
|
||||
const cartItems: CartItem[] = [
|
||||
{ name: "Raised Garden Bed Kit (4x8 ft)", price: 89.99, requestedBy: "Alice", funded: 89.99, status: "Funded" },
|
||||
{ name: "Organic Seed Variety Pack (30 types)", price: 34.5, requestedBy: "Carol", funded: 34.5, status: "Funded" },
|
||||
{ name: "Premium Potting Soil (40 qt, 3-pack)", price: 47.99, requestedBy: "Bob", funded: 32.0, status: "In Cart" },
|
||||
{ name: "Stainless Steel Garden Tool Set", price: 62.0, requestedBy: "Dave", funded: 62.0, status: "Funded" },
|
||||
{ name: "Drip Irrigation Kit (100 ft)", price: 54.95, requestedBy: "Alice", funded: 20.0, status: "Needs Funding" },
|
||||
{ name: "Compost Tumbler (45 gal)", price: 109.0, requestedBy: "Bob", funded: 109.0, status: "Funded" },
|
||||
{ name: "Garden Kneeling Pad & Gloves Set", price: 28.5, requestedBy: "Carol", funded: 12.0, status: "Needs Funding" },
|
||||
{ name: "Solar-Powered Pest Repeller (4-pack)", price: 39.99, requestedBy: "Dave", funded: 39.99, status: "In Cart" },
|
||||
];
|
||||
|
||||
/* ─── Helpers ──────────────────────────────────────────────── */
|
||||
|
||||
function getMemberColor(name: string): string {
|
||||
return members.find((m) => m.name === name)?.color || "#64748b";
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: CartItem["status"]): string {
|
||||
switch (status) {
|
||||
case "Funded":
|
||||
return "rd-badge--emerald";
|
||||
case "In Cart":
|
||||
return "rd-badge--sky";
|
||||
case "Needs Funding":
|
||||
return "rd-badge--amber";
|
||||
}
|
||||
}
|
||||
|
||||
function progressFillClass(pct: number): string {
|
||||
if (pct >= 100) return "rd-progress__fill--emerald";
|
||||
if (pct >= 50) return "rd-progress__fill--sky";
|
||||
return "rd-progress__fill--amber";
|
||||
}
|
||||
|
||||
/* ─── Render ─────────────────────────────────────────────── */
|
||||
|
||||
export function renderDemo(): string {
|
||||
const totalCost = cartItems.reduce((sum, item) => sum + item.price, 0);
|
||||
const totalFunded = cartItems.reduce((sum, item) => sum + item.funded, 0);
|
||||
const perPerson = totalCost / members.length;
|
||||
const fundedCount = cartItems.filter((i) => i.status === "Funded").length;
|
||||
const overallPct = Math.round((totalFunded / totalCost) * 100);
|
||||
const uniqueRequesters = new Set(cartItems.map((i) => i.requestedBy)).size;
|
||||
|
||||
return `
|
||||
<div class="rd-root" style="--rd-accent-from: #10b981; --rd-accent-to: #2dd4bf;">
|
||||
|
||||
<!-- ── Hero ── -->
|
||||
<section class="rd-hero">
|
||||
<div style="display:inline-block;padding:0.375rem 1rem;background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.2);border-radius:9999px;font-size:0.875rem;color:#6ee7b7;font-weight:500;margin-bottom:1.5rem;">
|
||||
Group Shopping, Together
|
||||
</div>
|
||||
<h1>See how rCart works</h1>
|
||||
<p class="rd-subtitle">
|
||||
A community garden project where neighbors pool resources to buy everything they need together.
|
||||
</p>
|
||||
<div class="rd-meta">
|
||||
<span>8 items</span>
|
||||
<span style="color:#475569">|</span>
|
||||
<span>$${totalCost.toFixed(2)} total</span>
|
||||
<span style="color:#475569">|</span>
|
||||
<span>${fundedCount}/${cartItems.length} funded</span>
|
||||
</div>
|
||||
<div class="rd-avatars">
|
||||
${members
|
||||
.map(
|
||||
(m) =>
|
||||
`<div class="rd-avatar" style="background:${m.color}" title="${m.name}">${m.name[0]}</div>`,
|
||||
)
|
||||
.join("\n ")}
|
||||
<span class="rd-count">${members.length} members</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Overall Funding Progress ── -->
|
||||
<section class="rd-section rd-section--narrow">
|
||||
<div class="rd-card" style="padding:1.5rem;margin-bottom:1.5rem;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
|
||||
<div>
|
||||
<h2 style="font-size:1.125rem;font-weight:600;color:#f1f5f9;margin:0 0 0.25rem;">Community Garden Project</h2>
|
||||
<p class="rd-text-xs rd-text-muted" style="margin:0;">Shared cart for our neighborhood garden setup</p>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<p style="font-size:1.5rem;font-weight:700;color:white;margin:0;">$${totalFunded.toFixed(2)}</p>
|
||||
<p class="rd-text-xs rd-text-muted" style="margin:0;">of $${totalCost.toFixed(2)} funded</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rd-progress" style="margin-bottom:0.5rem;">
|
||||
<div class="rd-progress__fill" style="width:${overallPct}%"></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span class="rd-text-xs rd-text-muted">${overallPct}% funded</span>
|
||||
<span class="rd-text-xs rd-text-muted">$${(totalCost - totalFunded).toFixed(2)} remaining</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Cart Items ── -->
|
||||
<div class="rd-card" style="margin-bottom:1.5rem;">
|
||||
<div class="rd-card-header">
|
||||
<div class="rd-card-title"><span class="rd-icon">🛒</span> Cart Items</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;font-size:0.75rem;color:#94a3b8;">
|
||||
<span style="display:flex;align-items:center;gap:0.25rem;">
|
||||
<span style="width:0.5rem;height:0.5rem;border-radius:9999px;background:#10b981;display:inline-block;"></span> Funded
|
||||
</span>
|
||||
<span style="display:flex;align-items:center;gap:0.25rem;">
|
||||
<span style="width:0.5rem;height:0.5rem;border-radius:9999px;background:#0ea5e9;display:inline-block;"></span> In Cart
|
||||
</span>
|
||||
<span style="display:flex;align-items:center;gap:0.25rem;">
|
||||
<span style="width:0.5rem;height:0.5rem;border-radius:9999px;background:#f59e0b;display:inline-block;"></span> Needs Funding
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
${cartItems
|
||||
.map((item) => {
|
||||
const pct = Math.round((item.funded / item.price) * 100);
|
||||
const memberColor = getMemberColor(item.requestedBy);
|
||||
return `
|
||||
<div style="padding:1rem 1.25rem;${cartItems.indexOf(item) > 0 ? "border-top:1px solid rgba(51,65,85,0.3);" : ""}">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:0.5rem;">
|
||||
<div style="min-width:0;flex:1;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem;">
|
||||
<span class="rd-text-sm rd-font-medium" style="color:#e2e8f0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${item.name}</span>
|
||||
<span class="rd-badge ${statusBadgeClass(item.status)}">${item.status}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;color:#94a3b8;">
|
||||
<span style="display:flex;align-items:center;gap:0.375rem;">
|
||||
<span style="width:1rem;height:1rem;background:${memberColor};border-radius:9999px;display:inline-flex;align-items:center;justify-content:center;font-size:0.625rem;font-weight:700;color:white;">${item.requestedBy[0]}</span>
|
||||
${item.requestedBy}
|
||||
</span>
|
||||
<span style="color:#475569;">requested this</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;flex-shrink:0;">
|
||||
<p class="rd-text-sm rd-font-semibold" style="color:#e2e8f0;margin:0;">$${item.price.toFixed(2)}</p>
|
||||
${item.status !== "Funded" ? `<p class="rd-text-xs" style="color:#64748b;margin:0;">$${item.funded.toFixed(2)} funded</p>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rd-progress rd-progress--sm">
|
||||
<div class="rd-progress__fill ${progressFillClass(pct)}" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("")}
|
||||
</div>
|
||||
|
||||
<!-- ── Summary Grid ── -->
|
||||
<div class="rd-grid rd-grid--3" style="margin-bottom:1.5rem;">
|
||||
<div class="rd-stat">
|
||||
<p class="rd-text-xs rd-font-semibold rd-text-muted" style="text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.5rem;">Total Cost</p>
|
||||
<p class="rd-stat__value">$${totalCost.toFixed(2)}</p>
|
||||
<p class="rd-stat__sub">${cartItems.length} items across ${uniqueRequesters} requesters</p>
|
||||
</div>
|
||||
<div class="rd-stat">
|
||||
<p class="rd-text-xs rd-font-semibold rd-text-muted" style="text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.5rem;">Amount Funded</p>
|
||||
<p class="rd-stat__value" style="color:#34d399;">$${totalFunded.toFixed(2)}</p>
|
||||
<p class="rd-stat__sub">${fundedCount} of ${cartItems.length} items fully funded</p>
|
||||
</div>
|
||||
<div class="rd-stat">
|
||||
<p class="rd-text-xs rd-font-semibold rd-text-muted" style="text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.5rem;">Per-Person Split</p>
|
||||
<p class="rd-stat__value" style="color:#2dd4bf;">$${perPerson.toFixed(2)}</p>
|
||||
<p class="rd-stat__sub">split equally among ${members.length} members</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Member Activity ── -->
|
||||
<div class="rd-card" style="padding:1.5rem;">
|
||||
<h3 class="rd-text-sm rd-font-semibold" style="color:#cbd5e1;margin:0 0 1rem;">Member Activity</h3>
|
||||
<div class="rd-grid rd-grid--2">
|
||||
${members
|
||||
.map((member) => {
|
||||
const requested = cartItems.filter((i) => i.requestedBy === member.name);
|
||||
const requestedTotal = requested.reduce((sum, i) => sum + i.price, 0);
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;background:rgba(51,65,85,0.2);border-radius:0.75rem;padding:0.75rem;">
|
||||
<div class="rd-avatar" style="background:${member.color};flex-shrink:0;width:2.5rem;height:2.5rem;font-size:0.875rem;">${member.name[0]}</div>
|
||||
<div style="min-width:0;flex:1;">
|
||||
<p class="rd-text-sm rd-font-medium" style="color:#e2e8f0;margin:0;">${member.name}</p>
|
||||
<p class="rd-text-xs rd-text-muted" style="margin:0;">
|
||||
${requested.length} item${requested.length !== 1 ? "s" : ""} requested
|
||||
<span style="color:#475569;margin:0 0.375rem;">·</span>
|
||||
$${requestedTotal.toFixed(2)} total
|
||||
</p>
|
||||
</div>
|
||||
<div style="text-align:right;flex-shrink:0;">
|
||||
<p class="rd-text-sm rd-font-medium" style="color:#cbd5e1;margin:0;">$${perPerson.toFixed(2)}</p>
|
||||
<p class="rd-text-xs" style="color:#64748b;margin:0;">share</p>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── CTA ── -->
|
||||
<section class="rd-section rd-section--narrow">
|
||||
<div class="rd-cta">
|
||||
<h2>Ready to shop together?</h2>
|
||||
<p>
|
||||
Create a shared cart for your group, community, or team. Add items from any store,
|
||||
split costs fairly, and check out together.
|
||||
</p>
|
||||
<a href="/create-space" style="background:linear-gradient(135deg, #10b981, #059669);box-shadow:0 8px 24px rgba(16,185,129,0.25);">
|
||||
Create Your First Cart
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -10,12 +10,13 @@ import { Hono } from "hono";
|
|||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { renderShell, renderDemoShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import { depositOrderRevenue } from "./flow";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
import { renderLanding } from "./landing";
|
||||
import { renderDemo } from "./demo";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -442,6 +443,18 @@ routes.post("/api/fulfill/resolve", async (c) => {
|
|||
// ── Page route: shop ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
if (space === "demo") {
|
||||
return c.html(renderDemoShell({
|
||||
title: "rCart Demo — rSpace",
|
||||
moduleId: "rcart",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: renderDemo(),
|
||||
scripts: `<script type="module" src="/modules/rcart/cart-demo.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
|
||||
}));
|
||||
}
|
||||
return c.html(renderShell({
|
||||
title: `Shop | rSpace`,
|
||||
moduleId: "rcart",
|
||||
|
|
@ -449,8 +462,8 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-cart-shop space="${space}"></folk-cart-shop>`,
|
||||
scripts: `<script type="module" src="/modules/cart/folk-cart-shop.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/cart/cart.css">`,
|
||||
scripts: `<script type="module" src="/modules/rcart/folk-cart-shop.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -462,6 +475,7 @@ export const cartModule: RSpaceModule = {
|
|||
routes,
|
||||
standaloneDomain: "rcart.online",
|
||||
landingPage: renderLanding,
|
||||
demoPage: renderDemo,
|
||||
feeds: [
|
||||
{
|
||||
id: "orders",
|
||||
|
|
@ -0,0 +1,496 @@
|
|||
/**
|
||||
* <folk-choices-dashboard> — lists choice shapes (polls, rankings, spider charts)
|
||||
* from the current space and links to the canvas to create/interact with them.
|
||||
*/
|
||||
|
||||
class FolkChoicesDashboard extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private choices: any[] = [];
|
||||
private loading = true;
|
||||
private space: string;
|
||||
|
||||
/* Demo state */
|
||||
private demoTab: "spider" | "ranking" | "voting" = "spider";
|
||||
private hoveredPerson: string | null = null;
|
||||
private rankItems: { id: number; name: string; emoji: string }[] = [];
|
||||
private rankDragging: number | null = null;
|
||||
private voteOptions: { id: string; name: string; color: string; votes: number }[] = [];
|
||||
private voted = false;
|
||||
private votedId: string | null = null;
|
||||
private simTimer: number | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this.space === "demo") {
|
||||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
this.render();
|
||||
this.loadChoices();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.simTimer !== null) {
|
||||
clearInterval(this.simTimer);
|
||||
this.simTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
return parts.length >= 2 ? `/${parts[0]}/choices` : "/demo/choices";
|
||||
}
|
||||
|
||||
private async loadChoices() {
|
||||
this.loading = true;
|
||||
this.render();
|
||||
try {
|
||||
const res = await fetch(`${this.getApiBase()}/api/choices`);
|
||||
const data = await res.json();
|
||||
this.choices = data.choices || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load choices:", e);
|
||||
}
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
const typeIcons: Record<string, string> = {
|
||||
"folk-choice-vote": "☑",
|
||||
"folk-choice-rank": "📊",
|
||||
"folk-choice-spider": "🕸",
|
||||
};
|
||||
const typeLabels: Record<string, string> = {
|
||||
"folk-choice-vote": "Poll",
|
||||
"folk-choice-rank": "Ranking",
|
||||
"folk-choice-spider": "Spider Chart",
|
||||
};
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; padding: 1.5rem; }
|
||||
.rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
|
||||
.create-btns { display: flex; gap: 0.5rem; }
|
||||
.create-btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; text-decoration: none; }
|
||||
.create-btn:hover { border-color: #6366f1; color: #f1f5f9; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
|
||||
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; cursor: pointer; text-decoration: none; display: block; }
|
||||
.card:hover { border-color: #6366f1; }
|
||||
.card-icon { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
.card-title { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin: 0 0 0.25rem; }
|
||||
.card-type { color: #818cf8; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
||||
.card-meta { color: #94a3b8; font-size: 0.8125rem; }
|
||||
.stat { display: inline-block; margin-right: 1rem; }
|
||||
.empty { text-align: center; padding: 3rem; color: #64748b; }
|
||||
.empty-icon { font-size: 3rem; margin-bottom: 1rem; }
|
||||
.empty p { margin: 0.5rem 0; font-size: 0.875rem; }
|
||||
.loading { text-align: center; padding: 3rem; color: #94a3b8; }
|
||||
.info { background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; color: #a5b4fc; font-size: 0.8125rem; }
|
||||
</style>
|
||||
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Choices</span>
|
||||
<div class="create-btns">
|
||||
<a class="create-btn" href="/${this.space}/rspace" title="Open canvas to create choices">➕ New on Canvas</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
Choice tools (Polls, Rankings, Spider Charts) live on the collaborative canvas.
|
||||
Create them there and they'll appear here for quick access.
|
||||
</div>
|
||||
|
||||
${this.loading ? `<div class="loading">⏳ Loading choices...</div>` :
|
||||
this.choices.length === 0 ? this.renderEmpty() : this.renderGrid(typeIcons, typeLabels)}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmpty(): string {
|
||||
return `<div class="empty">
|
||||
<div class="empty-icon">☑</div>
|
||||
<p>No choices in this space yet.</p>
|
||||
<p>Open the <a href="/${this.space}/rspace" style="color:#818cf8">canvas</a> and use the Poll, Rank, or Spider buttons to create one.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderGrid(icons: Record<string, string>, labels: Record<string, string>): string {
|
||||
return `<div class="grid">
|
||||
${this.choices.map((ch) => `
|
||||
<a class="card" href="/${this.space}/rspace">
|
||||
<div class="card-icon">${icons[ch.type] || "☑"}</div>
|
||||
<div class="card-type">${labels[ch.type] || ch.type}</div>
|
||||
<h3 class="card-title">${this.esc(ch.title)}</h3>
|
||||
<div class="card-meta">
|
||||
<span class="stat">${ch.optionCount} options</span>
|
||||
<span class="stat">${ch.voteCount} responses</span>
|
||||
</div>
|
||||
</a>
|
||||
`).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ===== Demo mode ===== */
|
||||
|
||||
private loadDemoData() {
|
||||
this.rankItems = [
|
||||
{ id: 1, name: "Thai Place", emoji: "🍜" },
|
||||
{ id: 2, name: "Pizza", emoji: "🍕" },
|
||||
{ id: 3, name: "Sushi Bar", emoji: "🍣" },
|
||||
{ id: 4, name: "Tacos", emoji: "🌮" },
|
||||
{ id: 5, name: "Burgers", emoji: "🍔" },
|
||||
];
|
||||
this.voteOptions = [
|
||||
{ id: "action", name: "Action Movie", color: "#ef4444", votes: 2 },
|
||||
{ id: "comedy", name: "Comedy", color: "#f59e0b", votes: 3 },
|
||||
{ id: "horror", name: "Horror", color: "#8b5cf6", votes: 1 },
|
||||
{ id: "scifi", name: "Sci-Fi", color: "#06b6d4", votes: 2 },
|
||||
];
|
||||
this.voted = false;
|
||||
this.votedId = null;
|
||||
this.startVoteSim();
|
||||
this.renderDemo();
|
||||
}
|
||||
|
||||
private startVoteSim() {
|
||||
if (this.simTimer !== null) clearInterval(this.simTimer);
|
||||
const tick = () => {
|
||||
if (this.voted) return;
|
||||
const idx = Math.floor(Math.random() * this.voteOptions.length);
|
||||
this.voteOptions[idx].votes += 1;
|
||||
if (this.demoTab === "voting") this.renderDemo();
|
||||
};
|
||||
const scheduleNext = () => {
|
||||
const delay = 1200 + Math.random() * 2000;
|
||||
this.simTimer = window.setTimeout(() => {
|
||||
tick();
|
||||
scheduleNext();
|
||||
}, delay) as unknown as number;
|
||||
};
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
private renderDemo() {
|
||||
const tabs: { key: "spider" | "ranking" | "voting"; label: string; icon: string }[] = [
|
||||
{ key: "spider", label: "Spider Chart", icon: "🕸" },
|
||||
{ key: "ranking", label: "Ranking", icon: "📊" },
|
||||
{ key: "voting", label: "Live Voting", icon: "☑" },
|
||||
];
|
||||
|
||||
let content = "";
|
||||
if (this.demoTab === "spider") content = this.renderSpider();
|
||||
else if (this.demoTab === "ranking") content = this.renderRanking();
|
||||
else content = this.renderVoting();
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; padding: 1.5rem; }
|
||||
.rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
|
||||
.demo-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 999px; background: #6366f1; color: #fff; font-weight: 500; }
|
||||
|
||||
/* Tabs */
|
||||
.demo-tabs { display: flex; gap: 4px; margin-bottom: 1.5rem; background: #0f172a; border-radius: 10px; padding: 4px; }
|
||||
.demo-tab { flex: 1; text-align: center; padding: 0.6rem 0.75rem; border-radius: 8px; border: none; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.875rem; font-family: inherit; transition: all 0.15s; }
|
||||
.demo-tab:hover { color: #e2e8f0; background: #1e293b; }
|
||||
.demo-tab.active { background: #1e293b; color: #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||||
.demo-tab-icon { margin-right: 6px; }
|
||||
|
||||
/* Spider chart */
|
||||
.spider-wrap { display: flex; flex-direction: column; align-items: center; }
|
||||
.spider-svg { width: 100%; max-width: 420px; }
|
||||
.spider-legend { display: flex; gap: 1.25rem; margin-top: 1rem; justify-content: center; flex-wrap: wrap; }
|
||||
.spider-legend-item { display: flex; align-items: center; gap: 6px; cursor: pointer; padding: 4px 10px; border-radius: 6px; transition: background 0.15s; font-size: 0.875rem; color: #e2e8f0; }
|
||||
.spider-legend-item:hover { background: #1e293b; }
|
||||
.spider-legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.spider-axis-label { fill: #94a3b8; font-size: 13px; font-family: inherit; }
|
||||
|
||||
/* Ranking */
|
||||
.rank-list { list-style: none; padding: 0; margin: 0; max-width: 440px; margin-inline: auto; }
|
||||
.rank-item { display: flex; align-items: center; gap: 12px; padding: 0.75rem 1rem; margin-bottom: 6px; background: #1e293b; border: 1px solid #334155; border-radius: 10px; cursor: grab; transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; user-select: none; }
|
||||
.rank-item:active { cursor: grabbing; }
|
||||
.rank-item.dragging { opacity: 0.4; transform: scale(0.97); }
|
||||
.rank-item.drag-over { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.3); }
|
||||
.rank-pos { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.8rem; color: #0f172a; flex-shrink: 0; }
|
||||
.rank-pos.gold { background: #f59e0b; }
|
||||
.rank-pos.silver { background: #94a3b8; }
|
||||
.rank-pos.bronze { background: #cd7f32; }
|
||||
.rank-pos.plain { background: #334155; color: #94a3b8; }
|
||||
.rank-emoji { font-size: 1.5rem; flex-shrink: 0; }
|
||||
.rank-name { flex: 1; color: #f1f5f9; font-weight: 600; font-size: 1rem; }
|
||||
.rank-grip { color: #475569; font-size: 1.1rem; flex-shrink: 0; letter-spacing: 2px; }
|
||||
|
||||
/* Voting */
|
||||
.vote-wrap { max-width: 480px; margin-inline: auto; }
|
||||
.vote-option { display: flex; align-items: center; gap: 12px; padding: 0.75rem 1rem; margin-bottom: 8px; background: #1e293b; border: 1px solid #334155; border-radius: 10px; cursor: pointer; position: relative; overflow: hidden; transition: border-color 0.15s; }
|
||||
.vote-option:hover { border-color: #6366f1; }
|
||||
.vote-option.voted { border-color: #6366f1; }
|
||||
.vote-fill { position: absolute; left: 0; top: 0; bottom: 0; opacity: 0.12; transition: width 0.7s ease-out; pointer-events: none; }
|
||||
.vote-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; position: relative; z-index: 1; }
|
||||
.vote-name { flex: 1; color: #f1f5f9; font-weight: 600; font-size: 1rem; position: relative; z-index: 1; }
|
||||
.vote-count { color: #94a3b8; font-weight: 400; font-size: 0.8rem; min-width: 24px; text-align: right; position: relative; z-index: 1; font-variant-numeric: tabular-nums; }
|
||||
.vote-pct { font-weight: 600; font-size: 0.8rem; min-width: 40px; text-align: right; position: relative; z-index: 1; font-variant-numeric: tabular-nums; }
|
||||
.vote-badge { font-size: 0.625rem; padding: 2px 6px; border-radius: 999px; background: rgba(255,255,255,0.05); color: #94a3b8; margin-left: 6px; position: relative; z-index: 1; font-weight: 400; }
|
||||
.vote-actions { display: flex; justify-content: center; margin-top: 1rem; }
|
||||
.vote-reset { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; font-family: inherit; transition: all 0.15s; }
|
||||
.vote-reset:hover { border-color: #ef4444; color: #fca5a5; }
|
||||
.vote-status { text-align: center; margin-bottom: 1rem; font-size: 0.8rem; color: #64748b; }
|
||||
</style>
|
||||
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Choices</span>
|
||||
<span class="demo-badge">DEMO</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-tabs">
|
||||
${tabs.map((t) => `<button class="demo-tab${this.demoTab === t.key ? " active" : ""}" data-tab="${t.key}"><span class="demo-tab-icon">${t.icon}</span>${this.esc(t.label)}</button>`).join("")}
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
${content}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.bindDemoEvents();
|
||||
}
|
||||
|
||||
/* -- Spider Chart -- */
|
||||
|
||||
private polarToXY(cx: number, cy: number, radius: number, angleDeg: number): { x: number; y: number } {
|
||||
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
||||
return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) };
|
||||
}
|
||||
|
||||
private renderSpider(): string {
|
||||
const cx = 200, cy = 200, maxR = 150;
|
||||
const axes = ["Taste", "Price", "Speed", "Healthy", "Distance"];
|
||||
const people: { name: string; color: string; values: number[] }[] = [
|
||||
{ name: "Alice", color: "#7c5bf5", values: [0.9, 0.6, 0.8, 0.4, 0.7] },
|
||||
{ name: "Bob", color: "#f59e0b", values: [0.5, 0.9, 0.6, 0.7, 0.8] },
|
||||
{ name: "Carol", color: "#10b981", values: [0.7, 0.4, 0.9, 0.8, 0.3] },
|
||||
];
|
||||
const angleStep = 360 / axes.length;
|
||||
|
||||
// Grid rings
|
||||
let gridLines = "";
|
||||
for (let ring = 1; ring <= 5; ring++) {
|
||||
const r = (ring / 5) * maxR;
|
||||
const pts = axes.map((_, i) => {
|
||||
const p = this.polarToXY(cx, cy, r, i * angleStep);
|
||||
return `${p.x},${p.y}`;
|
||||
}).join(" ");
|
||||
gridLines += `<polygon points="${pts}" fill="none" stroke="#334155" stroke-width="1"/>`;
|
||||
}
|
||||
|
||||
// Axis lines + labels
|
||||
let axisLines = "";
|
||||
const labelOffset = 18;
|
||||
axes.forEach((label, i) => {
|
||||
const angle = i * angleStep;
|
||||
const tip = this.polarToXY(cx, cy, maxR, angle);
|
||||
axisLines += `<line x1="${cx}" y1="${cy}" x2="${tip.x}" y2="${tip.y}" stroke="#334155" stroke-width="1"/>`;
|
||||
const lp = this.polarToXY(cx, cy, maxR + labelOffset, angle);
|
||||
axisLines += `<text x="${lp.x}" y="${lp.y}" text-anchor="middle" dominant-baseline="central" class="spider-axis-label">${this.esc(label)}</text>`;
|
||||
});
|
||||
|
||||
// Data polygons
|
||||
let polygons = "";
|
||||
people.forEach((person) => {
|
||||
const dimmed = this.hoveredPerson !== null && this.hoveredPerson !== person.name;
|
||||
const opacity = dimmed ? 0.12 : 0.25;
|
||||
const strokeOpacity = dimmed ? 0.2 : 1;
|
||||
const strokeWidth = dimmed ? 1 : 2;
|
||||
const pts = person.values.map((v, i) => {
|
||||
const p = this.polarToXY(cx, cy, v * maxR, i * angleStep);
|
||||
return `${p.x},${p.y}`;
|
||||
}).join(" ");
|
||||
polygons += `<polygon points="${pts}" fill="${person.color}" fill-opacity="${opacity}" stroke="${person.color}" stroke-opacity="${strokeOpacity}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>`;
|
||||
|
||||
// Dots at each vertex
|
||||
person.values.forEach((v, i) => {
|
||||
const p = this.polarToXY(cx, cy, v * maxR, i * angleStep);
|
||||
const dotOpacity = dimmed ? 0.2 : 1;
|
||||
polygons += `<circle cx="${p.x}" cy="${p.y}" r="4" fill="${person.color}" opacity="${dotOpacity}"/>`;
|
||||
});
|
||||
});
|
||||
|
||||
const legend = people.map((p) =>
|
||||
`<div class="spider-legend-item" data-person="${this.esc(p.name)}"><span class="spider-legend-dot" style="background:${p.color}"></span>${this.esc(p.name)}</div>`
|
||||
).join("");
|
||||
|
||||
return `<div class="spider-wrap">
|
||||
<svg class="spider-svg" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
${gridLines}
|
||||
${axisLines}
|
||||
${polygons}
|
||||
</svg>
|
||||
<div class="spider-legend">${legend}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* -- Ranking -- */
|
||||
|
||||
private renderRanking(): string {
|
||||
const medalClass = (i: number) => i === 0 ? "gold" : i === 1 ? "silver" : i === 2 ? "bronze" : "plain";
|
||||
const items = this.rankItems.map((item, i) =>
|
||||
`<li class="rank-item" draggable="true" data-rank-id="${item.id}">
|
||||
<span class="rank-pos ${medalClass(i)}">${i + 1}</span>
|
||||
<span class="rank-emoji">${item.emoji}</span>
|
||||
<span class="rank-name">${this.esc(item.name)}</span>
|
||||
<span class="rank-grip">⠿</span>
|
||||
</li>`
|
||||
).join("");
|
||||
return `<ul class="rank-list">${items}</ul>`;
|
||||
}
|
||||
|
||||
/* -- Live Voting -- */
|
||||
|
||||
private renderVoting(): string {
|
||||
const sorted = [...this.voteOptions].sort((a, b) => b.votes - a.votes);
|
||||
const total = sorted.reduce((s, o) => s + o.votes, 0);
|
||||
const maxVotes = Math.max(...sorted.map((o) => o.votes), 1);
|
||||
|
||||
const items = sorted.map((opt) => {
|
||||
const pct = total > 0 ? (opt.votes / total) * 100 : 0;
|
||||
const isLeader = opt.votes === maxVotes && total > 4;
|
||||
return `<div class="vote-option${this.voted ? " voted" : ""}" data-vote-id="${opt.id}" style="border-color:${this.voted === true ? (this.votedId === opt.id ? opt.color : '#334155') : '#334155'}">
|
||||
<div class="vote-fill" style="width:${pct}%;background:${opt.color}"></div>
|
||||
<span class="vote-dot" style="background:${opt.color}"></span>
|
||||
<span class="vote-name">${this.esc(opt.name)}${isLeader ? `<span class="vote-badge">leading</span>` : ""}</span>
|
||||
<span class="vote-count">${opt.votes}</span>
|
||||
<span class="vote-pct" style="color:${opt.color}">${pct.toFixed(0)}%</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const status = this.voted
|
||||
? "Results are in!"
|
||||
: "Pick a movie \u2014 votes update live";
|
||||
|
||||
return `<div class="vote-wrap">
|
||||
<div class="vote-status">${status}</div>
|
||||
${items}
|
||||
${this.voted ? `<div class="vote-actions"><button class="vote-reset">Reset demo</button></div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* -- Demo event binding -- */
|
||||
|
||||
private bindDemoEvents() {
|
||||
// Tab switching
|
||||
this.shadow.querySelectorAll<HTMLButtonElement>(".demo-tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = btn.dataset.tab as "spider" | "ranking" | "voting";
|
||||
if (tab && tab !== this.demoTab) {
|
||||
this.demoTab = tab;
|
||||
this.renderDemo();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Spider legend hover
|
||||
this.shadow.querySelectorAll<HTMLElement>(".spider-legend-item").forEach((el) => {
|
||||
el.addEventListener("mouseenter", () => {
|
||||
this.hoveredPerson = el.dataset.person || null;
|
||||
this.renderDemo();
|
||||
});
|
||||
el.addEventListener("mouseleave", () => {
|
||||
this.hoveredPerson = null;
|
||||
this.renderDemo();
|
||||
});
|
||||
});
|
||||
|
||||
// Ranking drag-and-drop
|
||||
const rankList = this.shadow.querySelector(".rank-list");
|
||||
if (rankList) {
|
||||
const items = rankList.querySelectorAll<HTMLLIElement>(".rank-item");
|
||||
items.forEach((li) => {
|
||||
li.addEventListener("dragstart", (e: DragEvent) => {
|
||||
const id = parseInt(li.dataset.rankId || "0", 10);
|
||||
this.rankDragging = id;
|
||||
li.classList.add("dragging");
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(id));
|
||||
}
|
||||
});
|
||||
|
||||
li.addEventListener("dragover", (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||
li.classList.add("drag-over");
|
||||
});
|
||||
|
||||
li.addEventListener("dragleave", () => {
|
||||
li.classList.remove("drag-over");
|
||||
});
|
||||
|
||||
li.addEventListener("drop", (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
li.classList.remove("drag-over");
|
||||
const targetId = parseInt(li.dataset.rankId || "0", 10);
|
||||
if (this.rankDragging !== null && this.rankDragging !== targetId) {
|
||||
const fromIdx = this.rankItems.findIndex((r) => r.id === this.rankDragging);
|
||||
const toIdx = this.rankItems.findIndex((r) => r.id === targetId);
|
||||
if (fromIdx !== -1 && toIdx !== -1) {
|
||||
const [moved] = this.rankItems.splice(fromIdx, 1);
|
||||
this.rankItems.splice(toIdx, 0, moved);
|
||||
this.renderDemo();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
li.addEventListener("dragend", () => {
|
||||
this.rankDragging = null;
|
||||
li.classList.remove("dragging");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Voting click
|
||||
this.shadow.querySelectorAll<HTMLElement>(".vote-option").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
if (this.voted) return;
|
||||
const id = el.dataset.voteId || "";
|
||||
const opt = this.voteOptions.find((o) => o.id === id);
|
||||
if (opt) {
|
||||
opt.votes += 1;
|
||||
this.voted = true;
|
||||
this.votedId = id;
|
||||
this.renderDemo();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Vote reset
|
||||
const resetBtn = this.shadow.querySelector<HTMLButtonElement>(".vote-reset");
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener("click", () => {
|
||||
this.voteOptions = [
|
||||
{ id: "action", name: "Action Movie", color: "#ef4444", votes: 2 },
|
||||
{ id: "comedy", name: "Comedy", color: "#f59e0b", votes: 3 },
|
||||
{ id: "horror", name: "Horror", color: "#8b5cf6", votes: 1 },
|
||||
{ id: "scifi", name: "Sci-Fi", color: "#06b6d4", votes: 2 },
|
||||
];
|
||||
this.voted = false;
|
||||
this.votedId = null;
|
||||
this.startVoteSim();
|
||||
this.renderDemo();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-choices-dashboard", FolkChoicesDashboard);
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
/**
|
||||
* rChoices landing page — quick collaborative decisions.
|
||||
* Ported from the Next.js page.tsx. Interactive React demos (SpiderDemo,
|
||||
* RankingDemo, VotingDemo) replaced with text descriptions.
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
<!-- Hero -->
|
||||
<div class="rl-hero">
|
||||
<span class="rl-tagline">Quick collaborative decisions</span>
|
||||
<h1 class="rl-heading">What should <span style="color:#5eead4">5 friends</span><br>do tonight?</h1>
|
||||
<p class="rl-subtext">
|
||||
Spider plots to weigh trade-offs. Rankings to sort preferences.
|
||||
Live voting to decide fast. Powered by rSpace real-time sync.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rchoices" class="rl-cta-primary" id="ml-primary">Create a Choice Room</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Three tools -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Three decision tools</h2>
|
||||
<div class="rl-grid-3">
|
||||
<!-- Spider Plots -->
|
||||
<div class="rl-card">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem">
|
||||
<span style="font-size:0.7rem;padding:0.2rem 0.6rem;border-radius:9999px;background:rgba(168,85,247,0.1);color:#c084fc">Compare</span>
|
||||
</div>
|
||||
<h3 style="font-size:1.05rem;margin-bottom:0.35rem">Spider Plots</h3>
|
||||
<p>Everyone rates options on multiple criteria. See where preferences
|
||||
overlap — and where they don’t.</p>
|
||||
<div style="margin-top:1rem;padding:1rem;border-radius:0.75rem;background:rgba(168,85,247,0.05);border:1px solid rgba(168,85,247,0.1);text-align:center">
|
||||
<svg width="120" height="100" viewBox="0 0 120 100" style="margin:0 auto;display:block">
|
||||
<!-- Spider web axes -->
|
||||
<line x1="60" y1="10" x2="60" y2="90" stroke="#334155" stroke-width="0.5"/>
|
||||
<line x1="17" y1="35" x2="103" y2="65" stroke="#334155" stroke-width="0.5"/>
|
||||
<line x1="17" y1="65" x2="103" y2="35" stroke="#334155" stroke-width="0.5"/>
|
||||
<!-- Outer ring -->
|
||||
<polygon points="60,10 103,35 103,65 60,90 17,65 17,35" fill="none" stroke="#334155" stroke-width="0.5"/>
|
||||
<!-- Mid ring -->
|
||||
<polygon points="60,30 88,42 88,58 60,70 32,58 32,42" fill="none" stroke="#334155" stroke-width="0.5"/>
|
||||
<!-- Data shape 1 (purple) -->
|
||||
<polygon points="60,18 96,38 90,62 60,78 28,55 25,38" fill="rgba(168,85,247,0.15)" stroke="#c084fc" stroke-width="1.5"/>
|
||||
<!-- Data shape 2 (teal) -->
|
||||
<polygon points="60,25 85,45 98,58 60,72 35,60 22,42" fill="rgba(20,184,166,0.15)" stroke="#14b8a6" stroke-width="1.5"/>
|
||||
<!-- Axis labels -->
|
||||
<text x="60" y="6" text-anchor="middle" fill="#64748b" font-size="7">Price</text>
|
||||
<text x="110" y="34" text-anchor="start" fill="#64748b" font-size="7">Vibe</text>
|
||||
<text x="110" y="70" text-anchor="start" fill="#64748b" font-size="7">Menu</text>
|
||||
<text x="60" y="98" text-anchor="middle" fill="#64748b" font-size="7">Distance</text>
|
||||
<text x="10" y="70" text-anchor="end" fill="#64748b" font-size="7">Speed</text>
|
||||
<text x="10" y="34" text-anchor="end" fill="#64748b" font-size="7">Reviews</text>
|
||||
</svg>
|
||||
<p style="font-size:0.7rem;color:#64748b;margin-top:0.5rem">Multi-criteria comparison at a glance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rankings -->
|
||||
<div class="rl-card">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem">
|
||||
<span style="font-size:0.7rem;padding:0.2rem 0.6rem;border-radius:9999px;background:rgba(245,158,11,0.1);color:#fbbf24">Rank</span>
|
||||
</div>
|
||||
<h3 style="font-size:1.05rem;margin-bottom:0.35rem">Rankings</h3>
|
||||
<p>Drag to order your top picks. Borda count or instant-runoff finds
|
||||
the group’s true consensus.</p>
|
||||
<div style="margin-top:1rem;padding:0.75rem;border-radius:0.75rem;background:rgba(245,158,11,0.05);border:1px solid rgba(245,158,11,0.1)">
|
||||
<div style="display:flex;flex-direction:column;gap:0.4rem">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.6rem;border-radius:0.375rem;background:rgba(245,158,11,0.12);border:1px solid rgba(245,158,11,0.2)">
|
||||
<span style="font-size:0.7rem;font-weight:700;color:#fbbf24;width:1.2rem">1st</span>
|
||||
<span style="font-size:0.8rem;color:#e2e8f0">Thai place on 5th</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.6rem;border-radius:0.375rem;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06)">
|
||||
<span style="font-size:0.7rem;font-weight:700;color:#94a3b8;width:1.2rem">2nd</span>
|
||||
<span style="font-size:0.8rem;color:#e2e8f0">New pizza spot</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.6rem;border-radius:0.375rem;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06)">
|
||||
<span style="font-size:0.7rem;font-weight:700;color:#94a3b8;width:1.2rem">3rd</span>
|
||||
<span style="font-size:0.8rem;color:#e2e8f0">Taco truck</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.6rem;border-radius:0.375rem;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06)">
|
||||
<span style="font-size:0.7rem;font-weight:700;color:#94a3b8;width:1.2rem">4th</span>
|
||||
<span style="font-size:0.8rem;color:#e2e8f0">Sushi bar</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size:0.7rem;color:#64748b;margin-top:0.5rem;text-align:center">Drag to reorder — consensus emerges</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Voting -->
|
||||
<div class="rl-card">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem">
|
||||
<span style="font-size:0.7rem;padding:0.2rem 0.6rem;border-radius:9999px;background:rgba(34,211,238,0.1);color:#22d3ee">Vote</span>
|
||||
</div>
|
||||
<h3 style="font-size:1.05rem;margin-bottom:0.35rem">Live Voting</h3>
|
||||
<p>Cast your vote and watch results shift in real time. Perfect for
|
||||
quick polls and tiebreakers.</p>
|
||||
<div style="margin-top:1rem;padding:0.75rem;border-radius:0.75rem;background:rgba(34,211,238,0.05);border:1px solid rgba(34,211,238,0.1)">
|
||||
<div style="display:flex;flex-direction:column;gap:0.5rem">
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.75rem;margin-bottom:0.25rem">
|
||||
<span style="color:#e2e8f0">Hike</span>
|
||||
<span style="color:#22d3ee;font-weight:600">45%</span>
|
||||
</div>
|
||||
<div style="height:0.4rem;border-radius:9999px;background:rgba(255,255,255,0.06);overflow:hidden">
|
||||
<div style="height:100%;width:45%;border-radius:9999px;background:#22d3ee"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.75rem;margin-bottom:0.25rem">
|
||||
<span style="color:#e2e8f0">Beach</span>
|
||||
<span style="color:#22d3ee;font-weight:600">35%</span>
|
||||
</div>
|
||||
<div style="height:0.4rem;border-radius:9999px;background:rgba(255,255,255,0.06);overflow:hidden">
|
||||
<div style="height:100%;width:35%;border-radius:9999px;background:#22d3ee"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.75rem;margin-bottom:0.25rem">
|
||||
<span style="color:#e2e8f0">Stay in</span>
|
||||
<span style="color:#22d3ee;font-weight:600">20%</span>
|
||||
</div>
|
||||
<div style="height:0.4rem;border-radius:9999px;background:rgba(255,255,255,0.06);overflow:hidden">
|
||||
<div style="height:100%;width:20%;border-radius:9999px;background:#22d3ee"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size:0.7rem;color:#64748b;margin-top:0.5rem;text-align:center">Results update live — 9 votes in</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How it works -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">How it works</h2>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">1</div>
|
||||
<h3>Create</h3>
|
||||
<p>Name your choice room. It comes pre-loaded with vote, rank, and spider tools.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">2</div>
|
||||
<h3>Share</h3>
|
||||
<p>Send the link. Everyone joins the same real-time canvas — add options, cast votes.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">3</div>
|
||||
<h3>Decide</h3>
|
||||
<p>Results aggregate live. The group sees consensus emerge in seconds.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Use cases -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Decisions, not debates</h2>
|
||||
<p class="rl-subtext" style="text-align:center">
|
||||
Built for the everyday choices that stall group chats
|
||||
</p>
|
||||
<div class="rl-grid-2" style="margin-top:2rem">
|
||||
<div class="rl-card">
|
||||
<h3 style="margin-bottom:0.35rem">Where to eat?</h3>
|
||||
<p>Rate restaurants on price, distance, vibe. The spider plot finds the sweet spot.</p>
|
||||
</div>
|
||||
<div class="rl-card">
|
||||
<h3 style="margin-bottom:0.35rem">What to watch?</h3>
|
||||
<p>Everyone ranks their top 3 movies. Instant-runoff picks the winner.</p>
|
||||
</div>
|
||||
<div class="rl-card">
|
||||
<h3 style="margin-bottom:0.35rem">Weekend plans?</h3>
|
||||
<p>Quick poll: hike, beach, or stay in? Results in 30 seconds.</p>
|
||||
</div>
|
||||
<div class="rl-card">
|
||||
<h3 style="margin-bottom:0.35rem">Team retro priorities?</h3>
|
||||
<p>Rank improvement ideas by impact. Borda count surfaces what the team really wants.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">What should 5 friends do tonight?</h2>
|
||||
<p class="rl-subtext">Create a room and find out in 30 seconds.</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rchoices" class="rl-cta-primary">Create a Choice Room</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rl-back">
|
||||
<a href="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -56,8 +56,8 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-choices-dashboard space="${spaceSlug}"></folk-choices-dashboard>`,
|
||||
scripts: `<script type="module" src="/modules/choices/folk-choices-dashboard.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/choices/choices.css">`,
|
||||
scripts: `<script type="module" src="/modules/rchoices/folk-choices-dashboard.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rchoices/choices.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -16,9 +16,25 @@ class FolkAnalyticsView extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") {
|
||||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
this.loadStats();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.stats = {
|
||||
trackedApps: 17,
|
||||
cookiesSet: 0,
|
||||
scriptSize: "~2KB",
|
||||
selfHosted: true,
|
||||
apps: ["rSpace", "rBooks", "rCart", "rFunds", "rVote", "rWork", "rCal", "rTrips", "rPubs", "rForum", "rInbox", "rMaps", "rTube", "rChoices", "rWallet", "rData", "rNotes"],
|
||||
dashboardUrl: "https://analytics.rspace.online",
|
||||
};
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async loadStats() {
|
||||
try {
|
||||
const base = window.location.pathname.replace(/\/$/, "");
|
||||
|
|
@ -61,6 +77,9 @@ class FolkAnalyticsView extends HTMLElement {
|
|||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.pillars { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<div class="container">
|
||||
<p class="desc">Zero-knowledge, cookieless, self-hosted analytics for the r* ecosystem. Know how your tools are used without compromising anyone's privacy.</p>
|
||||
|
|
@ -128,8 +128,8 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-analytics-view space="${space}"></folk-analytics-view>`,
|
||||
scripts: `<script type="module" src="/modules/data/folk-analytics-view.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/data/data.css">`,
|
||||
scripts: `<script type="module" src="/modules/rdata/folk-analytics-view.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rdata/data.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Design module — collaborative design workspace via Affine.
|
||||
*
|
||||
* Wraps the Affine instance as an external app embedded in the rSpace shell.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
const AFFINE_URL = "https://affine.cosmolocal.world";
|
||||
|
||||
routes.get("/api/health", (c) => {
|
||||
return c.json({ ok: true, module: "rdesign" });
|
||||
});
|
||||
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const view = c.req.query("view");
|
||||
|
||||
if (view === "demo") {
|
||||
return c.html(renderShell({
|
||||
title: `${space} — Design | rSpace`,
|
||||
moduleId: "rdesign",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<div style="max-width:640px;margin:0 auto;padding:3rem 1rem;text-align:center">
|
||||
<div style="font-size:3rem;margin-bottom:1rem">🎯</div>
|
||||
<h2 style="font-size:1.5rem;margin-bottom:0.75rem;background:linear-gradient(135deg,#14b8a6,#22d3ee);-webkit-background-clip:text;-webkit-text-fill-color:transparent">rDesign</h2>
|
||||
<p style="color:#94a3b8;margin-bottom:2rem;line-height:1.6">Collaborative design workspace powered by Affine. Whiteboard, docs, and kanban — all in one tool for your community.</p>
|
||||
<a href="?" class="rapp-nav__btn--app-toggle" style="display:inline-block;padding:10px 24px;font-size:0.9rem">Open Affine</a>
|
||||
</div>`,
|
||||
}));
|
||||
}
|
||||
|
||||
// Default: show the external app directly
|
||||
return c.html(renderExternalAppShell({
|
||||
title: `${space} — Affine | rSpace`,
|
||||
moduleId: "rdesign",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: AFFINE_URL,
|
||||
appName: "Affine",
|
||||
theme: "dark",
|
||||
}));
|
||||
});
|
||||
|
||||
export const designModule: RSpaceModule = {
|
||||
id: "rdesign",
|
||||
name: "rDesign",
|
||||
icon: "🎯",
|
||||
description: "Collaborative design workspace with whiteboard and docs",
|
||||
routes,
|
||||
standaloneDomain: "rdesign.online",
|
||||
externalApp: { url: AFFINE_URL, name: "Affine" },
|
||||
};
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Docs module — collaborative documentation via Docmost.
|
||||
*
|
||||
* Wraps the Docmost instance as an external app embedded in the rSpace shell.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
const DOCMOST_URL = "https://docs.cosmolocal.world";
|
||||
|
||||
routes.get("/api/health", (c) => {
|
||||
return c.json({ ok: true, module: "rdocs" });
|
||||
});
|
||||
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const view = c.req.query("view");
|
||||
|
||||
if (view === "demo") {
|
||||
return c.html(renderShell({
|
||||
title: `${space} — Docs | rSpace`,
|
||||
moduleId: "rdocs",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<div style="max-width:640px;margin:0 auto;padding:3rem 1rem;text-align:center">
|
||||
<div style="font-size:3rem;margin-bottom:1rem">📝</div>
|
||||
<h2 style="font-size:1.5rem;margin-bottom:0.75rem;background:linear-gradient(135deg,#14b8a6,#22d3ee);-webkit-background-clip:text;-webkit-text-fill-color:transparent">rDocs</h2>
|
||||
<p style="color:#94a3b8;margin-bottom:2rem;line-height:1.6">Collaborative documentation powered by Docmost. Create wikis, knowledge bases, and shared documents for your community.</p>
|
||||
<a href="?" class="rapp-nav__btn--app-toggle" style="display:inline-block;padding:10px 24px;font-size:0.9rem">Open Docmost</a>
|
||||
</div>`,
|
||||
}));
|
||||
}
|
||||
|
||||
// Default: show the external app directly
|
||||
return c.html(renderExternalAppShell({
|
||||
title: `${space} — Docmost | rSpace`,
|
||||
moduleId: "rdocs",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: DOCMOST_URL,
|
||||
appName: "Docmost",
|
||||
theme: "dark",
|
||||
}));
|
||||
});
|
||||
|
||||
export const docsModule: RSpaceModule = {
|
||||
id: "rdocs",
|
||||
name: "rDocs",
|
||||
icon: "📝",
|
||||
description: "Collaborative documentation and knowledge base",
|
||||
routes,
|
||||
standaloneDomain: "rdocs.online",
|
||||
externalApp: { url: DOCMOST_URL, name: "Docmost" },
|
||||
};
|
||||
|
|
@ -20,11 +20,119 @@ class FolkFileBrowser extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "default";
|
||||
|
||||
if (this.space === "demo") {
|
||||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
|
||||
this.render();
|
||||
this.loadFiles();
|
||||
this.loadCards();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
const now = Date.now();
|
||||
this.files = [
|
||||
{
|
||||
id: "demo-file-1",
|
||||
name: "meeting-notes-feb2026.md",
|
||||
original_filename: "meeting-notes-feb2026.md",
|
||||
title: "meeting-notes-feb2026.md",
|
||||
size: 12288,
|
||||
file_size: 12288,
|
||||
mime_type: "text/markdown",
|
||||
created_at: new Date(now - 3 * 86400000).toISOString(),
|
||||
updated_at: new Date(now - 1 * 86400000).toISOString(),
|
||||
space: "demo",
|
||||
},
|
||||
{
|
||||
id: "demo-file-2",
|
||||
name: "budget-proposal.pdf",
|
||||
original_filename: "budget-proposal.pdf",
|
||||
title: "budget-proposal.pdf",
|
||||
size: 2202009,
|
||||
file_size: 2202009,
|
||||
mime_type: "application/pdf",
|
||||
created_at: new Date(now - 7 * 86400000).toISOString(),
|
||||
updated_at: new Date(now - 5 * 86400000).toISOString(),
|
||||
space: "demo",
|
||||
},
|
||||
{
|
||||
id: "demo-file-3",
|
||||
name: "community-logo.svg",
|
||||
original_filename: "community-logo.svg",
|
||||
title: "community-logo.svg",
|
||||
size: 46080,
|
||||
file_size: 46080,
|
||||
mime_type: "image/svg+xml",
|
||||
created_at: new Date(now - 14 * 86400000).toISOString(),
|
||||
updated_at: new Date(now - 14 * 86400000).toISOString(),
|
||||
space: "demo",
|
||||
},
|
||||
{
|
||||
id: "demo-file-4",
|
||||
name: "workshop-recording.mp4",
|
||||
original_filename: "workshop-recording.mp4",
|
||||
title: "workshop-recording.mp4",
|
||||
size: 157286400,
|
||||
file_size: 157286400,
|
||||
mime_type: "video/mp4",
|
||||
created_at: new Date(now - 2 * 86400000).toISOString(),
|
||||
updated_at: new Date(now - 2 * 86400000).toISOString(),
|
||||
space: "demo",
|
||||
},
|
||||
{
|
||||
id: "demo-file-5",
|
||||
name: "member-directory.csv",
|
||||
original_filename: "member-directory.csv",
|
||||
title: "member-directory.csv",
|
||||
size: 8192,
|
||||
file_size: 8192,
|
||||
mime_type: "text/csv",
|
||||
created_at: new Date(now - 10 * 86400000).toISOString(),
|
||||
updated_at: new Date(now - 4 * 86400000).toISOString(),
|
||||
space: "demo",
|
||||
},
|
||||
];
|
||||
|
||||
this.cards = [
|
||||
{
|
||||
id: "demo-card-1",
|
||||
title: "Design Sprint Outcomes",
|
||||
card_type: "summary",
|
||||
type: "summary",
|
||||
item_count: 3,
|
||||
body: "Key outcomes from the 5-day design sprint covering user flows, wireframes, and prototypes.",
|
||||
created_at: new Date(now - 5 * 86400000).toISOString(),
|
||||
space: "demo",
|
||||
},
|
||||
{
|
||||
id: "demo-card-2",
|
||||
title: "Q1 Budget Allocation",
|
||||
card_type: "data",
|
||||
type: "data",
|
||||
item_count: 5,
|
||||
body: "Budget breakdown across infrastructure, development, community, marketing, and reserves.",
|
||||
created_at: new Date(now - 12 * 86400000).toISOString(),
|
||||
space: "demo",
|
||||
},
|
||||
{
|
||||
id: "demo-card-3",
|
||||
title: "Community Principles",
|
||||
card_type: "reference",
|
||||
type: "reference",
|
||||
item_count: 7,
|
||||
body: "Seven guiding principles adopted by the community for governance and collaboration.",
|
||||
created_at: new Date(now - 20 * 86400000).toISOString(),
|
||||
space: "demo",
|
||||
},
|
||||
];
|
||||
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async loadFiles() {
|
||||
this.loading = true;
|
||||
this.render();
|
||||
|
|
@ -91,6 +199,10 @@ class FolkFileBrowser extends HTMLElement {
|
|||
|
||||
private async handleUpload(e: Event) {
|
||||
e.preventDefault();
|
||||
if (this.space === "demo") {
|
||||
alert("Upload is disabled in demo mode.");
|
||||
return;
|
||||
}
|
||||
const form = this.shadow.querySelector("#upload-form") as HTMLFormElement;
|
||||
if (!form) return;
|
||||
|
||||
|
|
@ -120,6 +232,10 @@ class FolkFileBrowser extends HTMLElement {
|
|||
}
|
||||
|
||||
private async handleDelete(fileId: string) {
|
||||
if (this.space === "demo") {
|
||||
alert("Delete is disabled in demo mode.");
|
||||
return;
|
||||
}
|
||||
if (!confirm("Delete this file?")) return;
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
|
|
@ -129,6 +245,10 @@ class FolkFileBrowser extends HTMLElement {
|
|||
}
|
||||
|
||||
private async handleShare(fileId: string) {
|
||||
if (this.space === "demo") {
|
||||
alert("Sharing is disabled in demo mode.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/files/${fileId}/share`, {
|
||||
|
|
@ -149,6 +269,10 @@ class FolkFileBrowser extends HTMLElement {
|
|||
|
||||
private async handleCreateCard(e: Event) {
|
||||
e.preventDefault();
|
||||
if (this.space === "demo") {
|
||||
alert("Creating cards is disabled in demo mode.");
|
||||
return;
|
||||
}
|
||||
const form = this.shadow.querySelector("#card-form") as HTMLFormElement;
|
||||
if (!form) return;
|
||||
|
||||
|
|
@ -173,6 +297,10 @@ class FolkFileBrowser extends HTMLElement {
|
|||
}
|
||||
|
||||
private async handleDeleteCard(cardId: string) {
|
||||
if (this.space === "demo") {
|
||||
alert("Deleting cards is disabled in demo mode.");
|
||||
return;
|
||||
}
|
||||
if (!confirm("Delete this card?")) return;
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
|
|
@ -286,6 +414,10 @@ class FolkFileBrowser extends HTMLElement {
|
|||
else if (action === "share") this.handleShare(id);
|
||||
else if (action === "delete-card") this.handleDeleteCard(id);
|
||||
else if (action === "download") {
|
||||
if (this.space === "demo") {
|
||||
alert("Download is disabled in demo mode.");
|
||||
return;
|
||||
}
|
||||
const base = this.getApiBase();
|
||||
window.open(`${base}/api/files/${id}/download`, "_blank");
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import { resolve } from "node:path";
|
|||
import { mkdir, writeFile, unlink } from "node:fs/promises";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
|
|
@ -367,15 +367,30 @@ routes.delete("/api/cards/:id", async (c) => {
|
|||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const spaceSlug = c.req.param("space") || "demo";
|
||||
const view = c.req.query("view");
|
||||
|
||||
if (view === "app") {
|
||||
return c.html(renderExternalAppShell({
|
||||
title: `${spaceSlug} — Seafile | rSpace`,
|
||||
moduleId: "rfiles",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: "https://files.rfiles.online",
|
||||
appName: "Seafile",
|
||||
theme: "dark",
|
||||
}));
|
||||
}
|
||||
|
||||
return c.html(renderShell({
|
||||
title: `${spaceSlug} — Files | rSpace`,
|
||||
moduleId: "rfiles",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
|
||||
scripts: `<script type="module" src="/modules/files/folk-file-browser.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/files/files.css">`,
|
||||
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
|
||||
<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
|
||||
scripts: `<script type="module" src="/modules/rfiles/folk-file-browser.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rfiles/files.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -387,6 +402,7 @@ export const filesModule: RSpaceModule = {
|
|||
routes,
|
||||
landingPage: renderLanding,
|
||||
standaloneDomain: "rfiles.online",
|
||||
externalApp: { url: "https://files.rfiles.online", name: "Seafile" },
|
||||
feeds: [
|
||||
{
|
||||
id: "file-activity",
|
||||
|
|
@ -13,6 +13,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
private view: "list" | "detail" | "create" = "list";
|
||||
private loading = false;
|
||||
private pollTimer: number | null = null;
|
||||
private space = "";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -20,10 +21,22 @@ class FolkForumDashboard extends HTMLElement {
|
|||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.render();
|
||||
this.loadInstances();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.instances = [
|
||||
{ id: "1", name: "Commons Hub", domain: "commons.rforum.online", status: "active", region: "nbg1", size: "cx22", admin_email: "admin@commons.example", vps_ip: "116.203.x.x", ssl_provisioned: true },
|
||||
{ id: "2", name: "Design Guild", domain: "design.rforum.online", status: "provisioning", region: "fsn1", size: "cx22", admin_email: "admin@design.example", vps_ip: "168.119.x.x", ssl_provisioned: false },
|
||||
{ id: "3", name: "Archive Project", domain: "archive.rforum.online", status: "destroyed", region: "hel1", size: "cx22", admin_email: "admin@archive.example", vps_ip: null, ssl_provisioned: false },
|
||||
];
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
}
|
||||
|
|
@ -63,6 +76,36 @@ class FolkForumDashboard extends HTMLElement {
|
|||
}
|
||||
|
||||
private async loadInstanceDetail(id: string) {
|
||||
if (this.space === "demo") {
|
||||
this.selectedInstance = this.instances.find(i => i.id === id);
|
||||
const demoLogs: Record<string, any[]> = {
|
||||
"1": [
|
||||
{ step: "create_vps", status: "success", message: "Server created in nbg1" },
|
||||
{ step: "wait_ready", status: "success", message: "Server booted and SSH ready" },
|
||||
{ step: "configure_dns", status: "success", message: "DNS record set for commons.rforum.online" },
|
||||
{ step: "install_discourse", status: "success", message: "Discourse installed and configured" },
|
||||
{ step: "verify_live", status: "success", message: "Forum responding at https://commons.rforum.online" },
|
||||
],
|
||||
"2": [
|
||||
{ step: "create_vps", status: "success", message: "Server created in fsn1" },
|
||||
{ step: "wait_ready", status: "success", message: "Server booted and SSH ready" },
|
||||
{ step: "configure_dns", status: "running", message: "Configuring DNS for design.rforum.online..." },
|
||||
{ step: "install_discourse", status: "pending", message: "" },
|
||||
{ step: "verify_live", status: "pending", message: "" },
|
||||
],
|
||||
"3": [
|
||||
{ step: "create_vps", status: "success", message: "Server created in hel1" },
|
||||
{ step: "wait_ready", status: "success", message: "Server booted and SSH ready" },
|
||||
{ step: "configure_dns", status: "success", message: "DNS record set for archive.rforum.online" },
|
||||
{ step: "install_discourse", status: "success", message: "Discourse installed and configured" },
|
||||
{ step: "verify_live", status: "success", message: "Forum verified live before destruction" },
|
||||
],
|
||||
};
|
||||
this.selectedLogs = demoLogs[id] || [];
|
||||
this.view = "detail";
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/instances/${id}`, { headers: this.getAuthHeaders() });
|
||||
|
|
@ -87,6 +130,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
|
||||
private async handleCreate(e: Event) {
|
||||
e.preventDefault();
|
||||
if (this.space === "demo") { alert("Create is disabled in demo mode."); return; }
|
||||
const form = this.shadow.querySelector("#create-form") as HTMLFormElement;
|
||||
if (!form) return;
|
||||
|
||||
|
|
@ -119,6 +163,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
}
|
||||
|
||||
private async handleDestroy(id: string) {
|
||||
if (this.space === "demo") { alert("Destroy is disabled in demo mode."); return; }
|
||||
if (!confirm("Are you sure you want to destroy this forum instance? This cannot be undone.")) return;
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
|
|
@ -221,6 +266,10 @@ class FolkForumDashboard extends HTMLElement {
|
|||
.price-name { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
|
||||
.price-cost { font-size: 18px; color: #64b5f6; font-weight: 700; }
|
||||
.price-specs { font-size: 11px; color: #888; margin-top: 4px; }
|
||||
@media (max-width: 768px) {
|
||||
.pricing { grid-template-columns: 1fr; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
${this.view === "list" ? this.renderList() : ""}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
/**
|
||||
* Forum module landing page — static HTML, no React.
|
||||
* rForum landing page — Discourse forum provisioning.
|
||||
* Ported from /opt/apps/rforum-online/src/app/page.tsx
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
|
|
@ -18,61 +19,41 @@ export function renderLanding(): string {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">What rForum Handles</h2>
|
||||
<div class="rl-grid-4">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">⚙</div>
|
||||
<h3>Automated Provisioning</h3>
|
||||
<p>One-click Discourse deployment. Server creation, installation, and configuration handled automatically.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">☁</div>
|
||||
<h3>Cloud VPS Deployment</h3>
|
||||
<p>Deploy on Hetzner Cloud in Germany, Finland, or the US. Transparent pricing with no markup.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🌐</div>
|
||||
<h3>DNS Integration</h3>
|
||||
<p>Automatic DNS setup for your subdomain via Cloudflare. SSL certificates provisioned with Let's Encrypt.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🛠</div>
|
||||
<h3>No DevOps Required</h3>
|
||||
<p>No command line, no SSH — just fill in your settings and watch your forum come online.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">1</div>
|
||||
<h3>Configure</h3>
|
||||
<p>Choose your subdomain, server region, and instance size. Add SMTP credentials for email delivery. Set your admin email.</p>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:rgba(99,102,241,0.15)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#818cf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</div>
|
||||
<h3>1. Configure</h3>
|
||||
<p>Choose your subdomain, server region, and instance size.
|
||||
Add SMTP credentials for email delivery. Set your admin email.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">2</div>
|
||||
<h3>Provision</h3>
|
||||
<p>We create a cloud server on Hetzner, install Discourse, configure SSL via Let's Encrypt, and set up DNS. Takes about 10-15 minutes.</p>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:rgba(139,92,246,0.15)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
<h3>2. Provision</h3>
|
||||
<p>We create a cloud server on Hetzner, install Discourse,
|
||||
configure SSL via Let's Encrypt, and set up DNS. Takes about 10-15 minutes.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">3</div>
|
||||
<h3>Go Live</h3>
|
||||
<p>Your forum is ready. Log in as admin, customize your community, invite members, and start conversations. Full SSH access included.</p>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:rgba(168,85,247,0.15)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#c084fc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
</div>
|
||||
<h3>3. Go Live</h3>
|
||||
<p>Your forum is ready. Log in as admin, customize your community,
|
||||
invite members, and start conversations. Full SSH access included.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pricing -->
|
||||
<section class="rl-section" id="pricing">
|
||||
<section class="rl-section rl-section--alt" id="pricing">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Transparent Pricing</h2>
|
||||
<p class="rl-subtext" style="text-align:center">
|
||||
|
|
@ -132,31 +113,49 @@ export function renderLanding(): string {
|
|||
</section>
|
||||
|
||||
<!-- What You Get -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">What You Get</h2>
|
||||
<div class="rl-grid-3" style="margin-top:2rem">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:rgba(99,102,241,0.12)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#818cf8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
</div>
|
||||
<h3>Automated SSL</h3>
|
||||
<p>Let's Encrypt certificates provisioned automatically during setup.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:rgba(99,102,241,0.12)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#818cf8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
||||
</div>
|
||||
<h3>Multiple Regions</h3>
|
||||
<p>Deploy in Germany, Finland, or the US East/West Coast.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:rgba(99,102,241,0.12)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#818cf8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7 15l3-3 3 3"/><path d="M8 8h.01M12 8h.01"/></svg>
|
||||
</div>
|
||||
<h3>Full SSH Access</h3>
|
||||
<p>Your server, your rules. SSH in anytime for custom configuration.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:rgba(99,102,241,0.12)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#818cf8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/></svg>
|
||||
</div>
|
||||
<h3>One-Click Updates</h3>
|
||||
<p>Discourse's built-in admin panel handles version upgrades.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:rgba(99,102,241,0.12)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#818cf8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/></svg>
|
||||
</div>
|
||||
<h3>DNS Management</h3>
|
||||
<p>Automatic DNS setup for *.rforum.online subdomains.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:rgba(99,102,241,0.12)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#818cf8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
</div>
|
||||
<h3>Real-Time Logs</h3>
|
||||
<p>Watch your forum provision step-by-step in the dashboard.</p>
|
||||
</div>
|
||||
|
|
@ -165,24 +164,36 @@ export function renderLanding(): string {
|
|||
</section>
|
||||
|
||||
<!-- Built on Open Source -->
|
||||
<section class="rl-section">
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
|
||||
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rForum.</p>
|
||||
<div class="rl-grid-4">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:rgba(139,92,246,0.12)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
</div>
|
||||
<h3>Discourse</h3>
|
||||
<p>The world's most popular open-source forum platform.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:rgba(139,92,246,0.12)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
</div>
|
||||
<h3>Hetzner Cloud API</h3>
|
||||
<p>Automated VPS provisioning in multiple regions.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:rgba(139,92,246,0.12)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
</div>
|
||||
<h3>Cloudflare API</h3>
|
||||
<p>DNS management and SSL termination.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:rgba(139,92,246,0.12)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
</div>
|
||||
<h3>Hono</h3>
|
||||
<p>Ultra-fast, lightweight API framework powering the backend.</p>
|
||||
</div>
|
||||
|
|
@ -191,7 +202,7 @@ export function renderLanding(): string {
|
|||
</section>
|
||||
|
||||
<!-- Your Data, Protected -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<section class="rl-section">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">Your Data, Protected</h2>
|
||||
<p class="rl-subtext">How rForum keeps your information safe.</p>
|
||||
|
|
@ -218,12 +229,12 @@ export function renderLanding(): string {
|
|||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section">
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">(You)rForum, your community.</h2>
|
||||
<p class="rl-subtext">Try the demo or create a space to get started.</p>
|
||||
<h2 class="rl-heading">Ready to launch your community?</h2>
|
||||
<p class="rl-subtext">Deploy a production Discourse forum in under 15 minutes. No DevOps experience needed.</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rforum" class="rl-cta-primary">Get Started</a>
|
||||
<a href="https://demo.rspace.online/rforum" class="rl-cta-primary">Launch Your Forum</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -231,6 +242,5 @@ export function renderLanding(): string {
|
|||
|
||||
<div class="rl-back">
|
||||
<a href="/">← Back to rSpace</a>
|
||||
</div>
|
||||
`;
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { Hono } from "hono";
|
|||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import { provisionInstance, destroyInstance } from "./lib/provisioner";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
|
|
@ -158,15 +158,30 @@ routes.get("/api/health", (c) => {
|
|||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const spaceSlug = c.req.param("space") || "demo";
|
||||
const view = c.req.query("view");
|
||||
|
||||
if (view === "app") {
|
||||
return c.html(renderExternalAppShell({
|
||||
title: `${spaceSlug} — Discourse | rSpace`,
|
||||
moduleId: "rforum",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: "https://commons.rforum.online",
|
||||
appName: "Discourse",
|
||||
theme: "dark",
|
||||
}));
|
||||
}
|
||||
|
||||
return c.html(renderShell({
|
||||
title: `${spaceSlug} — Forum | rSpace`,
|
||||
moduleId: "rforum",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
|
||||
scripts: `<script type="module" src="/modules/forum/folk-forum-dashboard.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/forum/forum.css">`,
|
||||
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
|
||||
<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
|
||||
scripts: `<script type="module" src="/modules/rforum/folk-forum-dashboard.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rforum/forum.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -178,6 +193,7 @@ export const forumModule: RSpaceModule = {
|
|||
routes,
|
||||
landingPage: renderLanding,
|
||||
standaloneDomain: "rforum.online",
|
||||
externalApp: { url: "https://commons.rforum.online", name: "Discourse" },
|
||||
feeds: [
|
||||
{
|
||||
id: "threads",
|
||||
|
|
@ -80,7 +80,7 @@ class FolkFundsApp extends HTMLElement {
|
|||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.flowId = this.getAttribute("flow-id") || "";
|
||||
this.isDemo = this.getAttribute("mode") === "demo";
|
||||
this.isDemo = this.getAttribute("mode") === "demo" || this.space === "demo";
|
||||
|
||||
if (this.isDemo) {
|
||||
this.view = "detail";
|
||||
|
|
@ -160,9 +160,9 @@ class FolkFundsApp extends HTMLElement {
|
|||
}
|
||||
|
||||
private getCssPath(): string {
|
||||
// In rSpace: /modules/funds/funds.css | Standalone: /modules/funds/funds.css
|
||||
// The shell always serves from /modules/funds/ in both modes
|
||||
return "/modules/funds/funds.css";
|
||||
// In rSpace: /modules/rfunds/funds.css | Standalone: /modules/rfunds/funds.css
|
||||
// The shell always serves from /modules/rfunds/ in both modes
|
||||
return "/modules/rfunds/funds.css";
|
||||
}
|
||||
|
||||
private render() {
|
||||
|
|
@ -0,0 +1,526 @@
|
|||
/**
|
||||
* rFunds demo — client-side WebSocket controller.
|
||||
*
|
||||
* Connects via DemoSync, extracts expenses and budget from shapes,
|
||||
* renders/updates budget overview, expense list, balances, settlements,
|
||||
* category breakdown, and per-person stats. Supports inline expense editing.
|
||||
*/
|
||||
|
||||
import { DemoSync } from "@lib/demo-sync-vanilla";
|
||||
import type { DemoShape } from "@lib/demo-sync-vanilla";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface ExpenseShape extends DemoShape {
|
||||
type: "demo-expense";
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
paidBy: string;
|
||||
split: "equal" | "custom";
|
||||
category: "transport" | "accommodation" | "activity" | "food";
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface BudgetShape extends DemoShape {
|
||||
type: "folk-budget";
|
||||
budgetTitle: string;
|
||||
currency: string;
|
||||
budgetTotal: number;
|
||||
spent: number;
|
||||
categories: { name: string; budget: number; spent: number }[];
|
||||
}
|
||||
|
||||
function isExpense(shape: DemoShape): shape is ExpenseShape {
|
||||
return shape.type === "demo-expense" && typeof (shape as ExpenseShape).amount === "number";
|
||||
}
|
||||
|
||||
function isBudget(shape: DemoShape): shape is BudgetShape {
|
||||
return shape.type === "folk-budget";
|
||||
}
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const MEMBERS = ["Maya", "Liam", "Priya", "Omar"];
|
||||
|
||||
const MEMBER_COLORS: Record<string, string> = {
|
||||
Maya: "#10b981",
|
||||
Liam: "#06b6d4",
|
||||
Priya: "#8b5cf6",
|
||||
Omar: "#f59e0b",
|
||||
};
|
||||
|
||||
const MEMBER_BG: Record<string, string> = {
|
||||
Maya: "rd-bg-emerald",
|
||||
Liam: "rd-bg-cyan",
|
||||
Priya: "rd-bg-violet",
|
||||
Omar: "rd-bg-amber",
|
||||
};
|
||||
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
transport: "\u{1F682}",
|
||||
accommodation: "\u{1F3E8}",
|
||||
activity: "\u26F7",
|
||||
food: "\u{1F372}",
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
transport: "Transport",
|
||||
accommodation: "Accommodation",
|
||||
activity: "Activities",
|
||||
food: "Food & Drink",
|
||||
};
|
||||
|
||||
const CATEGORY_TEXT_CLASS: Record<string, string> = {
|
||||
transport: "rd-cyan",
|
||||
accommodation: "rd-violet",
|
||||
activity: "rd-amber",
|
||||
food: "rd-rose",
|
||||
};
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function fmt(amount: number): string {
|
||||
return `\u20AC${amount.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ── Balance computation ──
|
||||
|
||||
interface BalanceEntry {
|
||||
name: string;
|
||||
paid: number;
|
||||
owes: number;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
function computeBalances(expenses: ExpenseShape[]): BalanceEntry[] {
|
||||
const total = expenses.reduce((s, e) => s + e.amount, 0);
|
||||
const perPerson = total / MEMBERS.length;
|
||||
const paid: Record<string, number> = {};
|
||||
MEMBERS.forEach((m) => (paid[m] = 0));
|
||||
expenses.forEach((e) => (paid[e.paidBy] = (paid[e.paidBy] || 0) + e.amount));
|
||||
return MEMBERS.map((name) => ({
|
||||
name,
|
||||
paid: paid[name] || 0,
|
||||
owes: perPerson,
|
||||
balance: (paid[name] || 0) - perPerson,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Settlement computation (greedy) ──
|
||||
|
||||
interface Settlement {
|
||||
from: string;
|
||||
to: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
function computeSettlements(balances: BalanceEntry[]): Settlement[] {
|
||||
const debtors = balances
|
||||
.filter((b) => b.balance < -0.01)
|
||||
.map((b) => ({ ...b, balance: -b.balance }));
|
||||
const creditors = balances
|
||||
.filter((b) => b.balance > 0.01)
|
||||
.map((b) => ({ ...b }));
|
||||
|
||||
debtors.sort((a, b) => b.balance - a.balance);
|
||||
creditors.sort((a, b) => b.balance - a.balance);
|
||||
|
||||
const settlements: Settlement[] = [];
|
||||
let di = 0,
|
||||
ci = 0;
|
||||
|
||||
while (di < debtors.length && ci < creditors.length) {
|
||||
const amount = Math.min(debtors[di].balance, creditors[ci].balance);
|
||||
if (amount > 0.01) {
|
||||
settlements.push({
|
||||
from: debtors[di].name,
|
||||
to: creditors[ci].name,
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
});
|
||||
}
|
||||
debtors[di].balance -= amount;
|
||||
creditors[ci].balance -= amount;
|
||||
if (debtors[di].balance < 0.01) di++;
|
||||
if (creditors[ci].balance < 0.01) ci++;
|
||||
}
|
||||
|
||||
return settlements;
|
||||
}
|
||||
|
||||
// ── DOM refs ──
|
||||
|
||||
const connBadge = document.getElementById("rd-conn-badge") as HTMLElement;
|
||||
const resetBtn = document.getElementById("rd-reset-btn") as HTMLButtonElement;
|
||||
const loadingEl = document.getElementById("rd-loading") as HTMLElement;
|
||||
const emptyEl = document.getElementById("rd-empty") as HTMLElement;
|
||||
|
||||
const budgetSection = document.getElementById("rd-budget-section") as HTMLElement;
|
||||
const expensesSection = document.getElementById("rd-expenses-section") as HTMLElement;
|
||||
const spendingSection = document.getElementById("rd-spending-section") as HTMLElement;
|
||||
const personSection = document.getElementById("rd-person-section") as HTMLElement;
|
||||
|
||||
const budgetTotalEl = document.getElementById("rd-budget-total") as HTMLElement;
|
||||
const budgetSpentEl = document.getElementById("rd-budget-spent") as HTMLElement;
|
||||
const budgetRemainingEl = document.getElementById("rd-budget-remaining") as HTMLElement;
|
||||
const budgetRemainingLabel = document.getElementById("rd-budget-remaining-label") as HTMLElement;
|
||||
const budgetPctLabel = document.getElementById("rd-budget-pct-label") as HTMLElement;
|
||||
const budgetLeftLabel = document.getElementById("rd-budget-left-label") as HTMLElement;
|
||||
const budgetBar = document.getElementById("rd-budget-bar") as HTMLElement;
|
||||
|
||||
const expenseList = document.getElementById("rd-expense-list") as HTMLElement;
|
||||
const expenseCount = document.getElementById("rd-expense-count") as HTMLElement;
|
||||
const expenseTotal = document.getElementById("rd-expense-total") as HTMLElement;
|
||||
const balancesBody = document.getElementById("rd-balances-body") as HTMLElement;
|
||||
const settlementsBody = document.getElementById("rd-settlements-body") as HTMLElement;
|
||||
|
||||
// ── DemoSync ──
|
||||
|
||||
const sync = new DemoSync({ filter: ["demo-expense", "folk-budget"] });
|
||||
|
||||
// Editing state
|
||||
let editingExpenseId: string | null = null;
|
||||
|
||||
// Show loading spinner immediately
|
||||
loadingEl.style.display = "";
|
||||
|
||||
// ── Connection events ──
|
||||
|
||||
sync.addEventListener("connected", () => {
|
||||
connBadge.className = "rd-status rd-status--connected";
|
||||
connBadge.textContent = "Connected";
|
||||
resetBtn.disabled = false;
|
||||
});
|
||||
|
||||
sync.addEventListener("disconnected", () => {
|
||||
connBadge.className = "rd-status rd-status--disconnected";
|
||||
connBadge.textContent = "Disconnected";
|
||||
resetBtn.disabled = true;
|
||||
});
|
||||
|
||||
// ── Snapshot -> render ──
|
||||
|
||||
sync.addEventListener("snapshot", ((e: CustomEvent) => {
|
||||
const shapes: Record<string, DemoShape> = e.detail.shapes;
|
||||
|
||||
const expenses = Object.values(shapes)
|
||||
.filter(isExpense)
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
|
||||
const budget = Object.values(shapes).find(isBudget) ?? null;
|
||||
|
||||
// Hide loading
|
||||
loadingEl.style.display = "none";
|
||||
|
||||
const hasData = expenses.length > 0 || budget !== null;
|
||||
|
||||
if (!hasData) {
|
||||
emptyEl.style.display = "";
|
||||
budgetSection.style.display = "none";
|
||||
expensesSection.style.display = "none";
|
||||
spendingSection.style.display = "none";
|
||||
personSection.style.display = "none";
|
||||
return;
|
||||
}
|
||||
emptyEl.style.display = "none";
|
||||
|
||||
// ── Computed values ──
|
||||
const totalSpent = expenses.reduce((s, ex) => s + ex.amount, 0);
|
||||
const budgetTotal = budget?.budgetTotal ?? 4000;
|
||||
const budgetSpent = budget?.spent ?? totalSpent;
|
||||
const budgetRemaining = budgetTotal - budgetSpent;
|
||||
const budgetPct = budgetTotal > 0 ? Math.min(100, Math.round((budgetSpent / budgetTotal) * 100)) : 0;
|
||||
|
||||
const balances = computeBalances(expenses);
|
||||
const settlements = computeSettlements(balances);
|
||||
|
||||
// Category breakdown from budget shape or computed from expenses
|
||||
let categoryBreakdown: { name: string; budget: number; spent: number }[];
|
||||
if (budget?.categories && budget.categories.length > 0) {
|
||||
categoryBreakdown = budget.categories;
|
||||
} else {
|
||||
const cats: Record<string, number> = {};
|
||||
expenses.forEach((ex) => (cats[ex.category] = (cats[ex.category] || 0) + ex.amount));
|
||||
categoryBreakdown = Object.entries(cats).map(([name, spent]) => ({
|
||||
name,
|
||||
budget: Math.round(budgetTotal / 4),
|
||||
spent,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Update Budget Overview ──
|
||||
budgetSection.style.display = "";
|
||||
budgetTotalEl.textContent = fmt(budgetTotal);
|
||||
budgetSpentEl.textContent = fmt(budgetSpent);
|
||||
budgetRemainingEl.textContent = fmt(Math.abs(budgetRemaining));
|
||||
budgetRemainingEl.className = `rd-stat__value ${budgetRemaining >= 0 ? "rd-cyan" : "rd-rose"}`;
|
||||
budgetRemainingLabel.textContent = budgetRemaining >= 0 ? "Remaining" : "Over Budget";
|
||||
budgetPctLabel.textContent = `${budgetPct}% used`;
|
||||
budgetLeftLabel.textContent = `${fmt(Math.abs(budgetRemaining))} ${budgetRemaining >= 0 ? "left" : "over"}`;
|
||||
budgetBar.style.width = `${budgetPct}%`;
|
||||
budgetBar.className = `rd-progress__fill ${budgetPct >= 90 ? "rd-progress__fill--rose" : budgetPct >= 70 ? "rd-progress__fill--amber" : "rd-progress__fill--emerald"}`;
|
||||
|
||||
// Category breakdown
|
||||
const catSection = document.getElementById("rd-category-breakdown")!;
|
||||
const catCards = catSection.querySelectorAll<HTMLElement>("[data-category]");
|
||||
catCards.forEach((card) => {
|
||||
const key = card.dataset.category!;
|
||||
const catData = categoryBreakdown.find(
|
||||
(c) => c.name.toLowerCase() === key,
|
||||
);
|
||||
const spent = catData?.spent ?? 0;
|
||||
const catBudget = catData?.budget ?? Math.round(budgetTotal / 4);
|
||||
const catPct = catBudget > 0 ? Math.min(100, Math.round((spent / catBudget) * 100)) : 0;
|
||||
|
||||
const amountsEl = card.querySelector("[data-cat-amounts]") as HTMLElement;
|
||||
const barEl = card.querySelector("[data-cat-bar]") as HTMLElement;
|
||||
const pctEl = card.querySelector("[data-cat-pct]") as HTMLElement;
|
||||
|
||||
if (amountsEl) amountsEl.textContent = `${fmt(spent)} / ${fmt(catBudget)}`;
|
||||
if (barEl) barEl.style.width = `${catPct}%`;
|
||||
if (pctEl) pctEl.textContent = `${catPct}% used`;
|
||||
});
|
||||
|
||||
// ── Update Expenses ──
|
||||
if (expenses.length > 0) {
|
||||
expensesSection.style.display = "";
|
||||
expenseCount.textContent = `Expenses (${expenses.length})`;
|
||||
expenseTotal.textContent = fmt(totalSpent);
|
||||
|
||||
expenseList.innerHTML = expenses
|
||||
.map((ex) => {
|
||||
const catIcon = CATEGORY_ICONS[ex.category] || "\u{1F4B0}";
|
||||
const catLabel = CATEGORY_LABELS[ex.category] || ex.category;
|
||||
const catTextClass = CATEGORY_TEXT_CLASS[ex.category] || "rd-text-muted";
|
||||
const perPerson = fmt(Math.round((ex.amount / MEMBERS.length) * 100) / 100);
|
||||
|
||||
return `<div class="rd-item" data-expense-id="${esc(ex.id)}">
|
||||
<div style="width:2.5rem; height:2.5rem; border-radius:0.75rem; display:flex; align-items:center; justify-content:center; font-size:1.125rem; background:rgba(51,65,85,0.4); flex-shrink:0;">
|
||||
${catIcon}
|
||||
</div>
|
||||
<div style="flex:1; min-width:0;">
|
||||
<p style="font-size:0.875rem; color:#e2e8f0; font-weight:500; margin:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${esc(ex.description)}</p>
|
||||
<div style="display:flex; align-items:center; gap:0.375rem; font-size:0.75rem; color:#64748b; margin-top:0.125rem; flex-wrap:wrap;">
|
||||
<span class="${catTextClass}">${catLabel}</span>
|
||||
<span>\u00B7</span>
|
||||
<span>Paid by ${esc(ex.paidBy)}</span>
|
||||
<span>\u00B7</span>
|
||||
<span>${ex.split === "equal" ? `Split ${MEMBERS.length} ways` : "Custom split"}</span>
|
||||
<span>\u00B7</span>
|
||||
<span>${formatDate(ex.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="rd-expense-amount" data-edit-expense="${esc(ex.id)}" data-amount="${ex.amount}" style="background:none; border:none; cursor:pointer; text-align:right; padding:0.25rem 0.5rem; border-radius:0.5rem; transition:background 0.15s; flex-shrink:0;" title="Click to edit amount">
|
||||
<span style="font-size:0.875rem; font-weight:600; color:#e2e8f0; display:block;">${fmt(ex.amount)}</span>
|
||||
<span style="font-size:0.75rem; color:#64748b; display:block;">${perPerson}/person</span>
|
||||
</button>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
} else {
|
||||
expensesSection.style.display = "none";
|
||||
}
|
||||
|
||||
// ── Update Balances ──
|
||||
balancesBody.innerHTML = balances
|
||||
.map((b) => {
|
||||
const bgClass = MEMBER_BG[b.name] || "rd-bg-slate";
|
||||
const initial = b.name[0];
|
||||
const balanceColor = b.balance >= 0 ? "rd-emerald" : "rd-rose";
|
||||
const balanceStr = `${b.balance >= 0 ? "+" : ""}${fmt(Math.round(b.balance * 100) / 100)}`;
|
||||
|
||||
return `<div style="display:flex; align-items:center; gap:0.75rem; margin-bottom:0.75rem;">
|
||||
<div class="rd-avatar ${bgClass}" style="width:2rem; height:2rem; font-size:0.75rem;">${initial}</div>
|
||||
<div style="flex:1; min-width:0;">
|
||||
<p style="font-size:0.875rem; color:#e2e8f0; margin:0;">${esc(b.name)}</p>
|
||||
<p style="font-size:0.75rem; color:#64748b; margin:0;">Paid ${fmt(b.paid)}</p>
|
||||
</div>
|
||||
<span style="font-size:0.875rem; font-weight:600;" class="${balanceColor}">${balanceStr}</span>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// ── Update Settlements ──
|
||||
if (settlements.length > 0) {
|
||||
settlementsBody.innerHTML =
|
||||
settlements
|
||||
.map(
|
||||
(s) => `<div style="display:flex; align-items:center; gap:0.5rem; background:rgba(51,65,85,0.3); border-radius:0.75rem; padding:0.75rem; margin-bottom:0.5rem;">
|
||||
<span style="font-size:0.875rem; font-weight:500; color:#fb7185;">${esc(s.from)}</span>
|
||||
<span style="flex:1; text-align:center;">
|
||||
<span style="font-size:0.75rem; color:#64748b;">\u2192</span>
|
||||
<span style="display:block; font-size:0.875rem; font-weight:600; color:white;">${fmt(s.amount)}</span>
|
||||
</span>
|
||||
<span style="font-size:0.875rem; font-weight:500; color:#34d399;">${esc(s.to)}</span>
|
||||
</div>`,
|
||||
)
|
||||
.join("") +
|
||||
`<p style="font-size:0.75rem; color:#64748b; text-align:center; margin:0.5rem 0 0;">
|
||||
${settlements.length} payment${settlements.length !== 1 ? "s" : ""} to settle all debts
|
||||
</p>`;
|
||||
} else {
|
||||
settlementsBody.innerHTML = `<p style="font-size:0.875rem; color:#64748b; text-align:center;">All settled up!</p>`;
|
||||
}
|
||||
|
||||
// ── Update Spending by Category ──
|
||||
if (expenses.length > 0) {
|
||||
spendingSection.style.display = "";
|
||||
const catKeys = ["transport", "accommodation", "activity", "food"] as const;
|
||||
catKeys.forEach((cat) => {
|
||||
const catExpenses = expenses.filter((e) => e.category === cat);
|
||||
const catTotal = catExpenses.reduce((s, e) => s + e.amount, 0);
|
||||
const catPct = totalSpent > 0 ? Math.round((catTotal / totalSpent) * 100) : 0;
|
||||
|
||||
const card = document.querySelector<HTMLElement>(`[data-spending-cat="${cat}"]`);
|
||||
if (!card) return;
|
||||
const amountEl = card.querySelector("[data-spending-amount]") as HTMLElement;
|
||||
const barEl = card.querySelector("[data-spending-bar]") as HTMLElement;
|
||||
const pctEl = card.querySelector("[data-spending-pct]") as HTMLElement;
|
||||
|
||||
if (amountEl) amountEl.textContent = fmt(catTotal);
|
||||
if (barEl) barEl.style.width = `${catPct}%`;
|
||||
if (pctEl) pctEl.textContent = `${catPct}% of total`;
|
||||
});
|
||||
} else {
|
||||
spendingSection.style.display = "none";
|
||||
}
|
||||
|
||||
// ── Update Per Person ──
|
||||
if (expenses.length > 0) {
|
||||
personSection.style.display = "";
|
||||
balances.forEach((b) => {
|
||||
const card = document.querySelector<HTMLElement>(`[data-person="${b.name}"]`);
|
||||
if (!card) return;
|
||||
const paidPct = totalSpent > 0 ? Math.round((b.paid / totalSpent) * 100) : 0;
|
||||
const paidEl = card.querySelector("[data-person-paid]") as HTMLElement;
|
||||
const pctEl = card.querySelector("[data-person-pct]") as HTMLElement;
|
||||
const balanceEl = card.querySelector("[data-person-balance]") as HTMLElement;
|
||||
|
||||
if (paidEl) paidEl.textContent = fmt(b.paid);
|
||||
if (pctEl) pctEl.textContent = `paid (${paidPct}%)`;
|
||||
if (balanceEl) {
|
||||
const roundedBalance = Math.round(b.balance * 100) / 100;
|
||||
const label = b.balance >= 0 ? "Gets back " : "Owes ";
|
||||
balanceEl.textContent = `${label}${fmt(Math.abs(roundedBalance))}`;
|
||||
balanceEl.className = b.balance >= 0 ? "rd-emerald" : "rd-rose";
|
||||
balanceEl.style.fontSize = "0.875rem";
|
||||
balanceEl.style.fontWeight = "600";
|
||||
balanceEl.style.margin = "0.25rem 0 0";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
personSection.style.display = "none";
|
||||
}
|
||||
}) as EventListener);
|
||||
|
||||
// ── Inline expense editing via event delegation ──
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Click on amount button to start editing
|
||||
const amountBtn = target.closest<HTMLElement>("[data-edit-expense]");
|
||||
if (amountBtn && !editingExpenseId) {
|
||||
const expenseId = amountBtn.dataset.editExpense!;
|
||||
const currentAmount = parseFloat(amountBtn.dataset.amount!);
|
||||
editingExpenseId = expenseId;
|
||||
|
||||
amountBtn.innerHTML = `
|
||||
<div style="display:flex; align-items:center; gap:0.25rem;">
|
||||
<span style="font-size:0.875rem; color:#94a3b8;">\u20AC</span>
|
||||
<input type="number" value="${currentAmount}" step="0.01" min="0"
|
||||
style="width:5rem; background:#334155; border:1px solid rgba(16,185,129,0.5); border-radius:0.5rem; padding:0.25rem 0.5rem; font-size:0.875rem; color:white; text-align:right; outline:none;"
|
||||
id="rd-edit-input" autofocus>
|
||||
<button id="rd-edit-save" style="background:rgba(16,185,129,0.1); color:#34d399; border:none; cursor:pointer; font-size:0.75rem; padding:0.125rem 0.375rem; border-radius:0.25rem;">\u2713</button>
|
||||
<button id="rd-edit-cancel" style="background:rgba(51,65,85,0.5); color:#94a3b8; border:none; cursor:pointer; font-size:0.75rem; padding:0.125rem 0.375rem; border-radius:0.25rem;">\u2717</button>
|
||||
</div>`;
|
||||
|
||||
const input = document.getElementById("rd-edit-input") as HTMLInputElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Save edit
|
||||
if (target.id === "rd-edit-save" || target.closest("#rd-edit-save")) {
|
||||
saveEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel edit
|
||||
if (target.id === "rd-edit-cancel" || target.closest("#rd-edit-cancel")) {
|
||||
cancelEdit();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle keyboard events on edit input
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (!editingExpenseId) return;
|
||||
const input = document.getElementById("rd-edit-input") as HTMLInputElement;
|
||||
if (!input || e.target !== input) return;
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
});
|
||||
|
||||
function saveEdit(): void {
|
||||
if (!editingExpenseId) return;
|
||||
const input = document.getElementById("rd-edit-input") as HTMLInputElement;
|
||||
if (!input) return;
|
||||
|
||||
const newAmount = parseFloat(input.value);
|
||||
if (!isNaN(newAmount) && newAmount >= 0) {
|
||||
sync.updateShape(editingExpenseId, {
|
||||
amount: Math.round(newAmount * 100) / 100,
|
||||
});
|
||||
}
|
||||
editingExpenseId = null;
|
||||
}
|
||||
|
||||
function cancelEdit(): void {
|
||||
editingExpenseId = null;
|
||||
// Re-render will happen on next snapshot; force it by dispatching current state
|
||||
sync.dispatchEvent(
|
||||
new CustomEvent("snapshot", {
|
||||
detail: { shapes: sync.shapes },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reset button ──
|
||||
|
||||
resetBtn.addEventListener("click", async () => {
|
||||
resetBtn.disabled = true;
|
||||
try {
|
||||
await sync.resetDemo();
|
||||
} catch (err) {
|
||||
console.error("Reset failed:", err);
|
||||
} finally {
|
||||
if (sync.connected) resetBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Connect ──
|
||||
|
||||
sync.connect();
|
||||
|
|
@ -169,3 +169,12 @@
|
|||
.funds-tx__amount--positive { color: #10b981; }
|
||||
.funds-tx__amount--negative { color: #ef4444; }
|
||||
.funds-tx__time { font-size: 11px; color: #64748b; white-space: nowrap; }
|
||||
|
||||
/* ── Mobile responsive ──────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.funds-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.funds-flows__grid { grid-template-columns: 1fr; }
|
||||
.funds-features__grid { grid-template-columns: 1fr; }
|
||||
.funds-cards { grid-template-columns: 1fr; }
|
||||
.funds-tabs { flex-wrap: wrap; }
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
/**
|
||||
* rFunds demo page — group expense tracking for "Alpine Explorer 2026".
|
||||
*
|
||||
* Renders server-side HTML skeleton with budget overview, expense list,
|
||||
* balances, settlements, category breakdown, and per-person stats.
|
||||
* The client-side funds-demo.ts hydrates via WebSocket (DemoSync).
|
||||
*/
|
||||
|
||||
/* ─── Constants ─────────────────────────────────────────────── */
|
||||
|
||||
const MEMBERS = [
|
||||
{ name: "Maya", initial: "M", color: "#10b981", bgClass: "rd-bg-emerald" },
|
||||
{ name: "Liam", initial: "L", color: "#06b6d4", bgClass: "rd-bg-cyan" },
|
||||
{ name: "Priya", initial: "P", color: "#8b5cf6", bgClass: "rd-bg-violet" },
|
||||
{ name: "Omar", initial: "O", color: "#f59e0b", bgClass: "rd-bg-amber" },
|
||||
];
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: "transport", icon: "\u{1F682}", label: "Transport", colorClass: "rd-progress__fill--cyan", badgeClass: "rd-badge--sky", textClass: "rd-cyan" },
|
||||
{ key: "accommodation", icon: "\u{1F3E8}", label: "Accommodation", colorClass: "rd-progress__fill--violet", badgeClass: "rd-badge--teal", textClass: "rd-violet" },
|
||||
{ key: "activity", icon: "\u26F7", label: "Activities", colorClass: "rd-progress__fill--amber", badgeClass: "rd-badge--amber", textClass: "rd-amber" },
|
||||
{ key: "food", icon: "\u{1F372}", label: "Food & Drink", colorClass: "rd-progress__fill--rose", badgeClass: "rd-badge--rose", textClass: "rd-rose" },
|
||||
];
|
||||
|
||||
/* ─── Render ─────────────────────────────────────────────── */
|
||||
|
||||
export function renderDemo(): string {
|
||||
return `
|
||||
<div class="rd-root" style="--rd-accent-from: #f59e0b; --rd-accent-to: #10b981;">
|
||||
|
||||
<!-- ── Hero ── -->
|
||||
<section class="rd-hero">
|
||||
<h1>Alpine Explorer 2026</h1>
|
||||
<p class="rd-subtitle">Group Expenses</p>
|
||||
<div class="rd-meta">
|
||||
<span>\u{1F4C5} Jul 6-20, 2026</span>
|
||||
<span style="color:#475569">|</span>
|
||||
<span>\u{1F465} ${MEMBERS.length} travelers</span>
|
||||
<span style="color:#475569">|</span>
|
||||
<span>\u{1F3D4} Chamonix \u2192 Zermatt \u2192 Dolomites</span>
|
||||
</div>
|
||||
<div class="rd-avatars">
|
||||
${MEMBERS.map(
|
||||
(m) =>
|
||||
`<div class="rd-avatar ${m.bgClass}" title="${m.name}">${m.initial}</div>`,
|
||||
).join("\n ")}
|
||||
<span class="rd-count">${MEMBERS.length} members</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Status bar ── -->
|
||||
<div class="rd-section rd-section--narrow">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.5rem; flex-wrap:wrap; gap:0.75rem">
|
||||
<div style="display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap">
|
||||
<span id="rd-conn-badge" class="rd-status rd-status--disconnected">Disconnected</span>
|
||||
<span class="rd-badge rd-badge--amber" style="font-size:0.7rem">Live — synced across all r* demos</span>
|
||||
</div>
|
||||
<button id="rd-reset-btn" class="rd-btn rd-btn--ghost" disabled>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||
Reset Demo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Loading state ── -->
|
||||
<div class="rd-section rd-section--narrow">
|
||||
<div id="rd-loading" class="rd-card" style="border:2px dashed rgba(100,116,139,0.4); display:none">
|
||||
<div class="rd-card-body" style="padding:3rem; text-align:center">
|
||||
<div style="width:2rem; height:2rem; margin:0 auto 0.75rem; border:3px solid rgba(245,158,11,0.2); border-top-color:#f59e0b; border-radius:50%; animation:rd-spin 0.8s linear infinite"></div>
|
||||
<p class="rd-text-muted">Connecting to rSpace...</p>
|
||||
</div>
|
||||
</div>
|
||||
<style>@keyframes rd-spin { to { transform: rotate(360deg); } }</style>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div id="rd-empty" class="rd-card" style="border:2px dashed rgba(100,116,139,0.4); display:none">
|
||||
<div class="rd-card-body" style="padding:3rem; text-align:center">
|
||||
<div style="font-size:2rem; margin-bottom:0.75rem">\u{1F4B0}</div>
|
||||
<p class="rd-text-muted">No expense data found. Try resetting the demo.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Budget Overview ── -->
|
||||
<section id="rd-budget-section" class="rd-section rd-section--narrow" style="display:none">
|
||||
<div class="rd-card" style="padding:1.5rem; margin-bottom:1.5rem;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1rem;">
|
||||
<h2 style="font-size:1.125rem; font-weight:600; color:#f1f5f9; margin:0; display:flex; align-items:center; gap:0.5rem;">
|
||||
\u{1F4CA} Trip Budget
|
||||
</h2>
|
||||
<span class="rd-live">live</span>
|
||||
</div>
|
||||
|
||||
<!-- Budget totals: 3-column stat grid -->
|
||||
<div class="rd-grid rd-grid--3" style="margin-bottom:1rem;">
|
||||
<div class="rd-stat">
|
||||
<p class="rd-stat__value" id="rd-budget-total">\u20AC4,000</p>
|
||||
<p class="rd-stat__label">Total Budget</p>
|
||||
</div>
|
||||
<div class="rd-stat">
|
||||
<p class="rd-stat__value rd-emerald" id="rd-budget-spent">\u20AC0</p>
|
||||
<p class="rd-stat__label">Spent</p>
|
||||
</div>
|
||||
<div class="rd-stat">
|
||||
<p class="rd-stat__value rd-cyan" id="rd-budget-remaining">\u20AC4,000</p>
|
||||
<p class="rd-stat__label" id="rd-budget-remaining-label">Remaining</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div style="margin-bottom:1rem;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; font-size:0.75rem; color:#94a3b8; margin-bottom:0.375rem;">
|
||||
<span id="rd-budget-pct-label">0% used</span>
|
||||
<span id="rd-budget-left-label">\u20AC4,000 left</span>
|
||||
</div>
|
||||
<div class="rd-progress">
|
||||
<div class="rd-progress__fill rd-progress__fill--emerald" id="rd-budget-bar" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category breakdown -->
|
||||
<div>
|
||||
<h3 style="font-size:0.875rem; font-weight:600; color:#cbd5e1; margin:0 0 0.75rem;">Budget by Category</h3>
|
||||
<div class="rd-grid rd-grid--2" id="rd-category-breakdown">
|
||||
${CATEGORIES.map(
|
||||
(cat) => `
|
||||
<div class="rd-stat" style="padding:0.75rem;" data-category="${cat.key}">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:0.5rem;">
|
||||
<span style="display:flex; align-items:center; gap:0.5rem; font-size:0.875rem; color:#e2e8f0;">
|
||||
${cat.icon} ${cat.label}
|
||||
</span>
|
||||
<span class="rd-text-xs rd-text-muted" data-cat-amounts>\u20AC0 / \u20AC1,000</span>
|
||||
</div>
|
||||
<div class="rd-progress rd-progress--sm">
|
||||
<div class="${cat.colorClass}" style="height:100%; border-radius:9999px; width:0%; transition:width 0.3s" data-cat-bar></div>
|
||||
</div>
|
||||
<p class="rd-text-xs rd-text-dim" style="margin:0.25rem 0 0;" data-cat-pct>0% used</p>
|
||||
</div>`,
|
||||
).join("")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Expenses + Balances grid ── -->
|
||||
<section id="rd-expenses-section" class="rd-section" style="display:none">
|
||||
<div style="display:grid; grid-template-columns:1fr; gap:1rem;">
|
||||
|
||||
<!-- Expense list (left 2/3 on desktop) -->
|
||||
<div class="rd-card" id="rd-expense-card" style="grid-column:1;">
|
||||
<div class="rd-card-header">
|
||||
<div class="rd-card-title"><span class="rd-icon">\u{1F4DD}</span> <span id="rd-expense-count">Expenses (0)</span></div>
|
||||
<span class="rd-text-xs rd-text-muted">Click amount to edit</span>
|
||||
</div>
|
||||
<div id="rd-expense-list">
|
||||
<!-- Populated by funds-demo.ts -->
|
||||
</div>
|
||||
<!-- Total row -->
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; padding:0.75rem 1.25rem; border-top:1px solid rgba(51,65,85,0.5); background:rgba(51,65,85,0.2);">
|
||||
<span style="font-size:0.875rem; font-weight:600; color:#cbd5e1;">Total</span>
|
||||
<span style="font-size:1.125rem; font-weight:700; color:white;" id="rd-expense-total">\u20AC0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Balances (right 1/3 on desktop) -->
|
||||
<div style="display:flex; flex-direction:column; gap:1rem;">
|
||||
<div class="rd-card" id="rd-balances">
|
||||
<div class="rd-card-header">
|
||||
<div class="rd-card-title"><span class="rd-icon">\u2696\uFE0F</span> Balances</div>
|
||||
</div>
|
||||
<div class="rd-card-body" id="rd-balances-body">
|
||||
<!-- Populated by funds-demo.ts -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rd-card" id="rd-settlements">
|
||||
<div class="rd-card-header">
|
||||
<div class="rd-card-title"><span class="rd-icon">\u{1F4B8}</span> Settle Up</div>
|
||||
</div>
|
||||
<div class="rd-card-body" id="rd-settlements-body">
|
||||
<!-- Populated by funds-demo.ts -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Spending by Category ── -->
|
||||
<section id="rd-spending-section" class="rd-section" style="display:none">
|
||||
<div class="rd-card" style="padding:1.5rem;">
|
||||
<h2 style="font-size:1.125rem; font-weight:600; margin:0 0 1rem; display:flex; align-items:center; gap:0.5rem;">
|
||||
\u{1F4CA} Spending by Category
|
||||
</h2>
|
||||
<div class="rd-grid rd-grid--4" id="rd-spending-grid">
|
||||
${CATEGORIES.map(
|
||||
(cat) => `
|
||||
<div class="rd-stat" data-spending-cat="${cat.key}">
|
||||
<div style="font-size:1.5rem; margin-bottom:0.5rem;">${cat.icon}</div>
|
||||
<p style="font-size:0.875rem; color:#cbd5e1; font-weight:500; margin:0;">${cat.label}</p>
|
||||
<p style="font-size:1.125rem; font-weight:700; color:white; margin:0.25rem 0;" data-spending-amount>\u20AC0</p>
|
||||
<div class="rd-progress rd-progress--xs" style="margin-bottom:0.25rem;">
|
||||
<div class="${cat.colorClass}" style="height:100%; border-radius:9999px; width:0%; transition:width 0.3s" data-spending-bar></div>
|
||||
</div>
|
||||
<p class="rd-text-xs rd-text-dim" style="margin:0;" data-spending-pct>0% of total</p>
|
||||
</div>`,
|
||||
).join("")}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Per Person ── -->
|
||||
<section id="rd-person-section" class="rd-section" style="display:none">
|
||||
<div class="rd-card" style="padding:1.5rem;">
|
||||
<h2 style="font-size:1.125rem; font-weight:600; margin:0 0 1rem; display:flex; align-items:center; gap:0.5rem;">
|
||||
\u{1F464} Per Person
|
||||
</h2>
|
||||
<div class="rd-grid rd-grid--4" id="rd-person-grid">
|
||||
${MEMBERS.map(
|
||||
(m) => `
|
||||
<div class="rd-stat" data-person="${m.name}">
|
||||
<div class="rd-avatar ${m.bgClass}" style="width:3rem; height:3rem; font-size:1.125rem; margin:0 auto 0.5rem; box-shadow:0 0 0 2px #1e293b;">${m.initial}</div>
|
||||
<p style="font-size:0.875rem; color:#cbd5e1; font-weight:500; margin:0;">${m.name}</p>
|
||||
<p style="font-size:1.125rem; font-weight:700; color:white; margin:0.25rem 0;" data-person-paid>\u20AC0</p>
|
||||
<p class="rd-text-xs rd-text-dim" style="margin:0;" data-person-pct>paid (0%)</p>
|
||||
<p style="font-size:0.875rem; font-weight:600; margin:0.25rem 0 0;" data-person-balance>\u20AC0</p>
|
||||
</div>`,
|
||||
).join("")}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── CTA ── -->
|
||||
<section class="rd-section rd-section--narrow">
|
||||
<div class="rd-cta">
|
||||
<h2>Track Your Group Expenses</h2>
|
||||
<p>
|
||||
rFunds makes it easy to manage shared costs, track budgets, and settle up.
|
||||
Design custom funding flows with threshold-based mechanisms.
|
||||
</p>
|
||||
<a href="/create-space" style="background:linear-gradient(135deg, #f59e0b, #10b981); box-shadow:0 8px 24px rgba(245,158,11,0.25);">
|
||||
Create Your Space
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Responsive grid for expenses + balances on desktop */
|
||||
@media (min-width: 768px) {
|
||||
#rd-expenses-section > div {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>`;
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* rFunds — rich landing page body.
|
||||
* Ported from rfunds-online/app/page.tsx (Next.js/Tailwind).
|
||||
* Returned by landingPage() in the module export;
|
||||
* the shell wraps it with header, CSS, and analytics.
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
<!-- Hero -->
|
||||
<div class="rl-hero">
|
||||
<span class="rl-tagline">rFunds</span>
|
||||
<h1 class="rl-heading" style="background:linear-gradient(to right,#fcd34d,#6ee7b7,#93c5fd);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||
Threshold-Based Flow Funding
|
||||
</h1>
|
||||
<p class="rl-subtext" style="font-size:1.25rem;max-width:48rem;margin:0 auto 2rem">
|
||||
Design continuous funding flows that respond dynamically to threshold conditions.
|
||||
Connect funnels, set overflow rules, and track outcomes in real-time.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rfunds" class="rl-cta-secondary" id="ml-primary">Try the Demo</a>
|
||||
<a href="/create-space" class="rl-cta-primary">Create Your Own</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div style="width:3rem;height:3rem;background:rgba(245,158,11,0.2);border-radius:0.75rem;display:flex;align-items:center;justify-content:center;margin:0 auto 1rem">
|
||||
<svg style="width:1.5rem;height:1.5rem;color:#fbbf24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Create Funnels</h3>
|
||||
<p>Each funnel represents a funding pool with three zones: overflow (above max),
|
||||
healthy (between thresholds), and critical (below min). Set thresholds by
|
||||
dragging handles.</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card rl-card--center">
|
||||
<div style="width:3rem;height:3rem;background:rgba(16,185,129,0.2);border-radius:0.75rem;display:flex;align-items:center;justify-content:center;margin:0 auto 1rem">
|
||||
<svg style="width:1.5rem;height:1.5rem;color:#34d399" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Connect Flows</h3>
|
||||
<p>Connect funnels with overflow edges (sideways) so excess funds automatically
|
||||
route to other pools. Add spending edges (downward) to fund outcomes and
|
||||
deliverables.</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card rl-card--center">
|
||||
<div style="width:3rem;height:3rem;background:rgba(59,130,246,0.2);border-radius:0.75rem;display:flex;align-items:center;justify-content:center;margin:0 auto 1rem">
|
||||
<svg style="width:1.5rem;height:1.5rem;color:#60a5fa" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Simulate & Share</h3>
|
||||
<p>Run simulations to see how funds flow through your system. Watch edge widths
|
||||
scale proportionally. Save your space locally and share it with a link.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The Funnel Metaphor -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<div style="background:rgba(30,41,59,0.3);border-radius:1rem;border:1px solid rgba(51,65,85,0.5);padding:2rem">
|
||||
<h2 class="rl-heading" style="text-align:center;margin-bottom:2rem">The Funnel Metaphor</h2>
|
||||
<div style="max-width:28rem;margin:0 auto;font-family:monospace;font-size:0.875rem;text-align:center">
|
||||
|
||||
<!-- Overflow Zone -->
|
||||
<div style="color:#fbbf24">
|
||||
<div style="background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);border-radius:0.75rem 0.75rem 0 0;padding:0.75rem 2rem">
|
||||
<div style="font-weight:600">OVERFLOW ZONE</div>
|
||||
<div style="font-size:0.75rem;color:#fcd34d">Above MAX threshold</div>
|
||||
<div style="font-size:0.75rem;color:rgba(252,211,77,0.7)">Excess funds redistribute to other funnels</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Max Threshold Line -->
|
||||
<div style="font-size:0.75rem;color:#f59e0b;font-weight:700;border-bottom:2px dashed rgba(245,158,11,0.5);padding:0.25rem 0;background:rgba(245,158,11,0.05)">
|
||||
-- MAX THRESHOLD --
|
||||
</div>
|
||||
|
||||
<!-- Healthy Zone -->
|
||||
<div style="color:#34d399">
|
||||
<div style="background:rgba(16,185,129,0.1);border-left:1px solid rgba(16,185,129,0.3);border-right:1px solid rgba(16,185,129,0.3);padding:1rem 2rem">
|
||||
<div style="font-weight:600">HEALTHY ZONE</div>
|
||||
<div style="font-size:0.75rem;color:#6ee7b7">Normal operations</div>
|
||||
<div style="font-size:0.75rem;color:rgba(110,231,183,0.7)">Full flow rate, balanced funding</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Min Threshold Line -->
|
||||
<div style="font-size:0.75rem;color:#ef4444;font-weight:700;border-bottom:2px dashed rgba(239,68,68,0.5);padding:0.25rem 0;background:rgba(239,68,68,0.05)">
|
||||
-- MIN THRESHOLD --
|
||||
</div>
|
||||
|
||||
<!-- Critical Zone -->
|
||||
<div style="color:#94a3b8">
|
||||
<div style="background:rgba(51,65,85,0.3);border:1px solid rgba(71,85,105,0.3);border-radius:0 0 0.75rem 0.75rem;padding:0.75rem 2rem">
|
||||
<div style="font-weight:600;color:#f87171">CRITICAL ZONE</div>
|
||||
<div style="font-size:0.75rem;color:#fca5a5">Below MIN threshold</div>
|
||||
<div style="font-size:0.75rem;color:#64748b">Outflow restricted, conservation mode</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Ecosystem Integration -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Ecosystem Integration</h2>
|
||||
<p class="rl-subtext" style="text-align:center">
|
||||
rFunds connects to other rSpace modules for end-to-end treasury governance.
|
||||
</p>
|
||||
<div class="rl-grid-2" style="max-width:700px;margin:0 auto">
|
||||
<div class="rl-integration">
|
||||
<div class="rl-icon-box">💰</div>
|
||||
<div>
|
||||
<h3><a href="/rwallet" style="color:#14b8a6;text-decoration:none">rWallet</a></h3>
|
||||
<p>On-chain balances and wallet-based treasury tracking for decentralized communities.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-integration">
|
||||
<div class="rl-icon-box">🗳</div>
|
||||
<div>
|
||||
<h3><a href="/rvote" style="color:#14b8a6;text-decoration:none">rVote</a></h3>
|
||||
<p>Governance decisions that direct fund allocation through formal proposal workflows.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Built on Open Source -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
|
||||
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rFunds.</p>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🌊</div>
|
||||
<h3>Flow Service</h3>
|
||||
<p>Custom budget flow engine powering the river visualization and conviction-based allocation logic.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🗃</div>
|
||||
<h3>PostgreSQL</h3>
|
||||
<p>Reliable relational storage for space-flow associations, transaction history, and pool balances.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">⚡</div>
|
||||
<h3>Hono</h3>
|
||||
<p>Ultrafast web framework for the API layer — lightweight, typed, and edge-ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Your Data, Protected -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">Your Data, Protected</h2>
|
||||
<p class="rl-subtext">How rFunds keeps your information safe.</p>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔒</div>
|
||||
<h3>End-to-End Encryption</h3>
|
||||
<span class="rl-badge">Coming Soon</span>
|
||||
<p>All content encrypted before it leaves your device. Not even the server can read it.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🕵</div>
|
||||
<h3>Zero-Knowledge Architecture</h3>
|
||||
<span class="rl-badge">Coming Soon</span>
|
||||
<p>The server processes your requests without ever seeing your data in the clear.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🏠</div>
|
||||
<h3>Self-Hosted</h3>
|
||||
<p>Run on your own infrastructure. Your server, your rules, your data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">Ready to design your funding flows?</h2>
|
||||
<p class="rl-subtext">Start with the interactive demo or create your own space with custom funnels and connections.</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rfunds" class="rl-cta-primary">Try the Demo</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create Your Own</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rl-back">
|
||||
<a href="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -8,11 +8,12 @@ import { Hono } from "hono";
|
|||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { renderShell, renderDemoShell } from "../../server/shell";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
import { renderLanding } from "./landing";
|
||||
import { renderDemo } from "./demo";
|
||||
|
||||
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
|
||||
|
||||
|
|
@ -189,14 +190,27 @@ routes.delete("/api/space-flows/:flowId", async (c) => {
|
|||
// ─── Page routes ────────────────────────────────────────
|
||||
|
||||
const fundsScripts = `
|
||||
<script type="module" src="/modules/funds/folk-funds-app.js"></script>
|
||||
<script type="module" src="/modules/funds/folk-budget-river.js"></script>`;
|
||||
<script type="module" src="/modules/rfunds/folk-funds-app.js"></script>
|
||||
<script type="module" src="/modules/rfunds/folk-budget-river.js"></script>`;
|
||||
|
||||
const fundsStyles = `<link rel="stylesheet" href="/modules/funds/funds.css">`;
|
||||
const fundsStyles = `<link rel="stylesheet" href="/modules/rfunds/funds.css">`;
|
||||
|
||||
// Landing page
|
||||
routes.get("/", (c) => {
|
||||
const spaceSlug = c.req.param("space") || "demo";
|
||||
if (spaceSlug === "demo") {
|
||||
return c.html(renderDemoShell({
|
||||
title: "rFunds Demo — rSpace",
|
||||
moduleId: "rfunds",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: renderDemo(),
|
||||
demoScripts: `<script type="module" src="/lib/demo-sync.js"></script>
|
||||
<script type="module" src="/modules/rfunds/funds-demo.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rfunds/funds.css">`,
|
||||
}));
|
||||
}
|
||||
return c.html(renderShell({
|
||||
title: `rFunds — TBFF Flow Funding | rSpace`,
|
||||
moduleId: "rfunds",
|
||||
|
|
@ -204,8 +218,8 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-funds-app space="${spaceSlug}"></folk-funds-app>`,
|
||||
scripts: `<script type="module" src="/modules/funds/folk-funds-app.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/funds/funds.css">`,
|
||||
scripts: `<script type="module" src="/modules/rfunds/folk-funds-app.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rfunds/funds.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -247,6 +261,7 @@ export const fundsModule: RSpaceModule = {
|
|||
description: "Budget flows, river visualization, and treasury management",
|
||||
routes,
|
||||
landingPage: renderLanding,
|
||||
demoPage: renderDemo,
|
||||
standaloneDomain: "rfunds.online",
|
||||
feeds: [
|
||||
{
|
||||
|
|
@ -15,6 +15,19 @@ class FolkInboxClient extends HTMLElement {
|
|||
private currentThread: any = null;
|
||||
private approvals: any[] = [];
|
||||
private filter: "all" | "open" | "snoozed" | "closed" = "all";
|
||||
private demoThreads: Record<string, any[]> = {
|
||||
team: [
|
||||
{ id: "t1", from_name: "Alice Chen", from_address: "alice@example.com", subject: "Sprint planning notes", status: "open", is_read: true, is_starred: true, comment_count: 3, received_at: new Date(Date.now() - 2 * 3600000).toISOString(), body_text: "Here are the sprint planning notes from today's session. We agreed on the following priorities for the next two weeks:\n\n1. Ship local-first sync for notes module\n2. Polish the calendar demo mode\n3. Review provider registry API\n\nLet me know if I missed anything.", comments: [{ username: "Bob Martinez", body: "Looks good! I'd add the inbox overhaul too.", created_at: new Date(Date.now() - 1.5 * 3600000).toISOString() }, { username: "Carol Wu", body: "Agreed, calendar polish is top priority.", created_at: new Date(Date.now() - 1 * 3600000).toISOString() }, { username: "Alice Chen", body: "Updated the list. Thanks!", created_at: new Date(Date.now() - 0.5 * 3600000).toISOString() }] },
|
||||
{ id: "t2", from_name: "Bob Martinez", from_address: "bob@example.com", subject: "Deploy checklist for v2.1", status: "open", is_read: false, is_starred: false, comment_count: 1, received_at: new Date(Date.now() - 5 * 3600000).toISOString(), body_text: "Here is the deploy checklist for v2.1. Please review before we cut the release.\n\n- [ ] Run full test suite\n- [ ] Update changelog\n- [ ] Tag release in Gitea\n- [ ] Deploy to staging\n- [ ] Smoke test all modules", comments: [{ username: "Alice Chen", body: "I can handle the changelog update.", created_at: new Date(Date.now() - 4 * 3600000).toISOString() }] },
|
||||
{ id: "t3", from_name: "Carol Wu", from_address: "carol@example.com", subject: "Design system color tokens", status: "snoozed", is_read: true, is_starred: false, comment_count: 0, received_at: new Date(Date.now() - 24 * 3600000).toISOString(), body_text: "I've been working on standardizing our color tokens across all modules. The current approach of inline hex values is getting unwieldy. Proposal attached.", comments: [] },
|
||||
{ id: "t4", from_name: "Dave Park", from_address: "dave@example.com", subject: "Q1 Retrospective summary", status: "closed", is_read: true, is_starred: false, comment_count: 5, received_at: new Date(Date.now() - 72 * 3600000).toISOString(), body_text: "Summary of our Q1 retrospective:\n\nWhat went well: Local-first architecture, community engagement, rapid prototyping.\nWhat to improve: Documentation, test coverage, onboarding flow.", comments: [{ username: "Alice Chen", body: "Great summary, Dave.", created_at: new Date(Date.now() - 70 * 3600000).toISOString() }, { username: "Bob Martinez", body: "+1 on improving docs.", created_at: new Date(Date.now() - 69 * 3600000).toISOString() }, { username: "Carol Wu", body: "I can lead the onboarding redesign.", created_at: new Date(Date.now() - 68 * 3600000).toISOString() }, { username: "Dave Park", body: "Sounds good, let's schedule a kickoff.", created_at: new Date(Date.now() - 67 * 3600000).toISOString() }, { username: "Alice Chen", body: "Added to next sprint.", created_at: new Date(Date.now() - 66 * 3600000).toISOString() }] },
|
||||
],
|
||||
support: [
|
||||
{ id: "t5", from_name: "New User", from_address: "newuser@example.com", subject: "How do I create a space?", status: "open", is_read: false, is_starred: false, comment_count: 0, received_at: new Date(Date.now() - 1 * 3600000).toISOString(), body_text: "Hi, I just signed up and I'm not sure how to create my own space. The docs mention a space switcher but I can't find it. Could you point me in the right direction?", comments: [] },
|
||||
{ id: "t6", from_name: "Partner Org", from_address: "partner@example.org", subject: "Integration API access request", status: "open", is_read: true, is_starred: true, comment_count: 2, received_at: new Date(Date.now() - 8 * 3600000).toISOString(), body_text: "We'd like to integrate our platform with rSpace modules via the API. Could you provide API documentation and access credentials for our staging environment?", comments: [{ username: "Team Bot", body: "Request logged. Assigning to API team.", created_at: new Date(Date.now() - 7 * 3600000).toISOString() }, { username: "Bob Martinez", body: "I'll send over the API docs today.", created_at: new Date(Date.now() - 6 * 3600000).toISOString() }] },
|
||||
{ id: "t7", from_name: "Community Member", from_address: "member@example.com", subject: "Feature request: dark mode", status: "closed", is_read: true, is_starred: false, comment_count: 4, received_at: new Date(Date.now() - 96 * 3600000).toISOString(), body_text: "Would love to see a proper dark mode toggle. The current theme is close but some panels still have bright backgrounds.", comments: [{ username: "Carol Wu", body: "This is on our roadmap! Targeting next release.", created_at: new Date(Date.now() - 90 * 3600000).toISOString() }, { username: "Community Member", body: "Awesome, looking forward to it.", created_at: new Date(Date.now() - 88 * 3600000).toISOString() }, { username: "Carol Wu", body: "Dark mode shipped in v2.0!", created_at: new Date(Date.now() - 48 * 3600000).toISOString() }, { username: "Community Member", body: "Looks great, thanks!", created_at: new Date(Date.now() - 46 * 3600000).toISOString() }] },
|
||||
],
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -23,9 +36,18 @@ class FolkInboxClient extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.loadMailboxes();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.mailboxes = [
|
||||
{ slug: "team", name: "Team Inbox", email: "team@rspace.online", description: "Shared workspace inbox for internal team communications" },
|
||||
{ slug: "support", name: "Support", email: "support@rspace.online", description: "Community support requests with multi-sig approval workflows" },
|
||||
];
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async loadMailboxes() {
|
||||
try {
|
||||
const base = window.location.pathname.replace(/\/$/, "");
|
||||
|
|
@ -39,6 +61,12 @@ class FolkInboxClient extends HTMLElement {
|
|||
}
|
||||
|
||||
private async loadThreads(slug: string) {
|
||||
if (this.space === "demo") {
|
||||
this.threads = this.demoThreads[slug] || [];
|
||||
if (this.filter !== "all") this.threads = this.threads.filter(t => t.status === this.filter);
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = window.location.pathname.replace(/\/$/, "");
|
||||
const status = this.filter === "all" ? "" : `?status=${this.filter}`;
|
||||
|
|
@ -52,6 +80,15 @@ class FolkInboxClient extends HTMLElement {
|
|||
}
|
||||
|
||||
private async loadThread(id: string) {
|
||||
if (this.space === "demo") {
|
||||
const all = [...(this.demoThreads.team || []), ...(this.demoThreads.support || [])];
|
||||
this.currentThread = all.find(t => t.id === id) || null;
|
||||
if (this.currentThread) {
|
||||
this.currentThread.comments = this.currentThread.comments || [{ username: "Team Bot", body: "Thread noted.", created_at: new Date().toISOString() }];
|
||||
}
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = window.location.pathname.replace(/\/$/, "");
|
||||
const resp = await fetch(`${base}/api/threads/${id}`);
|
||||
|
|
@ -63,6 +100,7 @@ class FolkInboxClient extends HTMLElement {
|
|||
}
|
||||
|
||||
private async loadApprovals() {
|
||||
if (this.space === "demo") { this.approvals = []; this.render(); return; }
|
||||
try {
|
||||
const base = window.location.pathname.replace(/\/$/, "");
|
||||
const q = this.currentMailbox ? `?mailbox=${this.currentMailbox.slug}` : "";
|
||||
|
|
@ -136,6 +174,10 @@ class FolkInboxClient extends HTMLElement {
|
|||
.approval-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
|
||||
.btn-approve { padding: 0.4rem 1rem; border-radius: 6px; border: none; background: #22c55e; color: white; cursor: pointer; font-size: 0.8rem; }
|
||||
.btn-reject { padding: 0.4rem 1rem; border-radius: 6px; border: none; background: #ef4444; color: white; cursor: pointer; font-size: 0.8rem; }
|
||||
@media (max-width: 600px) {
|
||||
.thread-from { width: auto; max-width: 100px; }
|
||||
.thread-row { flex-wrap: wrap; gap: 4px; }
|
||||
}
|
||||
</style>
|
||||
<div class="container">
|
||||
${this.renderNav()}
|
||||
|
|
@ -329,6 +371,7 @@ class FolkInboxClient extends HTMLElement {
|
|||
// Approval actions
|
||||
this.shadow.querySelectorAll("[data-approve]").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (this.space === "demo") { alert("Approvals are disabled in demo mode."); return; }
|
||||
const id = (btn as HTMLElement).dataset.approve!;
|
||||
const base = window.location.pathname.replace(/\/$/, "");
|
||||
await fetch(`${base}/api/approvals/${id}/sign`, {
|
||||
|
|
@ -341,6 +384,7 @@ class FolkInboxClient extends HTMLElement {
|
|||
});
|
||||
this.shadow.querySelectorAll("[data-reject]").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (this.space === "demo") { alert("Approvals are disabled in demo mode."); return; }
|
||||
const id = (btn as HTMLElement).dataset.reject!;
|
||||
const base = window.location.pathname.replace(/\/$/, "");
|
||||
await fetch(`${base}/api/approvals/${id}/sign`, {
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* rInbox (rChats) landing page — encrypted community chat.
|
||||
* Ported from rchats-online Next.js page.tsx (shadcn/ui + Lucide).
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
<!-- Hero -->
|
||||
<div class="rl-hero">
|
||||
<span class="rl-tagline">Part of the rSpace Ecosystem</span>
|
||||
<h1 class="rl-heading">Encrypted<br>
|
||||
<span style="background:linear-gradient(135deg,#3b82f6,#14b8a6);-webkit-background-clip:text;background-clip:text;color:transparent">Community Chat</span>
|
||||
</h1>
|
||||
<p class="rl-subtext">
|
||||
Secure messaging powered by <strong style="color:#e2e8f0">EncryptID passkeys</strong>.
|
||||
No passwords, no seed phrases — just <strong style="color:#e2e8f0">hardware-backed encryption</strong> and
|
||||
full data ownership for your community.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rinbox" class="rl-cta-primary" id="ml-primary">
|
||||
Try the Demo →
|
||||
</a>
|
||||
<a href="https://demo.rspace.online/rinbox" class="rl-cta-secondary">Get Started</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<div style="text-align:center;margin-bottom:1.5rem">
|
||||
<span class="rl-badge">How It Works</span>
|
||||
<h2 class="rl-heading" style="margin-top:0.75rem">Secure Chat in 30 Seconds</h2>
|
||||
<p class="rl-subtext">
|
||||
<strong style="color:#3b82f6">Create a passkey</strong> on your device,
|
||||
<strong style="color:#14b8a6">join a community</strong>, and
|
||||
<strong style="color:#e2e8f0">chat with end-to-end encryption</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card" style="border:2px solid rgba(59,130,246,0.4);background:linear-gradient(135deg,rgba(59,130,246,0.1),rgba(59,130,246,0.05))">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||
<div style="height:2rem;width:2rem;border-radius:50%;background:#3b82f6;display:flex;align-items:center;justify-content:center">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 10V4a2 2 0 00-4 0v6"/><path d="M18 11a6 6 0 00-12 0v3"/><rect x="3" y="14" width="18" height="8" rx="2"/><line x1="12" y1="18" x2="12" y2="18"/></svg>
|
||||
</div>
|
||||
<h3 style="font-weight:700;font-size:1.125rem;color:#e2e8f0">1. Create a Passkey</h3>
|
||||
</div>
|
||||
<p style="font-size:0.875rem;color:#94a3b8;line-height:1.6">
|
||||
Register with EncryptID using your device's biometrics or security key.
|
||||
No passwords to remember or leak.
|
||||
<strong style="color:#e2e8f0;display:block;margin-top:0.5rem">Your keys never leave your device.</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card" style="border:2px solid rgba(20,184,166,0.4);background:linear-gradient(135deg,rgba(20,184,166,0.1),rgba(20,184,166,0.05))">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||
<div style="height:2rem;width:2rem;border-radius:50%;background:#14b8a6;display:flex;align-items:center;justify-content:center">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4-4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
|
||||
</div>
|
||||
<h3 style="font-weight:700;font-size:1.125rem;color:#e2e8f0">2. Join a Community</h3>
|
||||
</div>
|
||||
<p style="font-size:0.875rem;color:#94a3b8;line-height:1.6">
|
||||
Create or join a community chat space. Invite members with role-based
|
||||
access: viewers, participants, moderators, and admins.
|
||||
<strong style="color:#e2e8f0;display:block;margin-top:0.5rem">One identity across all r* apps.</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card" style="border:2px solid rgba(100,116,139,0.4);background:linear-gradient(135deg,rgba(100,116,139,0.1),rgba(100,116,139,0.05))">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||
<div style="height:2rem;width:2rem;border-radius:50%;background:#64748b;display:flex;align-items:center;justify-content:center">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/><rect x="9" y="9" width="6" height="1" rx="0.5"/></svg>
|
||||
</div>
|
||||
<h3 style="font-weight:700;font-size:1.125rem;color:#e2e8f0">3. Chat Securely</h3>
|
||||
</div>
|
||||
<p style="font-size:0.875rem;color:#94a3b8;line-height:1.6">
|
||||
Messages are encrypted with keys derived from your passkey.
|
||||
Only community members can read them.
|
||||
<strong style="color:#e2e8f0;display:block;margin-top:0.5rem">True end-to-end encryption.</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Row 1 -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Built on Self-Sovereign Identity</h2>
|
||||
<p class="rl-subtext" style="text-align:center">Security without compromise, powered by EncryptID</p>
|
||||
|
||||
<div class="rl-grid-4">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:linear-gradient(135deg,#3b82f6,rgba(59,130,246,0.6))">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
</div>
|
||||
<h3>End-to-End Encryption</h3>
|
||||
<p>AES-256-GCM encryption with keys derived from your passkey. Messages are unreadable to the server.</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:linear-gradient(135deg,#14b8a6,rgba(20,184,166,0.6))">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 10V4a2 2 0 00-4 0v6"/><path d="M18 11a6 6 0 00-12 0v3"/><rect x="3" y="14" width="18" height="8" rx="2"/><line x1="12" y1="18" x2="12" y2="18"/></svg>
|
||||
</div>
|
||||
<h3>Passkey Authentication</h3>
|
||||
<p>WebAuthn passkeys backed by biometrics or hardware security keys. Phishing-resistant by design.</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:linear-gradient(135deg,#22c55e,#059669)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4-4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
|
||||
</div>
|
||||
<h3>Community Spaces</h3>
|
||||
<p>Create isolated chat spaces with role-based access. Viewer, participant, moderator, and admin roles.</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:linear-gradient(135deg,#f97316,#d97706)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
||||
</div>
|
||||
<h3>Cross-App SSO</h3>
|
||||
<p>One identity across rSpace, rVote, rMaps, rWork, and the full r* ecosystem via EncryptID.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Row 2 -->
|
||||
<div class="rl-grid-4" style="margin-top:1rem">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:linear-gradient(135deg,#a855f7,#7c3aed)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.42 4.58a5.4 5.4 0 00-7.65 0l-.77.78-.77-.78a5.4 5.4 0 00-7.65 7.65l.78.77L12 20.64l7.64-7.64.78-.77a5.4 5.4 0 000-7.65z"/><path d="M12 5.36V12"/><path d="M8.5 8.5L12 12l3.5-3.5"/></svg>
|
||||
</div>
|
||||
<h3>Social Recovery</h3>
|
||||
<p>No seed phrases. Designate trusted guardians who can help you recover access to your account.</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:linear-gradient(135deg,#3b82f6,#0891b2)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
</div>
|
||||
<h3>No Passwords</h3>
|
||||
<p>Passkeys replace passwords entirely. Nothing to leak, nothing to forget, nothing to phish.</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:linear-gradient(135deg,#f43f5e,#ec4899)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
</div>
|
||||
<h3>Self-Sovereign</h3>
|
||||
<p>You own your identity and encryption keys. No platform lock-in, no central authority.</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box" style="background:linear-gradient(135deg,#14b8a6,#0891b2)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/><rect x="9" y="9" width="6" height="1" rx="0.5"/></svg>
|
||||
</div>
|
||||
<h3>rSpace Ecosystem</h3>
|
||||
<p>Chat integrates with rSpace canvas, rVote governance, rFunds treasury, and more.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<div class="rl-card" style="border:2px solid rgba(59,130,246,0.3);background:linear-gradient(135deg,rgba(59,130,246,0.1),rgba(20,184,166,0.05),rgba(100,116,139,0.1));text-align:center;padding:3rem 1.5rem;position:relative;overflow:hidden">
|
||||
<div style="position:absolute;top:0;right:0;width:16rem;height:16rem;background:rgba(59,130,246,0.1);border-radius:50%;filter:blur(48px)"></div>
|
||||
<div style="position:absolute;bottom:0;left:0;width:16rem;height:16rem;background:rgba(20,184,166,0.1);border-radius:50%;filter:blur(48px)"></div>
|
||||
<div style="position:relative">
|
||||
<span class="rl-badge">Try EncryptID</span>
|
||||
<h2 class="rl-heading" style="margin-top:1rem">See it in action</h2>
|
||||
<p class="rl-subtext" style="max-width:36rem;margin:0.5rem auto 0">
|
||||
Try the interactive EncryptID demo — register a passkey, derive encryption keys,
|
||||
and test signing and encryption right in your browser. No account needed.
|
||||
</p>
|
||||
<div class="rl-cta-row" style="margin-top:1.5rem">
|
||||
<a href="https://demo.rspace.online/rinbox" class="rl-cta-primary">
|
||||
Launch Demo →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rl-back">
|
||||
<a href="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -528,6 +528,57 @@ async function runSyncLoop() {
|
|||
// Start IMAP sync in background
|
||||
runSyncLoop();
|
||||
|
||||
// ── About / use-cases landing ──
|
||||
routes.get("/about", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
return c.html(renderShell({
|
||||
title: `Multi-Sig Inbox — rInbox | rSpace`,
|
||||
moduleId: "rinbox",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `
|
||||
<div style="max-width:56rem;margin:0 auto;padding:2rem 1.5rem;">
|
||||
<h1 style="font-size:2rem;font-weight:bold;text-align:center;margin-bottom:0.5rem;">What can you do with a multi-sig inbox?</h1>
|
||||
<p style="color:#94a3b8;text-align:center;margin-bottom:2.5rem;max-width:36rem;margin-left:auto;margin-right:auto;">
|
||||
When outbound email requires collective approval, entirely new coordination patterns become possible.
|
||||
</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1.5rem;">
|
||||
<div style="background:rgba(30,41,59,.5);border:1px solid rgba(51,65,85,.5);border-radius:1rem;padding:1.5rem;">
|
||||
<h3 style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem;color:#22d3ee;">Governance & Resolutions</h3>
|
||||
<p style="color:#94a3b8;font-size:.875rem;line-height:1.6;">Board decisions require 3-of-5 signers before the email sends. The message is the vote — no separate tooling needed.</p>
|
||||
</div>
|
||||
<div style="background:rgba(30,41,59,.5);border:1px solid rgba(51,65,85,.5);border-radius:1rem;padding:1.5rem;">
|
||||
<h3 style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem;color:#a78bfa;">Escrow & Conditional Release</h3>
|
||||
<p style="color:#94a3b8;font-size:.875rem;line-height:1.6;">Hold sensitive documents in an inbox that only unlocks when N parties agree. Mediation where neither side can act alone.</p>
|
||||
</div>
|
||||
<div style="background:rgba(30,41,59,.5);border:1px solid rgba(51,65,85,.5);border-radius:1rem;padding:1.5rem;">
|
||||
<h3 style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem;color:#f87171;">Whistleblower Coordination</h3>
|
||||
<p style="color:#94a3b8;font-size:.875rem;line-height:1.6;">Evidence requires M-of-N co-signers before release. Dead man’s switch if a signer goes silent. Nobody goes first alone.</p>
|
||||
</div>
|
||||
<div style="background:rgba(30,41,59,.5);border:1px solid rgba(51,65,85,.5);border-radius:1rem;padding:1.5rem;">
|
||||
<h3 style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem;color:#4ade80;">Social Key Recovery</h3>
|
||||
<p style="color:#94a3b8;font-size:.875rem;line-height:1.6;">Lost access? 3-of-5 trusted contacts co-sign your restoration. No phone number, no backup email — a trust network.</p>
|
||||
</div>
|
||||
<div style="background:rgba(30,41,59,.5);border:1px solid rgba(51,65,85,.5);border-radius:1rem;padding:1.5rem;">
|
||||
<h3 style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem;color:#fbbf24;">Tamper-Proof Audit Trails</h3>
|
||||
<p style="color:#94a3b8;font-size:.875rem;line-height:1.6;">Every email read and sent is co-signed. Cryptographic proof of who approved what, when. Built for compliance.</p>
|
||||
</div>
|
||||
<div style="background:rgba(30,41,59,.5);border:1px solid rgba(51,65,85,.5);border-radius:1rem;padding:1.5rem;">
|
||||
<h3 style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem;color:#60a5fa;">Treasury & Payments</h3>
|
||||
<p style="color:#94a3b8;font-size:.875rem;line-height:1.6;">Invoice arrives, 2-of-3 finance team co-sign the reply authorizing payment. Bridges email to on-chain wallets.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:2.5rem;">
|
||||
<a href="/${space}/rinbox" style="display:inline-block;padding:0.75rem 2rem;background:#0891b2;color:white;border-radius:0.75rem;text-decoration:none;font-weight:600;">Open Inbox</a>
|
||||
<a href="https://rinbox.online" style="display:inline-block;padding:0.75rem 2rem;margin-left:1rem;border:1px solid rgba(51,65,85,.5);color:#94a3b8;border-radius:0.75rem;text-decoration:none;font-weight:600;">rinbox.online</a>
|
||||
</div>
|
||||
</div>`,
|
||||
scripts: "",
|
||||
styles: "",
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
|
|
@ -538,8 +589,8 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-inbox-client space="${space}"></folk-inbox-client>`,
|
||||
scripts: `<script type="module" src="/modules/inbox/folk-inbox-client.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/inbox/inbox.css">`,
|
||||
scripts: `<script type="module" src="/modules/rinbox/folk-inbox-client.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rinbox/inbox.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -141,8 +141,8 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
|
||||
scripts: `<script type="module" src="/modules/maps/folk-map-viewer.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/maps/maps.css">`,
|
||||
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=3"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -156,9 +156,9 @@ routes.get("/:room", (c) => {
|
|||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
styles: `<link rel="stylesheet" href="/modules/maps/maps.css">`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
|
||||
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
|
||||
scripts: `<script type="module" src="/modules/maps/folk-map-viewer.js"></script>`,
|
||||
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=3"></script>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,548 @@
|
|||
/**
|
||||
* <folk-graph-viewer> — community relationship graph.
|
||||
*
|
||||
* Displays network nodes (people, companies, opportunities)
|
||||
* and edges in a force-directed layout with search and filtering.
|
||||
*/
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "person" | "company" | "opportunity";
|
||||
workspace: string;
|
||||
role?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
type: "work_at" | "point_of_contact" | "collaborates";
|
||||
label?: string;
|
||||
}
|
||||
|
||||
class FolkGraphViewer extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
private workspaces: any[] = [];
|
||||
private info: any = null;
|
||||
private nodes: GraphNode[] = [];
|
||||
private edges: GraphEdge[] = [];
|
||||
private filter: "all" | "person" | "company" | "opportunity" = "all";
|
||||
private searchQuery = "";
|
||||
private error = "";
|
||||
private selectedNode: GraphNode | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.loadData();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.info = { name: "rSpace Community", member_count: 10, company_count: 3, opportunity_count: 0 };
|
||||
|
||||
this.workspaces = [
|
||||
{ name: "Commons DAO", slug: "commons-dao", nodeCount: 5, edgeCount: 4 },
|
||||
{ name: "Mycelial Lab", slug: "mycelial-lab", nodeCount: 5, edgeCount: 4 },
|
||||
{ name: "Regenerative Fund", slug: "regenerative-fund", nodeCount: 5, edgeCount: 4 },
|
||||
];
|
||||
|
||||
// Organizations
|
||||
this.nodes = [
|
||||
{ id: "org-1", name: "Commons DAO", type: "company", workspace: "Commons DAO", description: "Decentralized governance cooperative" },
|
||||
{ id: "org-2", name: "Mycelial Lab", type: "company", workspace: "Mycelial Lab", description: "Protocol research collective" },
|
||||
{ id: "org-3", name: "Regenerative Fund", type: "company", workspace: "Regenerative Fund", description: "Impact funding vehicle" },
|
||||
|
||||
// People — Commons DAO
|
||||
{ id: "p-1", name: "Alice Chen", type: "person", workspace: "Commons DAO", role: "Lead Engineer", location: "Vancouver" },
|
||||
{ id: "p-2", name: "Bob Nakamura", type: "person", workspace: "Commons DAO", role: "Community Lead", location: "Tokyo" },
|
||||
{ id: "p-3", name: "Carol Santos", type: "person", workspace: "Commons DAO", role: "Treasury Steward", location: "S\u00e3o Paulo" },
|
||||
{ id: "p-4", name: "Dave Okafor", type: "person", workspace: "Commons DAO", role: "Governance Facilitator", location: "Lagos" },
|
||||
|
||||
// People — Mycelial Lab
|
||||
{ id: "p-5", name: "Eva Larsson", type: "person", workspace: "Mycelial Lab", role: "Ops Coordinator", location: "Stockholm" },
|
||||
{ id: "p-6", name: "Frank M\u00fcller", type: "person", workspace: "Mycelial Lab", role: "Protocol Designer", location: "Berlin" },
|
||||
{ id: "p-7", name: "Grace Kim", type: "person", workspace: "Mycelial Lab", role: "Strategy Lead", location: "Seoul" },
|
||||
|
||||
// People — Regenerative Fund
|
||||
{ id: "p-8", name: "Hiro Tanaka", type: "person", workspace: "Regenerative Fund", role: "Research Lead", location: "Osaka" },
|
||||
{ id: "p-9", name: "Iris Patel", type: "person", workspace: "Regenerative Fund", role: "Developer Relations", location: "Mumbai" },
|
||||
{ id: "p-10", name: "James Wright", type: "person", workspace: "Regenerative Fund", role: "Security Auditor", location: "London" },
|
||||
];
|
||||
|
||||
// Edges: work_at links + cross-org point_of_contact
|
||||
this.edges = [
|
||||
// Work_at — Commons DAO
|
||||
{ source: "p-1", target: "org-1", type: "work_at" },
|
||||
{ source: "p-2", target: "org-1", type: "work_at" },
|
||||
{ source: "p-3", target: "org-1", type: "work_at" },
|
||||
{ source: "p-4", target: "org-1", type: "work_at" },
|
||||
|
||||
// Work_at — Mycelial Lab
|
||||
{ source: "p-5", target: "org-2", type: "work_at" },
|
||||
{ source: "p-6", target: "org-2", type: "work_at" },
|
||||
{ source: "p-7", target: "org-2", type: "work_at" },
|
||||
|
||||
// Work_at — Regenerative Fund
|
||||
{ source: "p-8", target: "org-3", type: "work_at" },
|
||||
{ source: "p-9", target: "org-3", type: "work_at" },
|
||||
{ source: "p-10", target: "org-3", type: "work_at" },
|
||||
|
||||
// Cross-org point_of_contact edges
|
||||
{ source: "p-1", target: "p-6", type: "point_of_contact", label: "Alice \u2194 Frank" },
|
||||
{ source: "p-2", target: "p-3", type: "point_of_contact", label: "Bob \u2194 Carol" },
|
||||
{ source: "p-4", target: "p-7", type: "point_of_contact", label: "Dave \u2194 Grace" },
|
||||
];
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/network/);
|
||||
return match ? `/${match[1]}/network` : "";
|
||||
}
|
||||
|
||||
private async loadData() {
|
||||
const base = this.getApiBase();
|
||||
try {
|
||||
const [wsRes, infoRes] = await Promise.all([
|
||||
fetch(`${base}/api/workspaces`),
|
||||
fetch(`${base}/api/info`),
|
||||
]);
|
||||
if (wsRes.ok) this.workspaces = await wsRes.json();
|
||||
if (infoRes.ok) this.info = await infoRes.json();
|
||||
} catch { /* offline */ }
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getFilteredNodes(): GraphNode[] {
|
||||
let filtered = this.nodes;
|
||||
if (this.filter !== "all") {
|
||||
filtered = filtered.filter(n => n.type === this.filter);
|
||||
}
|
||||
if (this.searchQuery.trim()) {
|
||||
const q = this.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(n =>
|
||||
n.name.toLowerCase().includes(q) ||
|
||||
n.workspace.toLowerCase().includes(q) ||
|
||||
(n.role && n.role.toLowerCase().includes(q)) ||
|
||||
(n.location && n.location.toLowerCase().includes(q)) ||
|
||||
(n.description && n.description.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private computeForceLayout(nodes: GraphNode[], edges: GraphEdge[], W: number, H: number): Record<string, { x: number; y: number }> {
|
||||
const pos: Record<string, { x: number; y: number }> = {};
|
||||
|
||||
// Initial positions: orgs in triangle, people around their org
|
||||
const orgCenters: Record<string, { x: number; y: number }> = {
|
||||
"org-1": { x: W / 2, y: 120 },
|
||||
"org-2": { x: 160, y: 380 },
|
||||
"org-3": { x: W - 160, y: 380 },
|
||||
};
|
||||
const orgNameToId: Record<string, string> = {
|
||||
"Commons DAO": "org-1", "Mycelial Lab": "org-2", "Regenerative Fund": "org-3",
|
||||
};
|
||||
|
||||
for (const [id, p] of Object.entries(orgCenters)) pos[id] = { ...p };
|
||||
|
||||
const peopleByOrg: Record<string, GraphNode[]> = {};
|
||||
for (const n of nodes) {
|
||||
if (n.type === "person") {
|
||||
const oid = orgNameToId[n.workspace];
|
||||
if (oid) { (peopleByOrg[oid] ??= []).push(n); }
|
||||
}
|
||||
}
|
||||
for (const [oid, people] of Object.entries(peopleByOrg)) {
|
||||
const c = orgCenters[oid];
|
||||
if (!c) continue;
|
||||
const gcx = W / 2, gcy = 250;
|
||||
const base = Math.atan2(c.y - gcy, c.x - gcx);
|
||||
const spread = Math.PI * 0.8;
|
||||
people.forEach((p, i) => {
|
||||
const angle = base - spread / 2 + (spread * i) / Math.max(people.length - 1, 1);
|
||||
pos[p.id] = { x: c.x + 110 * Math.cos(angle), y: c.y + 110 * Math.sin(angle) };
|
||||
});
|
||||
}
|
||||
|
||||
// Run force iterations
|
||||
const allIds = nodes.map(n => n.id).filter(id => pos[id]);
|
||||
for (let iter = 0; iter < 80; iter++) {
|
||||
const force: Record<string, { fx: number; fy: number }> = {};
|
||||
for (const id of allIds) force[id] = { fx: 0, fy: 0 };
|
||||
|
||||
// Repulsion between all nodes
|
||||
for (let i = 0; i < allIds.length; i++) {
|
||||
for (let j = i + 1; j < allIds.length; j++) {
|
||||
const a = pos[allIds[i]], b = pos[allIds[j]];
|
||||
let dx = b.x - a.x, dy = b.y - a.y;
|
||||
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||||
const repel = 3000 / (dist * dist);
|
||||
dx /= dist; dy /= dist;
|
||||
force[allIds[i]].fx -= dx * repel;
|
||||
force[allIds[i]].fy -= dy * repel;
|
||||
force[allIds[j]].fx += dx * repel;
|
||||
force[allIds[j]].fy += dy * repel;
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction along edges
|
||||
for (const edge of edges) {
|
||||
const a = pos[edge.source], b = pos[edge.target];
|
||||
if (!a || !b) continue;
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const idealLen = edge.type === "work_at" ? 100 : 200;
|
||||
const attract = (dist - idealLen) * 0.01;
|
||||
const ux = dx / Math.max(dist, 1), uy = dy / Math.max(dist, 1);
|
||||
if (force[edge.source]) { force[edge.source].fx += ux * attract; force[edge.source].fy += uy * attract; }
|
||||
if (force[edge.target]) { force[edge.target].fx -= ux * attract; force[edge.target].fy -= uy * attract; }
|
||||
}
|
||||
|
||||
// Center gravity
|
||||
for (const id of allIds) {
|
||||
const p = pos[id];
|
||||
force[id].fx += (W / 2 - p.x) * 0.002;
|
||||
force[id].fy += (H / 2 - p.y) * 0.002;
|
||||
}
|
||||
|
||||
// Apply forces with damping
|
||||
const damping = 0.4 * (1 - iter / 80);
|
||||
for (const id of allIds) {
|
||||
pos[id].x += force[id].fx * damping;
|
||||
pos[id].y += force[id].fy * damping;
|
||||
pos[id].x = Math.max(30, Math.min(W - 30, pos[id].x));
|
||||
pos[id].y = Math.max(30, Math.min(H - 30, pos[id].y));
|
||||
}
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
private getTrustScore(nodeId: string): number {
|
||||
return Math.min(100, this.edges.filter(e => e.source === nodeId || e.target === nodeId).length * 20);
|
||||
}
|
||||
|
||||
private getConnectedNodes(nodeId: string): GraphNode[] {
|
||||
const connIds = new Set<string>();
|
||||
for (const e of this.edges) {
|
||||
if (e.source === nodeId) connIds.add(e.target);
|
||||
if (e.target === nodeId) connIds.add(e.source);
|
||||
}
|
||||
return this.nodes.filter(n => connIds.has(n.id));
|
||||
}
|
||||
|
||||
private renderDetailPanel(): string {
|
||||
if (!this.selectedNode) return "";
|
||||
const n = this.selectedNode;
|
||||
const connected = this.getConnectedNodes(n.id);
|
||||
const trust = n.type === "person" ? this.getTrustScore(n.id) : -1;
|
||||
return `
|
||||
<div class="detail-panel" id="detail-panel">
|
||||
<div class="detail-header">
|
||||
<span class="detail-icon">${n.type === "company" ? "\u{1F3E2}" : "\u{1F464}"}</span>
|
||||
<div class="detail-info">
|
||||
<div class="detail-name">${this.esc(n.name)}</div>
|
||||
<div class="detail-type">${this.esc(n.type === "company" ? "Organization" : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}</div>
|
||||
</div>
|
||||
<button class="detail-close" id="close-detail">\u2715</button>
|
||||
</div>
|
||||
${n.description ? `<p class="detail-desc">${this.esc(n.description)}</p>` : ""}
|
||||
${trust >= 0 ? `<div class="detail-trust"><span class="trust-label">Trust Score</span><span class="trust-bar"><span class="trust-fill" style="width:${trust}%"></span></span><span class="trust-val">${trust}</span></div>` : ""}
|
||||
${connected.length > 0 ? `
|
||||
<div class="detail-section">Connected (${connected.length})</div>
|
||||
${connected.map(c => `<div class="detail-conn"><span class="conn-dot" style="background:${c.type === "company" ? "#22c55e" : "#3b82f6"}"></span>${this.esc(c.name)}<span class="conn-role">${this.esc(c.role || c.type)}</span></div>`).join("")}
|
||||
` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderGraphNodes(): string {
|
||||
const filtered = this.getFilteredNodes();
|
||||
if (filtered.length === 0 && this.nodes.length > 0) {
|
||||
return `<div class="placeholder"><p style="font-size:14px;color:#888">No nodes match current filter.</p></div>`;
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
return `
|
||||
<div class="placeholder">
|
||||
<p style="font-size:48px">🕸️</p>
|
||||
<p style="font-size:16px">Community Relationship Graph</p>
|
||||
<p>Connect the force-directed layout engine to visualize your network.</p>
|
||||
<p style="font-size:12px;color:#444">Automerge CRDT sync + d3-force layout</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const W = 700;
|
||||
const H = 500;
|
||||
const filteredIds = new Set(filtered.map(n => n.id));
|
||||
|
||||
// Force-directed layout
|
||||
const positions = this.computeForceLayout(this.nodes, this.edges, W, H);
|
||||
|
||||
// Org colors
|
||||
const orgColors: Record<string, string> = {
|
||||
"org-1": "#6366f1", "org-2": "#22c55e", "org-3": "#f59e0b",
|
||||
};
|
||||
|
||||
// Cluster backgrounds based on computed positions
|
||||
const orgIds = ["org-1", "org-2", "org-3"];
|
||||
const clustersSvg = orgIds.map(orgId => {
|
||||
const pos = positions[orgId];
|
||||
if (!pos) return "";
|
||||
const color = orgColors[orgId] || "#333";
|
||||
return `<circle cx="${pos.x}" cy="${pos.y}" r="140" fill="${color}" opacity="0.04" stroke="${color}" stroke-width="1" stroke-opacity="0.15" stroke-dasharray="4 4"/>`;
|
||||
}).join("");
|
||||
|
||||
// Render edges
|
||||
const edgesSvg: string[] = [];
|
||||
for (const edge of this.edges) {
|
||||
const sp = positions[edge.source];
|
||||
const tp = positions[edge.target];
|
||||
if (!sp || !tp) continue;
|
||||
if (!filteredIds.has(edge.source) || !filteredIds.has(edge.target)) continue;
|
||||
|
||||
if (edge.type === "work_at") {
|
||||
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#555" stroke-width="1" opacity="0.35"/>`);
|
||||
} else if (edge.type === "point_of_contact") {
|
||||
const mx = (sp.x + tp.x) / 2;
|
||||
const my = (sp.y + tp.y) / 2;
|
||||
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#c084fc" stroke-width="1.5" stroke-dasharray="6 3" opacity="0.6"/>`);
|
||||
if (edge.label) {
|
||||
edgesSvg.push(`<text x="${mx}" y="${my - 6}" fill="#c084fc" font-size="8" text-anchor="middle" opacity="0.7">${this.esc(edge.label)}</text>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render nodes
|
||||
const nodesSvg = filtered.map(node => {
|
||||
const pos = positions[node.id];
|
||||
if (!pos) return "";
|
||||
const isOrg = node.type === "company";
|
||||
const color = isOrg ? (orgColors[node.id] || "#22c55e") : "#3b82f6";
|
||||
const radius = isOrg ? 22 : 12;
|
||||
const isSelected = this.selectedNode?.id === node.id;
|
||||
|
||||
let label = this.esc(node.name);
|
||||
let sublabel = "";
|
||||
if (isOrg && node.description) {
|
||||
sublabel = `<text x="${pos.x}" y="${pos.y + radius + 26}" fill="#888" font-size="8" text-anchor="middle">${this.esc(node.description)}</text>`;
|
||||
} else if (!isOrg && node.role) {
|
||||
sublabel = `<text x="${pos.x}" y="${pos.y + radius + 24}" fill="#666" font-size="8" text-anchor="middle">${this.esc(node.role)}${node.location ? " \u00b7 " + this.esc(node.location) : ""}</text>`;
|
||||
}
|
||||
|
||||
// Trust score badge for people
|
||||
const trust = !isOrg ? this.getTrustScore(node.id) : -1;
|
||||
const trustBadge = trust >= 0 ? `
|
||||
<circle cx="${pos.x + radius - 2}" cy="${pos.y - radius + 2}" r="8" fill="#7c3aed" stroke="#0d0d14" stroke-width="1.5"/>
|
||||
<text x="${pos.x + radius - 2}" y="${pos.y - radius + 5.5}" fill="#fff" font-size="7" font-weight="700" text-anchor="middle">${trust}</text>
|
||||
` : "";
|
||||
|
||||
return `
|
||||
<g class="graph-node" data-node-id="${node.id}" style="cursor:pointer">
|
||||
${isSelected ? `<circle cx="${pos.x}" cy="${pos.y}" r="${radius + 6}" fill="none" stroke="${color}" stroke-width="2" opacity="0.6"/>` : ""}
|
||||
<circle cx="${pos.x}" cy="${pos.y}" r="${radius}" fill="${color}" opacity="${isOrg ? 0.9 : 0.75}" stroke="${isOrg ? color : "none"}" stroke-width="${isOrg ? 2 : 0}" stroke-opacity="0.3"/>
|
||||
${isOrg ? `<text x="${pos.x}" y="${pos.y + 4}" fill="#fff" font-size="9" font-weight="600" text-anchor="middle">${label.length > 14 ? label.slice(0, 12) + "\u2026" : label}</text>` : ""}
|
||||
<text x="${pos.x}" y="${pos.y + radius + 13}" fill="#ccc" font-size="${isOrg ? 11 : 10}" font-weight="${isOrg ? 600 : 400}" text-anchor="middle">${label}</text>
|
||||
${sublabel}
|
||||
${trustBadge}
|
||||
</g>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `<svg viewBox="0 0 ${W} ${H}" width="100%" height="100%" style="max-height:500px">${clustersSvg}${edgesSvg.join("")}${nodesSvg}</svg>`;
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
|
||||
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
|
||||
.search-input {
|
||||
border: 1px solid #333; border-radius: 8px; padding: 8px 12px;
|
||||
background: #16161e; color: #e0e0e0; font-size: 13px; width: 200px; outline: none;
|
||||
}
|
||||
.search-input:focus { border-color: #6366f1; }
|
||||
.filter-btn {
|
||||
padding: 6px 12px; border-radius: 8px; border: 1px solid #333;
|
||||
background: #16161e; color: #888; cursor: pointer; font-size: 12px;
|
||||
}
|
||||
.filter-btn:hover { border-color: #555; }
|
||||
.filter-btn.active { border-color: #6366f1; color: #6366f1; }
|
||||
|
||||
.graph-canvas {
|
||||
width: 100%; height: 500px; border-radius: 12px;
|
||||
background: #0d0d14; border: 1px solid #222;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.placeholder { text-align: center; color: #555; padding: 40px; }
|
||||
.placeholder p { margin: 6px 0; }
|
||||
|
||||
.workspace-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.ws-card {
|
||||
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
|
||||
padding: 16px; cursor: pointer; transition: border-color 0.2s;
|
||||
}
|
||||
.ws-card:hover { border-color: #555; }
|
||||
.ws-name { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
||||
.ws-meta { font-size: 12px; color: #888; }
|
||||
|
||||
.legend { display: flex; gap: 16px; margin-top: 12px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #888; }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.legend-line { display: inline-block; }
|
||||
.dot-person { background: #3b82f6; }
|
||||
.dot-company { background: #22c55e; }
|
||||
.dot-opportunity { background: #f59e0b; }
|
||||
|
||||
.stats { display: flex; gap: 20px; margin-bottom: 16px; }
|
||||
.stat { text-align: center; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; color: #6366f1; }
|
||||
.stat-label { font-size: 11px; color: #888; }
|
||||
|
||||
.demo-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; background: #f59e0b22; color: #f59e0b; font-size: 11px; font-weight: 600; margin-left: 8px; }
|
||||
|
||||
.graph-node:hover circle:first-child { filter: brightness(1.2); }
|
||||
|
||||
.detail-panel {
|
||||
background: #1a1a2e; border: 1px solid #334155; border-radius: 10px;
|
||||
padding: 16px; margin-top: 12px;
|
||||
}
|
||||
.detail-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
.detail-icon { font-size: 24px; }
|
||||
.detail-info { flex: 1; }
|
||||
.detail-name { font-size: 15px; font-weight: 600; color: #e2e8f0; }
|
||||
.detail-type { font-size: 12px; color: #94a3b8; }
|
||||
.detail-close { background: none; border: none; color: #64748b; font-size: 16px; cursor: pointer; padding: 4px; }
|
||||
.detail-close:hover { color: #e2e8f0; }
|
||||
.detail-desc { font-size: 13px; color: #94a3b8; line-height: 1.5; margin: 8px 0; }
|
||||
.detail-trust { display: flex; align-items: center; gap: 8px; margin: 10px 0; }
|
||||
.trust-label { font-size: 11px; color: #64748b; min-width: 70px; }
|
||||
.trust-bar { flex: 1; height: 6px; background: #1e1e2e; border-radius: 3px; overflow: hidden; }
|
||||
.trust-fill { display: block; height: 100%; background: #7c3aed; border-radius: 3px; transition: width 0.3s; }
|
||||
.trust-val { font-size: 12px; font-weight: 700; color: #a78bfa; min-width: 24px; text-align: right; }
|
||||
.detail-section { font-size: 11px; font-weight: 600; color: #64748b; margin: 12px 0 6px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.detail-conn { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #e2e8f0; padding: 4px 0; }
|
||||
.conn-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.conn-role { font-size: 11px; color: #64748b; margin-left: auto; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.graph-canvas { height: 350px; }
|
||||
.workspace-list { grid-template-columns: 1fr; }
|
||||
.stats { flex-wrap: wrap; gap: 12px; }
|
||||
.toolbar { flex-direction: column; align-items: stretch; }
|
||||
.search-input { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
|
||||
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Network Graph${this.space === "demo" ? '<span class="demo-badge">Demo</span>' : ""}</span>
|
||||
</div>
|
||||
|
||||
${this.info ? `
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="stat-value">${this.info.member_count || 0}</div><div class="stat-label">People</div></div>
|
||||
<div class="stat"><div class="stat-value">${this.info.company_count || 0}</div><div class="stat-label">Organizations</div></div>
|
||||
<div class="stat"><div class="stat-value">${this.edges?.filter((e: GraphEdge) => e.type === "point_of_contact").length || 0}</div><div class="stat-label">Cross-org Links</div></div>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<div class="toolbar">
|
||||
<input class="search-input" type="text" placeholder="Search nodes..." id="search-input" value="${this.esc(this.searchQuery)}">
|
||||
${(["all", "person", "company", "opportunity"] as const).map(f => {
|
||||
const labels: Record<string, string> = { all: "All", person: "People", company: "Organizations", opportunity: "Opportunities" };
|
||||
return `<button class="filter-btn ${this.filter === f ? "active" : ""}" data-filter="${f}">${labels[f]}</button>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
|
||||
<div class="graph-canvas">
|
||||
${this.nodes.length > 0 ? this.renderGraphNodes() : `
|
||||
<div class="placeholder">
|
||||
<p style="font-size:48px">🕸️</p>
|
||||
<p style="font-size:16px">Community Relationship Graph</p>
|
||||
<p>Connect the force-directed layout engine to visualize your network.</p>
|
||||
<p style="font-size:12px;color:#444">Automerge CRDT sync + d3-force layout</p>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
${this.renderDetailPanel()}
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item"><span class="legend-dot dot-person"></span> People</div>
|
||||
<div class="legend-item"><span class="legend-dot dot-company"></span> Organizations</div>
|
||||
<div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#555" stroke-width="2"/></svg> Works at</div>
|
||||
<div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#c084fc" stroke-width="2" stroke-dasharray="4 2"/></svg> Point of contact</div>
|
||||
</div>
|
||||
|
||||
${this.workspaces.length > 0 ? `
|
||||
<div style="margin-top:20px;font-size:14px;font-weight:600;color:#aaa">${this.space === "demo" ? "Organizations" : "Workspaces"}</div>
|
||||
<div class="workspace-list">
|
||||
${this.workspaces.map(ws => `
|
||||
<div class="ws-card">
|
||||
<div class="ws-name">${this.esc(ws.name || ws.slug)}</div>
|
||||
<div class="ws-meta">${ws.nodeCount || 0} nodes · ${ws.edgeCount || 0} edges</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
` : ""}
|
||||
`;
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
private attachListeners() {
|
||||
this.shadow.querySelectorAll("[data-filter]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
this.filter = (el as HTMLElement).dataset.filter as any;
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
let searchTimeout: any;
|
||||
this.shadow.getElementById("search-input")?.addEventListener("input", (e) => {
|
||||
this.searchQuery = (e.target as HTMLInputElement).value;
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => this.render(), 200);
|
||||
});
|
||||
|
||||
// Node click → detail panel
|
||||
this.shadow.querySelectorAll("[data-node-id]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
const id = (el as HTMLElement).dataset.nodeId!;
|
||||
if (this.selectedNode?.id === id) { this.selectedNode = null; }
|
||||
else { this.selectedNode = this.nodes.find(n => n.id === id) || null; }
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Close detail panel
|
||||
this.shadow.getElementById("close-detail")?.addEventListener("click", () => {
|
||||
this.selectedNode = null;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-graph-viewer", FolkGraphViewer);
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { renderLanding } from "./landing";
|
||||
|
|
@ -217,15 +217,30 @@ routes.get("/api/workspaces", (c) => {
|
|||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const view = c.req.query("view");
|
||||
|
||||
if (view === "app") {
|
||||
return c.html(renderExternalAppShell({
|
||||
title: `${space} — Twenty CRM | rSpace`,
|
||||
moduleId: "rnetwork",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: "https://demo.rnetwork.online",
|
||||
appName: "Twenty CRM",
|
||||
theme: "dark",
|
||||
}));
|
||||
}
|
||||
|
||||
return c.html(renderShell({
|
||||
title: `${space} — Network | rSpace`,
|
||||
moduleId: "rnetwork",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
|
||||
scripts: `<script type="module" src="/modules/network/folk-graph-viewer.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/network/network.css">`,
|
||||
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
|
||||
<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
|
||||
scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js?v=3"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -237,6 +252,7 @@ export const networkModule: RSpaceModule = {
|
|||
routes,
|
||||
landingPage: renderLanding,
|
||||
standaloneDomain: "rnetwork.online",
|
||||
externalApp: { url: "https://demo.rnetwork.online", name: "Twenty CRM" },
|
||||
feeds: [
|
||||
{
|
||||
id: "trust-graph",
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,353 @@
|
|||
/**
|
||||
* rNotes demo — client-side WebSocket controller.
|
||||
*
|
||||
* Connects to rSpace via DemoSync, populates note cards,
|
||||
* packing list checkboxes, sidebar, and notebook header.
|
||||
*/
|
||||
|
||||
import { DemoSync, type DemoShape } from "../../../lib/demo-sync-vanilla";
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function shapesByType(shapes: Record<string, DemoShape>, type: string): DemoShape[] {
|
||||
return Object.values(shapes).filter((s) => s.type === type);
|
||||
}
|
||||
|
||||
function shapeByType(shapes: Record<string, DemoShape>, type: string): DemoShape | undefined {
|
||||
return Object.values(shapes).find((s) => s.type === type);
|
||||
}
|
||||
|
||||
function $(id: string): HTMLElement | null {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
// ── Simple markdown renderer ──
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
if (!text) return "";
|
||||
const lines = text.split("\n");
|
||||
const out: string[] = [];
|
||||
let inCodeBlock = false;
|
||||
let codeLang = "";
|
||||
let codeLines: string[] = [];
|
||||
let inList: "ul" | "ol" | null = null;
|
||||
|
||||
function flushList() {
|
||||
if (inList) { out.push(inList === "ul" ? "</ul>" : "</ol>"); inList = null; }
|
||||
}
|
||||
|
||||
function flushCode() {
|
||||
if (inCodeBlock) {
|
||||
const escaped = codeLines.join("\n").replace(/</g, "<").replace(/>/g, ">");
|
||||
out.push(`<div class="rd-md-codeblock">${codeLang ? `<div class="rd-md-codeblock-lang"><span>${codeLang}</span></div>` : ""}<pre>${escaped}</pre></div>`);
|
||||
inCodeBlock = false;
|
||||
codeLines = [];
|
||||
codeLang = "";
|
||||
}
|
||||
}
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw;
|
||||
|
||||
// Code fence
|
||||
if (line.startsWith("```")) {
|
||||
if (inCodeBlock) { flushCode(); } else { flushList(); inCodeBlock = true; codeLang = line.slice(3).trim(); }
|
||||
continue;
|
||||
}
|
||||
if (inCodeBlock) { codeLines.push(line); continue; }
|
||||
|
||||
// Blank line
|
||||
if (!line.trim()) { flushList(); continue; }
|
||||
|
||||
// Headings
|
||||
if (line.startsWith("### ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(4))}</h3>`); continue; }
|
||||
if (line.startsWith("## ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(3))}</h3>`); continue; }
|
||||
if (line.startsWith("# ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(2))}</h3>`); continue; }
|
||||
if (line.startsWith("#### ")) { flushList(); out.push(`<h4>${inlineFormat(line.slice(5))}</h4>`); continue; }
|
||||
if (line.startsWith("##### ")) { flushList(); out.push(`<h5>${inlineFormat(line.slice(6))}</h5>`); continue; }
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith("> ")) { flushList(); out.push(`<div class="rd-md-quote"><p>${inlineFormat(line.slice(2))}</p></div>`); continue; }
|
||||
|
||||
// Unordered list
|
||||
const ulMatch = line.match(/^[-*]\s+(.+)/);
|
||||
if (ulMatch) {
|
||||
if (inList !== "ul") { flushList(); out.push("<ul>"); inList = "ul"; }
|
||||
out.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
const olMatch = line.match(/^(\d+)\.\s+(.+)/);
|
||||
if (olMatch) {
|
||||
if (inList !== "ol") { flushList(); out.push("<ol>"); inList = "ol"; }
|
||||
out.push(`<li><span class="rd-md-num">${olMatch[1]}.</span>${inlineFormat(olMatch[2])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph
|
||||
flushList();
|
||||
out.push(`<p>${inlineFormat(line)}</p>`);
|
||||
}
|
||||
|
||||
flushCode();
|
||||
flushList();
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function inlineFormat(text: string): string {
|
||||
return text
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
}
|
||||
|
||||
// ── Note card rendering ──
|
||||
|
||||
const TAG_COLORS: Record<string, string> = {
|
||||
planning: "rgba(245,158,11,0.15)",
|
||||
travel: "rgba(20,184,166,0.15)",
|
||||
food: "rgba(251,146,60,0.15)",
|
||||
gear: "rgba(168,85,247,0.15)",
|
||||
safety: "rgba(239,68,68,0.15)",
|
||||
accommodation: "rgba(59,130,246,0.15)",
|
||||
};
|
||||
|
||||
function renderNoteCard(note: DemoShape, expanded: boolean): string {
|
||||
const title = (note.title as string) || "Untitled";
|
||||
const content = (note.content as string) || "";
|
||||
const tags = (note.tags as string[]) || [];
|
||||
const lastEdited = note.lastEdited as string;
|
||||
const synced = note.synced !== false;
|
||||
|
||||
const preview = content.split("\n").slice(0, 3).join(" ").slice(0, 120);
|
||||
const previewText = preview.replace(/[#*>`\-]/g, "").trim();
|
||||
|
||||
return `
|
||||
<div class="rd-card rd-note-card ${expanded ? "rd-note-card--expanded" : ""}" data-note-id="${note.id}" style="cursor:pointer;">
|
||||
<div style="padding:1rem 1.25rem;">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:0.5rem;">
|
||||
<h3 style="font-size:0.9375rem;font-weight:600;color:white;margin:0;">${escHtml(title)}</h3>
|
||||
${synced ? `<span class="rd-synced-badge"><span style="width:6px;height:6px;border-radius:50%;background:#2dd4bf;"></span>synced</span>` : ""}
|
||||
</div>
|
||||
${expanded
|
||||
? `<div class="rd-md" style="margin-top:0.75rem;">${renderMarkdown(content)}</div>`
|
||||
: `<p style="font-size:0.8125rem;color:#94a3b8;margin:0 0 0.75rem;line-height:1.5;">${escHtml(previewText)}${content.length > 120 ? "..." : ""}</p>`
|
||||
}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:0.75rem;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.375rem;">
|
||||
${tags.map((t) => `<span class="rd-note-tag" style="background:${TAG_COLORS[t] || "rgba(51,65,85,0.5)"}">${escHtml(t)}</span>`).join("")}
|
||||
</div>
|
||||
${lastEdited ? `<span style="font-size:0.6875rem;color:#64748b;">${formatRelative(lastEdited)}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diff = now - d.getTime();
|
||||
if (diff < 60_000) return "just now";
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`;
|
||||
return d.toLocaleDateString("en", { month: "short", day: "numeric" });
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
// ── Packing list rendering ──
|
||||
|
||||
function renderPackingList(packingList: DemoShape): string {
|
||||
const items = (packingList.items as Array<{ name: string; packed: boolean; category: string }>) || [];
|
||||
if (items.length === 0) return "";
|
||||
|
||||
// Group by category
|
||||
const groups: Record<string, typeof items> = {};
|
||||
for (const item of items) {
|
||||
const cat = item.category || "General";
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(item);
|
||||
}
|
||||
|
||||
const checked = items.filter((i) => i.packed).length;
|
||||
const pct = Math.round((checked / items.length) * 100);
|
||||
|
||||
let html = `
|
||||
<div class="rd-card" style="overflow:hidden;">
|
||||
<div style="padding:0.75rem 1rem;border-bottom:1px solid rgba(51,65,85,0.5);display:flex;align-items:center;justify-content:space-between;">
|
||||
<span style="font-size:0.8125rem;font-weight:600;color:white;">Packing Checklist</span>
|
||||
<span style="font-size:0.75rem;color:#94a3b8;">${checked}/${items.length} packed (${pct}%)</span>
|
||||
</div>
|
||||
<div style="padding:0.75rem 1rem 0.25rem;">
|
||||
<div style="height:0.375rem;background:rgba(51,65,85,0.5);border-radius:9999px;overflow:hidden;margin-bottom:0.75rem;">
|
||||
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#f59e0b,#fb923c);border-radius:9999px;transition:width 0.3s;"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
for (const [cat, catItems] of Object.entries(groups)) {
|
||||
html += `<div style="padding:0 0.75rem 0.75rem;">
|
||||
<h4 style="font-size:0.6875rem;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:0.05em;margin:0.5rem 0 0.25rem;">${escHtml(cat)}</h4>`;
|
||||
for (let i = 0; i < catItems.length; i++) {
|
||||
const item = catItems[i];
|
||||
const globalIdx = items.indexOf(item);
|
||||
html += `
|
||||
<div class="rd-pack-item" data-pack-idx="${globalIdx}">
|
||||
<div class="rd-pack-check ${item.packed ? "rd-pack-check--checked" : ""}">
|
||||
${item.packed ? `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg>` : ""}
|
||||
</div>
|
||||
<span style="font-size:0.8125rem;${item.packed ? "color:#64748b;text-decoration:line-through;" : "color:#e2e8f0;"}">${escHtml(item.name)}</span>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Avatars ──
|
||||
|
||||
const AVATAR_COLORS = ["#14b8a6", "#06b6d4", "#8b5cf6", "#f59e0b", "#f43f5e"];
|
||||
|
||||
function renderAvatars(members: string[]): string {
|
||||
if (!members.length) return "";
|
||||
return members.map((name, i) =>
|
||||
`<div class="rd-avatar" style="background:${AVATAR_COLORS[i % AVATAR_COLORS.length]}" title="${escHtml(name)}">${name[0]}</div>`
|
||||
).join("") + `<span style="font-size:0.75rem;color:#94a3b8;margin-left:0.25rem;">${members.length} collaborators</span>`;
|
||||
}
|
||||
|
||||
// ── Main ──
|
||||
|
||||
const expandedNotes = new Set<string>();
|
||||
|
||||
const sync = new DemoSync({ filter: ["folk-notebook", "folk-note", "folk-packing-list"] });
|
||||
|
||||
function render(shapes: Record<string, DemoShape>) {
|
||||
const notebook = shapeByType(shapes, "folk-notebook");
|
||||
const notes = shapesByType(shapes, "folk-note").sort((a, b) => {
|
||||
const aTime = a.lastEdited ? new Date(a.lastEdited as string).getTime() : 0;
|
||||
const bTime = b.lastEdited ? new Date(b.lastEdited as string).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
const packingList = shapeByType(shapes, "folk-packing-list");
|
||||
|
||||
// Hide loading skeleton
|
||||
const loading = $("rd-loading");
|
||||
if (loading) loading.style.display = "none";
|
||||
|
||||
// Notebook header
|
||||
if (notebook) {
|
||||
const nbTitle = $("rd-nb-title");
|
||||
const nbCount = $("rd-nb-count");
|
||||
const nbDesc = $("rd-nb-desc");
|
||||
const sbTitle = $("rd-sb-nb-title");
|
||||
const sbCount = $("rd-sb-note-count");
|
||||
const sbNum = $("rd-sb-notes-num");
|
||||
|
||||
if (nbTitle) nbTitle.textContent = (notebook.name as string) || "Trip Notebook";
|
||||
if (nbCount) nbCount.textContent = `${notes.length} notes`;
|
||||
if (nbDesc) nbDesc.textContent = (notebook.description as string) || "";
|
||||
if (sbTitle) sbTitle.textContent = (notebook.name as string) || "Trip Notebook";
|
||||
if (sbCount) sbCount.textContent = `${notes.length} note${notes.length !== 1 ? "s" : ""}`;
|
||||
if (sbNum) sbNum.textContent = String(notes.length);
|
||||
}
|
||||
|
||||
// Notes count
|
||||
const notesCount = $("rd-notes-count");
|
||||
if (notesCount) notesCount.textContent = `${notes.length} note${notes.length !== 1 ? "s" : ""}`;
|
||||
|
||||
// Notes container
|
||||
const container = $("rd-notes-container");
|
||||
const empty = $("rd-notes-empty");
|
||||
if (container) {
|
||||
if (notes.length === 0) {
|
||||
container.innerHTML = "";
|
||||
if (empty) empty.style.display = "block";
|
||||
} else {
|
||||
if (empty) empty.style.display = "none";
|
||||
container.innerHTML = notes.map((n) => renderNoteCard(n, expandedNotes.has(n.id))).join("");
|
||||
}
|
||||
}
|
||||
|
||||
// Packing list
|
||||
const packSection = $("rd-packing-section");
|
||||
const packContainer = $("rd-packing-container");
|
||||
if (packingList && packSection && packContainer) {
|
||||
packSection.style.display = "block";
|
||||
packContainer.innerHTML = renderPackingList(packingList);
|
||||
}
|
||||
|
||||
// Avatars — extract from notebook members or note authors
|
||||
const members = (notebook?.members as string[]) || [];
|
||||
const avatarsEl = $("rd-avatars");
|
||||
if (avatarsEl && members.length > 0) {
|
||||
avatarsEl.innerHTML = renderAvatars(members);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event listeners ──
|
||||
|
||||
sync.addEventListener("snapshot", ((e: CustomEvent) => {
|
||||
render(e.detail.shapes);
|
||||
}) as EventListener);
|
||||
|
||||
sync.addEventListener("connected", () => {
|
||||
const dot = $("rd-hero-dot");
|
||||
const label = $("rd-hero-label");
|
||||
if (dot) dot.style.background = "#10b981";
|
||||
if (label) label.textContent = "Live — Connected to rSpace";
|
||||
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
|
||||
if (resetBtn) resetBtn.disabled = false;
|
||||
});
|
||||
|
||||
sync.addEventListener("disconnected", () => {
|
||||
const dot = $("rd-hero-dot");
|
||||
const label = $("rd-hero-label");
|
||||
if (dot) dot.style.background = "#64748b";
|
||||
if (label) label.textContent = "Reconnecting...";
|
||||
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
|
||||
if (resetBtn) resetBtn.disabled = true;
|
||||
});
|
||||
|
||||
// ── Event delegation ──
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Note card expand/collapse
|
||||
const noteCard = target.closest<HTMLElement>("[data-note-id]");
|
||||
if (noteCard) {
|
||||
const id = noteCard.dataset.noteId!;
|
||||
if (expandedNotes.has(id)) expandedNotes.delete(id);
|
||||
else expandedNotes.add(id);
|
||||
render(sync.shapes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Packing checkbox toggle
|
||||
const packItem = target.closest<HTMLElement>("[data-pack-idx]");
|
||||
if (packItem) {
|
||||
const idx = parseInt(packItem.dataset.packIdx!, 10);
|
||||
const packingList = shapeByType(sync.shapes, "folk-packing-list");
|
||||
if (packingList) {
|
||||
const items = [...(packingList.items as Array<{ name: string; packed: boolean; category: string }>)];
|
||||
items[idx] = { ...items[idx], packed: !items[idx].packed };
|
||||
sync.updateShape(packingList.id, { items });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset button
|
||||
if (target.closest("#rd-reset-btn")) {
|
||||
sync.resetDemo().catch((err) => console.error("[Notes] Reset failed:", err));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Start ──
|
||||
|
||||
sync.connect();
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
/**
|
||||
* rNotes demo page — server-rendered HTML body.
|
||||
*
|
||||
* Returns the static HTML skeleton for the interactive notes demo.
|
||||
* The client-side notes-demo.ts populates note cards, packing list,
|
||||
* sidebar, and notebook header via WebSocket snapshots.
|
||||
*/
|
||||
|
||||
export function renderDemo(): string {
|
||||
return `
|
||||
<div class="rd-root" style="--rd-accent-from:#f59e0b; --rd-accent-to:#fb923c">
|
||||
|
||||
<!-- ── Hero ── -->
|
||||
<section class="rd-hero">
|
||||
<div style="display:inline-flex;align-items:center;gap:0.5rem;padding:0.375rem 1rem;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.2);border-radius:9999px;font-size:0.875rem;color:#fbbf24;font-weight:500;margin-bottom:1.5rem;">
|
||||
<span id="rd-hero-dot" style="width:0.5rem;height:0.5rem;border-radius:9999px;background:#f59e0b;animation:rd-pulse 2s ease-in-out infinite;"></span>
|
||||
<span id="rd-hero-label">Interactive Demo</span>
|
||||
</div>
|
||||
<h1>See how rNotes works</h1>
|
||||
<p class="rd-subtitle">A collaborative knowledge base for your team</p>
|
||||
<div class="rd-meta">
|
||||
<span>Live transcription</span>
|
||||
<span>Audio & video</span>
|
||||
<span>Organized notebooks</span>
|
||||
<span>Canvas sync</span>
|
||||
<span>Real-time collaboration</span>
|
||||
</div>
|
||||
<div class="rd-avatars" id="rd-avatars">
|
||||
<div class="rd-avatar" style="background:#14b8a6" title="...">...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Context bar + Reset ── -->
|
||||
<section class="rd-section rd-section--narrow">
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:1rem;margin-bottom:1.5rem;">
|
||||
<p style="text-align:center;font-size:0.875rem;color:#94a3b8;max-width:40rem;margin:0;">
|
||||
This demo shows a <span style="color:#e2e8f0;font-weight:500">Trip Planning Notebook</span> scenario
|
||||
with notes, a packing list, tags, and canvas sync — all powered by the
|
||||
<span style="color:#e2e8f0;font-weight:500">r* ecosystem</span> with live data from
|
||||
<span style="color:#e2e8f0;font-weight:500">rSpace</span>.
|
||||
</p>
|
||||
<button id="rd-reset-btn" class="rd-btn rd-btn--ghost" disabled>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||
Reset Demo
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Notebook header card ── -->
|
||||
<section class="rd-section rd-section--narrow">
|
||||
<div class="rd-card" id="rd-notebook-header" style="margin-bottom:1.5rem;">
|
||||
<div class="rd-card-header">
|
||||
<div class="rd-card-title">
|
||||
<span class="rd-icon" style="font-size:1.25rem;">📓</span>
|
||||
<span id="rd-nb-title">Loading...</span>
|
||||
<span id="rd-nb-count" class="rd-text-xs rd-text-muted" style="margin-left:0.5rem;"></span>
|
||||
</div>
|
||||
<a href="https://rnotes.online" target="_blank" rel="noopener noreferrer" class="rd-card-header rd-open-link">Open in rNotes</a>
|
||||
</div>
|
||||
<div class="rd-card-body" style="padding:0.75rem 1.25rem;">
|
||||
<p id="rd-nb-desc" class="rd-text-sm rd-text-muted" style="margin:0;">Loading notebook data...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Main layout: sidebar + content ── -->
|
||||
<div style="display:grid;grid-template-columns:1fr;gap:1.5rem;" class="rd-notes-layout">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div id="rd-sidebar">
|
||||
<div class="rd-card" style="overflow:hidden;">
|
||||
<!-- Sidebar header -->
|
||||
<div style="padding:0.75rem 1rem;border-bottom:1px solid rgba(51,65,85,0.5);display:flex;align-items:center;justify-content:space-between;">
|
||||
<span style="font-size:0.75rem;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:0.05em;">Notebook</span>
|
||||
<span id="rd-sb-note-count" style="font-size:0.75rem;color:#64748b;">0 notes</span>
|
||||
</div>
|
||||
<!-- Active notebook tree -->
|
||||
<div style="padding:0.5rem;">
|
||||
<div style="margin-bottom:0.25rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;border-radius:0.5rem;font-size:0.875rem;background:rgba(245,158,11,0.1);color:#fcd34d;">
|
||||
<span>📓</span>
|
||||
<span id="rd-sb-nb-title" style="font-weight:500;">Loading...</span>
|
||||
</div>
|
||||
<div style="margin-left:1rem;margin-top:0.125rem;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:0.375rem 0.75rem;border-radius:0.375rem;font-size:0.75rem;background:rgba(51,65,85,0.4);color:white;">
|
||||
<span>Notes</span>
|
||||
<span id="rd-sb-notes-num" style="color:#475569">0</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:0.375rem 0.75rem;border-radius:0.375rem;font-size:0.75rem;color:#64748b;margin-top:0.125rem;cursor:pointer;" onmouseover="this.style.background='rgba(51,65,85,0.2)';this.style.color='#cbd5e1'" onmouseout="this.style.background='';this.style.color='#64748b'">
|
||||
<span>Packing List</span>
|
||||
<span style="color:#475569">1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quick info links -->
|
||||
<div style="padding:0.75rem 1rem;border-top:1px solid rgba(51,65,85,0.5);display:flex;flex-direction:column;gap:0.5rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;color:#64748b;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<span>Search notes...</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;color:#64748b;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
|
||||
<span>Browse tags</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;color:#64748b;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<span>Recent edits</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes + Packing list -->
|
||||
<div>
|
||||
<!-- Notes section -->
|
||||
<div id="rd-notes-section">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<h2 style="font-size:0.875rem;font-weight:600;color:#cbd5e1;margin:0;">Notes</h2>
|
||||
<span id="rd-notes-count" class="rd-text-xs rd-text-muted">0 notes</span>
|
||||
</div>
|
||||
<span class="rd-text-xs rd-text-muted">Sort: Recently edited</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<div id="rd-loading" style="display:flex;flex-direction:column;gap:1rem;">
|
||||
${[1, 2, 3]
|
||||
.map(
|
||||
() => `
|
||||
<div class="rd-card" style="padding:1rem;">
|
||||
<div style="height:1rem;background:rgba(51,65,85,0.5);border-radius:0.25rem;width:66%;margin-bottom:0.75rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
|
||||
<div style="height:0.75rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:100%;margin-bottom:0.5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
|
||||
<div style="height:0.75rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:80%;margin-bottom:0.75rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<div style="height:1.25rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:4rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
|
||||
<div style="height:1.25rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
|
||||
</div>
|
||||
</div>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
<style>
|
||||
@keyframes rd-skeleton-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
</style>
|
||||
|
||||
<!-- Note cards container (populated by notes-demo.ts) -->
|
||||
<div id="rd-notes-container" style="display:flex;flex-direction:column;gap:1rem;"></div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div id="rd-notes-empty" class="rd-card" style="display:none;padding:2rem;text-align:center;">
|
||||
<p class="rd-text-muted rd-text-sm">No notes found. Try resetting the demo.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Packing list section -->
|
||||
<div id="rd-packing-section" style="margin-top:1.5rem;display:none;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem;">
|
||||
<h2 style="font-size:0.875rem;font-weight:600;color:#cbd5e1;margin:0;">Packing List</h2>
|
||||
</div>
|
||||
<div id="rd-packing-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Features showcase ── -->
|
||||
<section class="rd-section" style="margin-top:2rem;">
|
||||
<h2 style="font-size:1.5rem;font-weight:700;color:white;text-align:center;margin-bottom:2rem;">Everything you need to capture knowledge</h2>
|
||||
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:1rem;" class="rd-features-grid">
|
||||
${[
|
||||
{
|
||||
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`,
|
||||
title: "Live Transcription",
|
||||
desc: "Record and transcribe in real time. Stream audio via WebSocket or transcribe offline with Parakeet.js.",
|
||||
},
|
||||
{
|
||||
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
|
||||
title: "Rich Editing",
|
||||
desc: "Headings, lists, code blocks, highlights, images, and file attachments in every note.",
|
||||
},
|
||||
{
|
||||
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>`,
|
||||
title: "Notebooks",
|
||||
desc: "Organize notes into notebooks with sections. Nest as deep as you need.",
|
||||
},
|
||||
{
|
||||
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>`,
|
||||
title: "Flexible Tags",
|
||||
desc: "Cross-cutting tags let you find notes across all notebooks instantly.",
|
||||
},
|
||||
{
|
||||
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
|
||||
title: "Canvas Sync",
|
||||
desc: "Pin any note to your rSpace canvas for visual collaboration with your team.",
|
||||
},
|
||||
]
|
||||
.map(
|
||||
(f) => `
|
||||
<div class="rd-card" style="padding:1.25rem;">
|
||||
<div style="width:2.5rem;height:2.5rem;background:rgba(245,158,11,0.1);border-radius:0.5rem;display:flex;align-items:center;justify-content:center;margin-bottom:0.75rem;">
|
||||
${f.icon}
|
||||
</div>
|
||||
<h3 style="font-size:0.875rem;font-weight:600;color:white;margin:0 0 0.25rem;">${f.title}</h3>
|
||||
<p style="font-size:0.75rem;color:#94a3b8;margin:0;line-height:1.5;">${f.desc}</p>
|
||||
</div>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── CTA ── -->
|
||||
<section class="rd-section rd-section--narrow">
|
||||
<div class="rd-cta">
|
||||
<h2>Ready to capture everything?</h2>
|
||||
<p>
|
||||
rNotes gives your team a shared knowledge base with rich editing, flexible organization,
|
||||
and deep integration with the r* ecosystem — all on a collaborative canvas.
|
||||
</p>
|
||||
<a href="/create-space" style="background:linear-gradient(135deg,#f59e0b,#f97316);box-shadow:0 8px 24px rgba(245,158,11,0.25);">
|
||||
Start Taking Notes
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Notes-specific layout ── */
|
||||
@media (min-width: 1024px) {
|
||||
.rd-notes-layout {
|
||||
grid-template-columns: 16rem 1fr !important;
|
||||
}
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.rd-features-grid {
|
||||
grid-template-columns: repeat(3, 1fr) !important;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.rd-features-grid {
|
||||
grid-template-columns: repeat(5, 1fr) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Note card styles */
|
||||
.rd-note-card {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.rd-note-card:hover {
|
||||
border-color: rgba(100,116,139,0.6);
|
||||
}
|
||||
.rd-note-card--expanded {
|
||||
cursor: default;
|
||||
border-color: rgba(245,158,11,0.3) !important;
|
||||
box-shadow: 0 0 0 1px rgba(245,158,11,0.15);
|
||||
}
|
||||
|
||||
/* Synced badge */
|
||||
.rd-synced-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: rgba(20,184,166,0.1);
|
||||
border: 1px solid rgba(20,184,166,0.2);
|
||||
color: #2dd4bf;
|
||||
border-radius: 9999px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Markdown rendered content */
|
||||
.rd-md h3 { font-size: 1.125rem; font-weight: 700; color: white; margin: 1rem 0 0.5rem; }
|
||||
.rd-md h4 { font-size: 1rem; font-weight: 600; color: #e2e8f0; margin: 1rem 0 0.5rem; }
|
||||
.rd-md h5 { font-size: 0.875rem; font-weight: 600; color: #cbd5e1; margin: 0.75rem 0 0.25rem; }
|
||||
.rd-md p { font-size: 0.875rem; color: #cbd5e1; margin: 0.25rem 0; line-height: 1.6; }
|
||||
.rd-md strong { color: white; font-weight: 500; }
|
||||
.rd-md em { color: #cbd5e1; font-style: italic; }
|
||||
.rd-md code {
|
||||
color: #fcd34d;
|
||||
background: rgba(30,41,59,1);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
.rd-md .rd-md-quote {
|
||||
background: rgba(245,158,11,0.1);
|
||||
border-left: 2px solid #f59e0b;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.rd-md .rd-md-quote p { color: #fcd34d; }
|
||||
.rd-md ul, .rd-md ol { margin: 0.5rem 0; padding: 0; list-style: none; }
|
||||
.rd-md ul li, .rd-md ol li {
|
||||
display: flex; align-items: flex-start; gap: 0.5rem;
|
||||
font-size: 0.875rem; color: #cbd5e1; padding: 0.125rem 0;
|
||||
}
|
||||
.rd-md ul li::before { content: "\\2022"; color: #f59e0b; margin-top: 0.1rem; flex-shrink: 0; }
|
||||
.rd-md ol li .rd-md-num { color: #f59e0b; font-weight: 500; min-width: 1.2em; text-align: right; flex-shrink: 0; }
|
||||
.rd-md .rd-md-codeblock {
|
||||
background: rgba(2,6,23,1);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(51,65,85,0.5);
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.rd-md .rd-md-codeblock-lang {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(30,41,59,0.5);
|
||||
border-bottom: 1px solid rgba(51,65,85,0.5);
|
||||
font-size: 0.75rem; color: #94a3b8; font-family: monospace;
|
||||
}
|
||||
.rd-md .rd-md-codeblock pre {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.75rem; color: #cbd5e1;
|
||||
font-family: monospace;
|
||||
overflow-x: auto;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Tag pill */
|
||||
.rd-note-tag {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(51,65,85,0.5);
|
||||
color: #94a3b8;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(51,65,85,0.3);
|
||||
}
|
||||
|
||||
/* Packing list checkbox */
|
||||
.rd-pack-check {
|
||||
width: 1.25rem; height: 1.25rem; border-radius: 0.25rem; flex-shrink: 0;
|
||||
border: 2px solid #475569;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.rd-pack-check--checked {
|
||||
background: #f59e0b; border-color: #f59e0b;
|
||||
}
|
||||
.rd-pack-check:hover { border-color: #64748b; }
|
||||
|
||||
/* Packing item label */
|
||||
.rd-pack-item {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.rd-pack-item:hover { background: rgba(51,65,85,0.3); }
|
||||
</style>`;
|
||||
}
|
||||
|
|
@ -9,11 +9,12 @@ import { Hono } from "hono";
|
|||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { renderShell, renderDemoShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
import { renderLanding } from "./landing";
|
||||
import { renderDemo } from "./demo";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -362,6 +363,19 @@ routes.delete("/api/notes/:id", async (c) => {
|
|||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
if (space === "demo") {
|
||||
return c.html(renderDemoShell({
|
||||
title: "rNotes Demo — rSpace",
|
||||
moduleId: "rnotes",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: renderDemo(),
|
||||
demoScripts: `<script type="module" src="/lib/demo-sync.js"></script>
|
||||
<script type="module" src="/modules/rnotes/notes-demo.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css">`,
|
||||
}));
|
||||
}
|
||||
return c.html(renderShell({
|
||||
title: `${space} — Notes | rSpace`,
|
||||
moduleId: "rnotes",
|
||||
|
|
@ -369,8 +383,8 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
|
||||
scripts: `<script type="module" src="/modules/notes/folk-notes-app.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/notes/notes.css">`,
|
||||
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -381,6 +395,7 @@ export const notesModule: RSpaceModule = {
|
|||
description: "Notebooks with rich-text notes, voice transcription, and collaboration",
|
||||
routes,
|
||||
landingPage: renderLanding,
|
||||
demoPage: renderDemo,
|
||||
standaloneDomain: "rnotes.online",
|
||||
feeds: [
|
||||
{
|
||||
|
|
@ -51,9 +51,66 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") {
|
||||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
this.loadGallery();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.albums = [
|
||||
{ id: "demo-album-1", albumName: "Community Gathering", description: "Photos from our community events", assetCount: 12, albumThumbnailAssetId: null, updatedAt: "2026-02-15T10:00:00Z", shared: true },
|
||||
{ id: "demo-album-2", albumName: "Workshop Series", description: "Hands-on learning sessions", assetCount: 8, albumThumbnailAssetId: null, updatedAt: "2026-02-10T14:30:00Z", shared: true },
|
||||
{ id: "demo-album-3", albumName: "Nature Walks", description: "Exploring local ecosystems", assetCount: 15, albumThumbnailAssetId: null, updatedAt: "2026-02-20T09:15:00Z", shared: true },
|
||||
];
|
||||
this.assets = [
|
||||
{ id: "demo-asset-1", type: "IMAGE", originalFileName: "sunrise-over-commons.jpg", fileCreatedAt: "2026-02-25T06:30:00Z", exifInfo: { city: "Portland", country: "USA", make: "Fujifilm", model: "X-T5" } },
|
||||
{ id: "demo-asset-2", type: "IMAGE", originalFileName: "workshop-group-photo.jpg", fileCreatedAt: "2026-02-24T15:00:00Z", exifInfo: { city: "Portland", country: "USA" } },
|
||||
{ id: "demo-asset-3", type: "IMAGE", originalFileName: "mycelium-closeup.jpg", fileCreatedAt: "2026-02-23T11:20:00Z", exifInfo: { make: "Canon", model: "EOS R5" } },
|
||||
{ id: "demo-asset-4", type: "IMAGE", originalFileName: "community-garden.jpg", fileCreatedAt: "2026-02-22T09:45:00Z", exifInfo: { city: "Seattle", country: "USA" } },
|
||||
{ id: "demo-asset-5", type: "IMAGE", originalFileName: "maker-space-tools.jpg", fileCreatedAt: "2026-02-21T14:10:00Z", exifInfo: {} },
|
||||
{ id: "demo-asset-6", type: "IMAGE", originalFileName: "sunset-gathering.jpg", fileCreatedAt: "2026-02-20T18:30:00Z", exifInfo: { city: "Vancouver", country: "Canada", make: "Sony", model: "A7IV" } },
|
||||
{ id: "demo-asset-7", type: "IMAGE", originalFileName: "seed-library.jpg", fileCreatedAt: "2026-02-19T10:00:00Z", exifInfo: {} },
|
||||
{ id: "demo-asset-8", type: "IMAGE", originalFileName: "potluck-spread.jpg", fileCreatedAt: "2026-02-18T12:00:00Z", exifInfo: { city: "Portland", country: "USA" } },
|
||||
];
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getDemoAssetMeta(id: string): { width: number; height: number; color: string } {
|
||||
const meta: Record<string, { width: number; height: number; color: string }> = {
|
||||
"demo-asset-1": { width: 4000, height: 2667, color: "#f59e0b" },
|
||||
"demo-asset-2": { width: 3200, height: 2400, color: "#6366f1" },
|
||||
"demo-asset-3": { width: 2400, height: 2400, color: "#22c55e" },
|
||||
"demo-asset-4": { width: 3600, height: 2400, color: "#10b981" },
|
||||
"demo-asset-5": { width: 2800, height: 1867, color: "#8b5cf6" },
|
||||
"demo-asset-6": { width: 4000, height: 2667, color: "#ef4444" },
|
||||
"demo-asset-7": { width: 2000, height: 2000, color: "#14b8a6" },
|
||||
"demo-asset-8": { width: 3200, height: 2133, color: "#f97316" },
|
||||
};
|
||||
return meta[id] || { width: 2000, height: 2000, color: "#64748b" };
|
||||
}
|
||||
|
||||
private getDemoAlbumColor(id: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
"demo-album-1": "#6366f1",
|
||||
"demo-album-2": "#22c55e",
|
||||
"demo-album-3": "#f59e0b",
|
||||
};
|
||||
return colors[id] || "#64748b";
|
||||
}
|
||||
|
||||
private getDemoAlbumAssets(albumId: string): Asset[] {
|
||||
if (albumId === "demo-album-1") return this.assets.slice(0, 6);
|
||||
if (albumId === "demo-album-2") return this.assets.slice(2, 6);
|
||||
if (albumId === "demo-album-3") return this.assets.slice(0, 8);
|
||||
return [];
|
||||
}
|
||||
|
||||
private isDemo(): boolean {
|
||||
return this.space === "demo";
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/photos/);
|
||||
|
|
@ -89,6 +146,13 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
private async loadAlbum(album: Album) {
|
||||
this.selectedAlbum = album;
|
||||
this.view = "album";
|
||||
|
||||
if (this.isDemo()) {
|
||||
this.albumAssets = this.getDemoAlbumAssets(album.id);
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.render();
|
||||
|
||||
|
|
@ -249,6 +313,27 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
|
||||
.loading { text-align: center; color: #64748b; padding: 3rem; }
|
||||
.error { text-align: center; color: #f87171; padding: 1.5rem; background: rgba(248,113,113,0.08); border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
|
||||
|
||||
.demo-thumb {
|
||||
width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.85);
|
||||
text-align: center; padding: 8px;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
word-break: break-word; line-height: 1.3;
|
||||
}
|
||||
.demo-lightbox-img {
|
||||
width: 80vw; max-width: 900px; aspect-ratio: 3/2;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 8px;
|
||||
font-size: 20px; font-weight: 600; color: rgba(255,255,255,0.9);
|
||||
text-shadow: 0 2px 6px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.albums-grid { grid-template-columns: 1fr; }
|
||||
.photo-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
</style>
|
||||
|
||||
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
|
||||
|
|
@ -296,9 +381,11 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
${this.albums.map((a) => `
|
||||
<div class="album-card" data-album-id="${a.id}">
|
||||
<div class="album-thumb">
|
||||
${a.albumThumbnailAssetId
|
||||
? `<img src="${this.thumbUrl(a.albumThumbnailAssetId)}" alt="${this.esc(a.albumName)}" loading="lazy">`
|
||||
: '<span class="album-thumb-empty">📷</span>'}
|
||||
${this.isDemo()
|
||||
? `<div class="demo-thumb" style="background:${this.getDemoAlbumColor(a.id)}">${this.esc(a.albumName)}</div>`
|
||||
: a.albumThumbnailAssetId
|
||||
? `<img src="${this.thumbUrl(a.albumThumbnailAssetId)}" alt="${this.esc(a.albumName)}" loading="lazy">`
|
||||
: '<span class="album-thumb-empty">📷</span>'}
|
||||
</div>
|
||||
<div class="album-info">
|
||||
<div class="album-name">${this.esc(a.albumName)}</div>
|
||||
|
|
@ -315,7 +402,9 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
<div class="photo-grid">
|
||||
${this.assets.map((a) => `
|
||||
<div class="photo-cell" data-asset-id="${a.id}">
|
||||
<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">
|
||||
${this.isDemo()
|
||||
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
|
||||
: `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`}
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
|
|
@ -346,7 +435,9 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
<div class="photo-grid">
|
||||
${this.albumAssets.map((a) => `
|
||||
<div class="photo-cell" data-asset-id="${a.id}">
|
||||
<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">
|
||||
${this.isDemo()
|
||||
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
|
||||
: `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`}
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
|
|
@ -360,12 +451,17 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
const location = [info?.city, info?.country].filter(Boolean).join(", ");
|
||||
const camera = [info?.make, info?.model].filter(Boolean).join(" ");
|
||||
|
||||
const demoMeta = this.isDemo() ? this.getDemoAssetMeta(asset.id) : null;
|
||||
const displayName = asset.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " ");
|
||||
|
||||
return `
|
||||
<div class="lightbox" data-lightbox>
|
||||
<button class="lightbox-close" data-close-lightbox>✕</button>
|
||||
<img src="${this.originalUrl(asset.id)}" alt="${this.esc(asset.originalFileName)}">
|
||||
${demoMeta
|
||||
? `<div class="demo-lightbox-img" style="background:${demoMeta.color}">${this.esc(displayName)}</div>`
|
||||
: `<img src="${this.originalUrl(asset.id)}" alt="${this.esc(asset.originalFileName)}">`}
|
||||
<div class="lightbox-info">
|
||||
${asset.originalFileName}
|
||||
${asset.originalFileName}${demoMeta ? ` · ${demoMeta.width}x${demoMeta.height}` : ""}
|
||||
${location ? ` · ${this.esc(location)}` : ""}
|
||||
${camera ? ` · ${this.esc(camera)}` : ""}
|
||||
· ${this.formatDate(asset.fileCreatedAt)}
|
||||
|
|
@ -116,8 +116,8 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-photo-gallery space="${spaceSlug}"></folk-photo-gallery>`,
|
||||
scripts: `<script type="module" src="/modules/photos/folk-photo-gallery.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/photos/photos.css">`,
|
||||
scripts: `<script type="module" src="/modules/rphotos/folk-photo-gallery.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rphotos/photos.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -67,6 +67,22 @@ export class FolkPubsEditor extends HTMLElement {
|
|||
connectedCallback() {
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.render();
|
||||
const space = this.getAttribute("space") || "";
|
||||
if (space === "demo") {
|
||||
this.loadDemoContent();
|
||||
}
|
||||
}
|
||||
|
||||
private loadDemoContent() {
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.shadowRoot) return;
|
||||
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
|
||||
const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement;
|
||||
const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement;
|
||||
if (textarea) textarea.value = SAMPLE_CONTENT;
|
||||
if (titleInput) titleInput.value = "The Commons";
|
||||
if (authorInput) authorInput.value = "rSpace Community";
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
@ -465,14 +481,27 @@ export class FolkPubsEditor extends HTMLElement {
|
|||
.format-detail .pages { color: #60a5fa; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editor-layout { flex-direction: column; }
|
||||
:host { height: auto; min-height: calc(100vh - 92px); }
|
||||
.editor-layout { flex-direction: column; height: auto; }
|
||||
.editor-main { min-height: 0; }
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid #1e293b;
|
||||
max-height: 50vh;
|
||||
max-height: none;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.content-area { min-height: 40vh; }
|
||||
.content-area { min-height: 45vh; }
|
||||
.toolbar-left { flex-direction: column; gap: 0.375rem; }
|
||||
.title-input, .author-input { max-width: 100%; flex: 1; }
|
||||
.editor-toolbar { gap: 0.5rem; }
|
||||
.format-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
.btn-generate { font-size: 0.8rem; padding: 0.5rem; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.format-grid { grid-template-columns: 1fr 1fr; }
|
||||
.toolbar-right { width: 100%; }
|
||||
.btn-sample, .btn-upload { flex: 1; text-align: center; }
|
||||
}
|
||||
</style>`;
|
||||
}
|
||||
|
|
@ -0,0 +1,390 @@
|
|||
/**
|
||||
* rPubs landing page — community pocket press.
|
||||
* Ported from the Next.js page.tsx with all unique visual elements preserved.
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
<!-- Hero -->
|
||||
<div class="rl-hero">
|
||||
<span class="rl-tagline">Community Pocket Press</span>
|
||||
<h1 class="rl-heading">Write it. Press it.<br>Share it.</h1>
|
||||
<p class="rl-subtext">
|
||||
Instant local publishing for your writing. Paste in your text, pick a
|
||||
pocket format, and get a print-ready book in seconds — then find a
|
||||
local print shop or bind it yourself.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rpubs" class="rl-cta-primary" id="ml-primary">
|
||||
<span style="display:inline-flex;align-items:center;gap:0.5rem">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||
Start Writing
|
||||
</span>
|
||||
</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- How it works -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">From draft to booklet in three steps</h2>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num" style="position:relative">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#14b8a6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||
</div>
|
||||
<h3>Write or paste</h3>
|
||||
<p>Type directly, paste your markdown, or drop in a .md file. The editor handles the rest.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num" style="position:relative">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#14b8a6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
</div>
|
||||
<h3>Press it</h3>
|
||||
<p>Pick a pocket format — from A7 pocket to digest — and generate a typeset PDF in seconds.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num" style="position:relative">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#14b8a6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
</div>
|
||||
<h3>Print locally</h3>
|
||||
<p>Find a community print shop nearby, or download an imposition PDF and bind it at home.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Four pocket formats -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Four pocket formats</h2>
|
||||
<p class="rl-subtext" style="text-align:center">
|
||||
From tiny zines that fit in your back pocket to digest-sized readers for longer works.
|
||||
</p>
|
||||
<div class="rl-grid-4" style="margin-top:2rem">
|
||||
<div class="rl-card" style="text-align:left">
|
||||
<h3 style="margin-bottom:0.15rem">A7 Pocket</h3>
|
||||
<p style="font-size:0.75rem;color:#14b8a6;margin-bottom:0.5rem">74 × 105 mm</p>
|
||||
<p>Fits in your pocket. Perfect for poems, manifestos, and quick reads.</p>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin-top:0.5rem">8–32 pages</p>
|
||||
</div>
|
||||
<div class="rl-card" style="text-align:left">
|
||||
<h3 style="margin-bottom:0.15rem">Quarter Letter</h3>
|
||||
<p style="font-size:0.75rem;color:#14b8a6;margin-bottom:0.5rem">4.25 × 5.5"</p>
|
||||
<p>Classic zine size. Great for essays, short stories, and pamphlets.</p>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin-top:0.5rem">16–64 pages</p>
|
||||
</div>
|
||||
<div class="rl-card" style="text-align:left">
|
||||
<h3 style="margin-bottom:0.15rem">A6 Booklet</h3>
|
||||
<p style="font-size:0.75rem;color:#14b8a6;margin-bottom:0.5rem">105 × 148 mm</p>
|
||||
<p>Postcard-sized. Ideal for field guides, chapbooks, and handbooks.</p>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin-top:0.5rem">24–96 pages</p>
|
||||
</div>
|
||||
<div class="rl-card" style="text-align:left">
|
||||
<h3 style="margin-bottom:0.15rem">Digest</h3>
|
||||
<p style="font-size:0.75rem;color:#14b8a6;margin-bottom:0.5rem">5.5 × 8.5"</p>
|
||||
<p>Standard indie publishing size. For novellas, collections, and readers.</p>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin-top:0.5rem">32–200+ pages</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Group Buys -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Bulk orders, better prices</h2>
|
||||
<p class="rl-subtext" style="text-align:center">
|
||||
The more copies a community funds together, the cheaper each one gets.
|
||||
Group buys unlock better binding and lower per-copy costs.
|
||||
</p>
|
||||
|
||||
<!-- Tier cards -->
|
||||
<div class="rl-grid-3" style="margin-top:2rem">
|
||||
<div class="rl-card rl-card--center" style="position:relative">
|
||||
<p style="font-size:1.75rem;font-weight:700;color:#e2e8f0;margin-bottom:0.15rem">25+</p>
|
||||
<p style="font-size:0.9rem;font-weight:600;color:#14b8a6;margin-bottom:0.5rem">Saddle-stitch</p>
|
||||
<p style="font-size:0.8rem;margin-bottom:1rem">Staple-bound booklet. Quick, lightweight, great for zines and short runs.</p>
|
||||
<p style="font-size:1.25rem;font-weight:700;color:#e2e8f0">$8<span style="font-size:0.75rem;color:#94a3b8;font-weight:400">/copy</span></p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center" style="position:relative">
|
||||
<span class="rl-badge" style="position:absolute;top:-0.6rem;right:0.75rem">save 25%</span>
|
||||
<p style="font-size:1.75rem;font-weight:700;color:#e2e8f0;margin-bottom:0.15rem">50+</p>
|
||||
<p style="font-size:0.9rem;font-weight:600;color:#14b8a6;margin-bottom:0.5rem">Perfect-bind</p>
|
||||
<p style="font-size:0.8rem;margin-bottom:1rem">Glued spine with cover wrap. Professional feel, sits on a shelf like a real book.</p>
|
||||
<p style="font-size:1.25rem;font-weight:700;color:#e2e8f0">$6<span style="font-size:0.75rem;color:#94a3b8;font-weight:400">/copy</span></p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center" style="position:relative">
|
||||
<span class="rl-badge" style="position:absolute;top:-0.6rem;right:0.75rem">save 44%</span>
|
||||
<p style="font-size:1.75rem;font-weight:700;color:#e2e8f0;margin-bottom:0.15rem">100+</p>
|
||||
<p style="font-size:0.9rem;font-weight:600;color:#14b8a6;margin-bottom:0.5rem">Trade edition</p>
|
||||
<p style="font-size:0.8rem;margin-bottom:1rem">Full trade paperback. Robust binding, printed cover, retail-ready quality.</p>
|
||||
<p style="font-size:1.25rem;font-weight:700;color:#e2e8f0">$4.50<span style="font-size:0.75rem;color:#94a3b8;font-weight:400">/copy</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- How collaborative print runs work -->
|
||||
<div class="rl-card" style="margin-top:2.5rem">
|
||||
<h3 style="text-align:center;margin-bottom:1.25rem">How collaborative print runs work</h3>
|
||||
<div class="rl-grid-4" style="text-align:center">
|
||||
<div>
|
||||
<div style="width:2rem;height:2rem;border-radius:9999px;background:rgba(20,184,166,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 0.5rem">
|
||||
<span style="font-size:0.7rem;font-weight:700;color:#14b8a6">1</span>
|
||||
</div>
|
||||
<p style="font-size:0.75rem;color:#94a3b8;line-height:1.5">
|
||||
A title gets <a href="https://demo.rspace.online/rpubs" style="color:#14b8a6;text-decoration:none">funded</a> by the community
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div style="width:2rem;height:2rem;border-radius:9999px;background:rgba(20,184,166,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 0.5rem">
|
||||
<span style="font-size:0.7rem;font-weight:700;color:#14b8a6">2</span>
|
||||
</div>
|
||||
<p style="font-size:0.75rem;color:#94a3b8;line-height:1.5">
|
||||
Backers join the group buy as it hits each tier
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div style="width:2rem;height:2rem;border-radius:9999px;background:rgba(20,184,166,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 0.5rem">
|
||||
<span style="font-size:0.7rem;font-weight:700;color:#14b8a6">3</span>
|
||||
</div>
|
||||
<p style="font-size:0.75rem;color:#94a3b8;line-height:1.5">
|
||||
Order is placed with the nearest print shop via <a href="https://rcart.online" style="color:#14b8a6;text-decoration:none">rCart</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div style="width:2rem;height:2rem;border-radius:9999px;background:rgba(20,184,166,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 0.5rem">
|
||||
<span style="font-size:0.7rem;font-weight:700;color:#14b8a6">4</span>
|
||||
</div>
|
||||
<p style="font-size:0.75rem;color:#94a3b8;line-height:1.5">
|
||||
Books are printed locally and distributed to backers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cross-title batching -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Bundle across titles, not just within them</h2>
|
||||
<p class="rl-subtext" style="text-align:center">
|
||||
Printer discounts are based on <em>total volume at the press</em>, not copies of a single book.
|
||||
When multiple titles route to the same local printer, everyone benefits from the combined run.
|
||||
</p>
|
||||
|
||||
<!-- Cross-title batching example -->
|
||||
<div class="rl-card" style="max-width:680px;margin:2rem auto 0">
|
||||
<p style="font-size:0.7rem;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:1rem;text-align:center">
|
||||
How cross-title batching unlocks tiers
|
||||
</p>
|
||||
<div class="rl-grid-3" style="margin-bottom:1rem">
|
||||
<div style="border:1px solid rgba(255,255,255,0.06);border-radius:0.5rem;padding:0.75rem;text-align:center">
|
||||
<p style="font-size:0.875rem;font-weight:600;color:#e2e8f0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">The Commons</p>
|
||||
<p style="font-size:0.75rem;color:#94a3b8">J. Emmett</p>
|
||||
<p style="font-size:0.65rem;color:#64748b;margin-top:0.25rem">governance</p>
|
||||
<p style="font-size:1.25rem;font-weight:700;color:#14b8a6;margin-top:0.5rem">35</p>
|
||||
<p style="font-size:0.6rem;color:#94a3b8">pre-committed copies</p>
|
||||
</div>
|
||||
<div style="border:1px solid rgba(255,255,255,0.06);border-radius:0.5rem;padding:0.75rem;text-align:center">
|
||||
<p style="font-size:0.875rem;font-weight:600;color:#e2e8f0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">Mycelial Networks</p>
|
||||
<p style="font-size:0.75rem;color:#94a3b8">M. Stamets</p>
|
||||
<p style="font-size:0.65rem;color:#64748b;margin-top:0.25rem">ecology</p>
|
||||
<p style="font-size:1.25rem;font-weight:700;color:#14b8a6;margin-top:0.5rem">40</p>
|
||||
<p style="font-size:0.6rem;color:#94a3b8">pre-committed copies</p>
|
||||
</div>
|
||||
<div style="border:1px solid rgba(255,255,255,0.06);border-radius:0.5rem;padding:0.75rem;text-align:center">
|
||||
<p style="font-size:0.875rem;font-weight:600;color:#e2e8f0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">Cosmolocal Reader</p>
|
||||
<p style="font-size:0.75rem;color:#94a3b8">Collective</p>
|
||||
<p style="font-size:0.65rem;color:#64748b;margin-top:0.25rem">economics</p>
|
||||
<p style="font-size:1.25rem;font-weight:700;color:#14b8a6;margin-top:0.5rem">30</p>
|
||||
<p style="font-size:0.6rem;color:#94a3b8">pre-committed copies</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rl-divider"><span>at the same local printer</span></div>
|
||||
<div style="text-align:center">
|
||||
<p style="font-size:1.5rem;font-weight:700;color:#e2e8f0">105 copies</p>
|
||||
<p style="font-size:0.875rem;color:#14b8a6;font-weight:500">
|
||||
Trade edition tier unlocked — $4.50/copy for all three titles
|
||||
</p>
|
||||
<p style="font-size:0.75rem;color:#94a3b8;margin-top:0.25rem">
|
||||
No single title hit 100 alone, but together they clear the threshold
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pre-commitments from rSpace -->
|
||||
<div class="rl-integration" style="margin-top:2rem;max-width:680px;margin-left:auto;margin-right:auto">
|
||||
<div style="width:2.5rem;height:2.5rem;border-radius:0.5rem;background:rgba(20,184,166,0.2);display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#14b8a6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Pre-commitments flow from rSpace</h3>
|
||||
<p style="margin-bottom:0.75rem">
|
||||
Every reading group, community, and network keeps a
|
||||
<span style="color:#e2e8f0;font-weight:500">CRDT ledger</span> in their
|
||||
<a href="https://rspace.online" style="color:#14b8a6;text-decoration:none">{space}</a>.
|
||||
When members signal intent to buy, those pre-commitments sync across
|
||||
clients — conflict-free, append-only, and auditable.
|
||||
</p>
|
||||
<p style="margin-bottom:0.75rem">
|
||||
As pre-commitments accumulate across titles, genres, and networks that share a
|
||||
local printer, the combined volume triggers better pricing tiers.
|
||||
A poetry collective and a tech reading group in the same city don’t need to know
|
||||
each other — their orders batch automatically at the press.
|
||||
</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem">
|
||||
<a href="https://demo.rspace.online/rpubs" style="display:inline-flex;align-items:center;gap:0.4rem;padding:0.5rem 1rem;border-radius:0.5rem;background:#14b8a6;color:white;font-size:0.75rem;font-weight:500;text-decoration:none">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M2 12h20"/></svg>
|
||||
Fund a print run
|
||||
</a>
|
||||
<a href="https://rspace.online" style="display:inline-block;padding:0.5rem 1rem;border-radius:0.5rem;border:1px solid rgba(255,255,255,0.15);color:#94a3b8;font-size:0.75rem;font-weight:500;text-decoration:none">
|
||||
Learn about {space} ledgers
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signal / Aggregate / Release -->
|
||||
<div class="rl-grid-3" style="margin-top:2rem;text-align:center">
|
||||
<div>
|
||||
<div style="width:2rem;height:2rem;border-radius:9999px;background:rgba(20,184,166,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 0.5rem">
|
||||
<span style="font-size:0.7rem;font-weight:700;color:#14b8a6">1</span>
|
||||
</div>
|
||||
<p style="font-size:0.8rem;font-weight:600;color:#e2e8f0;margin-bottom:0.15rem">Signal</p>
|
||||
<p style="font-size:0.75rem;color:#94a3b8;line-height:1.5">
|
||||
Communities pre-commit in their {space} CRDT ledger — “I want 3 copies”
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div style="width:2rem;height:2rem;border-radius:9999px;background:rgba(20,184,166,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 0.5rem">
|
||||
<span style="font-size:0.7rem;font-weight:700;color:#14b8a6">2</span>
|
||||
</div>
|
||||
<p style="font-size:0.8rem;font-weight:600;color:#e2e8f0;margin-bottom:0.15rem">Aggregate</p>
|
||||
<p style="font-size:0.75rem;color:#94a3b8;line-height:1.5">
|
||||
Pre-commitments across titles and networks
|
||||
<a href="https://demo.rspace.online/rpubs" style="color:#14b8a6;text-decoration:none">flow into the fund</a>,
|
||||
batched by printer region
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div style="width:2rem;height:2rem;border-radius:9999px;background:rgba(20,184,166,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 0.5rem">
|
||||
<span style="font-size:0.7rem;font-weight:700;color:#14b8a6">3</span>
|
||||
</div>
|
||||
<p style="font-size:0.8rem;font-weight:600;color:#e2e8f0;margin-bottom:0.15rem">Release</p>
|
||||
<p style="font-size:0.75rem;color:#94a3b8;line-height:1.5">
|
||||
When volume hits a tier threshold, the print run triggers — everyone gets the bulk rate
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- rCart integration -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<div class="rl-grid-2">
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem">
|
||||
<span style="width:1.75rem;height:1.75rem;border-radius:0.375rem;background:#fdba74;display:flex;align-items:center;justify-content:center;font-size:0.6rem;font-weight:900;color:#0f172a;line-height:1">rCt</span>
|
||||
<span style="font-size:0.875rem;font-weight:600;color:#e2e8f0">rCart</span>
|
||||
<span style="font-size:0.75rem;color:#94a3b8">×</span>
|
||||
<span style="width:1.75rem;height:1.75rem;border-radius:0.375rem;background:#fda4af;display:flex;align-items:center;justify-content:center;font-size:0.6rem;font-weight:900;color:#0f172a;line-height:1">rP</span>
|
||||
<span style="font-size:0.875rem;font-weight:600;color:#e2e8f0">rPubs</span>
|
||||
</div>
|
||||
<h2 class="rl-heading">Group purchasing, built in</h2>
|
||||
<p style="color:#94a3b8;line-height:1.65;margin-bottom:1rem;font-size:0.95rem">
|
||||
rCart handles the commerce side of group buys — collecting orders,
|
||||
splitting payments, and coordinating delivery. When a title hits its
|
||||
funding goal, rCart automatically batches the order to the best local
|
||||
print shop.
|
||||
</p>
|
||||
<ul class="rl-check-list">
|
||||
<li><strong>Shared carts</strong> — friends, reading groups, or whole communities pool orders for better rates</li>
|
||||
<li><strong>Automatic tier unlock</strong> — as more copies are ordered, the price drops for everyone</li>
|
||||
<li><strong>Local fulfillment</strong> — orders route to the nearest registered print shop, not a distant warehouse</li>
|
||||
<li><strong>Transparent pricing</strong> — 30% to the author, 10% to the community, 60% to print and delivery</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rl-card" style="padding:1.5rem">
|
||||
<p style="font-size:0.7rem;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:1rem">Example group buy</p>
|
||||
<!-- Mini order summary -->
|
||||
<div style="margin-bottom:1rem">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.875rem;margin-bottom:0.75rem">
|
||||
<span style="font-weight:500;color:#e2e8f0">The Commons</span>
|
||||
<span style="color:#94a3b8">by J. Emmett</span>
|
||||
</div>
|
||||
<div class="rl-progress">
|
||||
<div class="rl-progress__fill" style="width:72%"></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.75rem;color:#94a3b8;margin-top:0.5rem">
|
||||
<span>36 of 50 copies ordered</span>
|
||||
<span style="color:#14b8a6;font-weight:500">72%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tier indicator -->
|
||||
<div class="rl-tier">
|
||||
<div class="rl-tier__item rl-tier__item--active">
|
||||
<p><strong>25+</strong></p>
|
||||
<p style="opacity:0.7">$8/copy</p>
|
||||
</div>
|
||||
<div class="rl-tier__item rl-tier__item--active">
|
||||
<p><strong>50+</strong></p>
|
||||
<p style="opacity:0.7">$6/copy</p>
|
||||
</div>
|
||||
<div class="rl-tier__item">
|
||||
<p><strong style="color:#64748b">100+</strong></p>
|
||||
<p style="color:#64748b">$4.50</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:0.75rem;color:#94a3b8;line-height:1.55;margin-top:1rem">
|
||||
<p style="margin-bottom:0.25rem"><span style="font-weight:500;color:#e2e8f0">Current tier:</span> Saddle-stitch at $8/copy</p>
|
||||
<p><span style="font-weight:500;color:#e2e8f0">14 more copies</span> to unlock perfect-bind at $6/copy — everyone saves.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cosmolocal -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">Design global, manufacture local</h2>
|
||||
<p class="rl-subtext">
|
||||
rPubs connects you to a cosmolocal network of independent print shops.
|
||||
Your writing is typeset in the cloud and printed close to where it’s
|
||||
needed — reducing shipping, supporting local makers, and keeping
|
||||
publishing accessible.
|
||||
</p>
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:0.75rem;flex-wrap:wrap">
|
||||
<a href="https://demo.rspace.online/rpubs" style="display:inline-flex;align-items:center;gap:0.4rem;padding:0.5rem 1rem;border-radius:0.5rem;border:1px solid rgba(20,184,166,0.3);color:#14b8a6;font-size:0.875rem;font-weight:500;text-decoration:none">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
Register Your Print Shop
|
||||
</a>
|
||||
<a href="https://demo.rspace.online/rpubs" style="display:inline-block;padding:0.5rem 1rem;border-radius:0.5rem;border:1px solid rgba(255,255,255,0.15);color:#94a3b8;font-size:0.875rem;font-weight:500;text-decoration:none">
|
||||
Fund a Title
|
||||
</a>
|
||||
<a href="https://rcart.online" style="display:inline-flex;align-items:center;gap:0.4rem;padding:0.5rem 1rem;border-radius:0.5rem;border:1px solid rgba(255,255,255,0.15);color:#94a3b8;font-size:0.875rem;font-weight:500;text-decoration:none">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>
|
||||
Browse on rCart
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">Write it. Press it. Share it.</h2>
|
||||
<p class="rl-subtext">Try the demo or create a space to get started.</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rpubs" class="rl-cta-primary">Start Writing</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rl-back">
|
||||
<a href="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue