feat: relay reviews
This commit is contained in:
259
package-lock.json
generated
259
package-lock.json
generated
@@ -28,7 +28,7 @@
|
|||||||
"@radix-ui/react-select": "^2.1.4",
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
"@radix-ui/react-separator": "^1.1.1",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slider": "^1.3.5",
|
"@radix-ui/react-slider": "^1.3.5",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
@@ -49,6 +49,8 @@
|
|||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"dataloader": "^2.2.3",
|
"dataloader": "^2.2.3",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"embla-carousel-wheel-gestures": "^8.1.0",
|
||||||
"emoji-picker-react": "^4.12.2",
|
"emoji-picker-react": "^4.12.2",
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
"franc-min": "^6.2.0",
|
"franc-min": "^6.2.0",
|
||||||
@@ -2607,6 +2609,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-arrow": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
|
||||||
@@ -2767,24 +2787,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-controllable-state": {
|
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
@@ -2877,6 +2879,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-compose-refs": {
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||||
@@ -2940,6 +2960,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-direction": {
|
"node_modules/@radix-ui/react-direction": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
|
||||||
@@ -3154,6 +3192,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-popover": {
|
"node_modules/@radix-ui/react-popover": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.4.tgz",
|
||||||
@@ -3190,6 +3246,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-popper": {
|
"node_modules/@radix-ui/react-popper": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
|
||||||
@@ -3289,6 +3363,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-radio-group": {
|
"node_modules/@radix-ui/react-radio-group": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
|
||||||
@@ -3494,24 +3586,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
@@ -3777,6 +3851,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-separator": {
|
"node_modules/@radix-ui/react-separator": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.1.tgz",
|
||||||
@@ -3930,23 +4022,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-controllable-state": {
|
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
@@ -4011,11 +4086,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.1.1",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-compose-refs": "1.1.1"
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -4027,6 +4103,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-switch": {
|
"node_modules/@radix-ui/react-switch": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz",
|
||||||
@@ -6898,6 +6989,49 @@
|
|||||||
"integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==",
|
"integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/embla-carousel": {
|
||||||
|
"version": "8.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||||
|
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/embla-carousel-react": {
|
||||||
|
"version": "8.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
|
||||||
|
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"embla-carousel": "8.6.0",
|
||||||
|
"embla-carousel-reactive-utils": "8.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/embla-carousel-reactive-utils": {
|
||||||
|
"version": "8.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
|
||||||
|
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"embla-carousel": "8.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/embla-carousel-wheel-gestures": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel-wheel-gestures/-/embla-carousel-wheel-gestures-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-J68jkYrxbWDmXOm2n2YHl+uMEXzkGSKjWmjaEgL9xVvPb3HqVmg6rJSKfI3sqIDVvm7mkeTy87wtG/5263XqHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"wheel-gestures": "^2.2.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"embla-carousel": "^8.0.0 || ~8.0.0-rc03"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/emoji-picker-react": {
|
"node_modules/emoji-picker-react": {
|
||||||
"version": "4.12.2",
|
"version": "4.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.2.tgz",
|
||||||
@@ -12312,6 +12446,15 @@
|
|||||||
"webidl-conversions": "^4.0.2"
|
"webidl-conversions": "^4.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wheel-gestures": {
|
||||||
|
"version": "2.2.48",
|
||||||
|
"resolved": "https://registry.npmjs.org/wheel-gestures/-/wheel-gestures-2.2.48.tgz",
|
||||||
|
"integrity": "sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"@radix-ui/react-select": "^2.1.4",
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
"@radix-ui/react-separator": "^1.1.1",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slider": "^1.3.5",
|
"@radix-ui/react-slider": "^1.3.5",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
@@ -59,6 +59,8 @@
|
|||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"dataloader": "^2.2.3",
|
"dataloader": "^2.2.3",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"embla-carousel-wheel-gestures": "^8.1.0",
|
||||||
"emoji-picker-react": "^4.12.2",
|
"emoji-picker-react": "^4.12.2",
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
"franc-min": "^6.2.0",
|
"franc-min": "^6.2.0",
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export default function ContentPreview({
|
|||||||
kinds.ShortTextNote,
|
kinds.ShortTextNote,
|
||||||
ExtendedKind.COMMENT,
|
ExtendedKind.COMMENT,
|
||||||
ExtendedKind.VOICE,
|
ExtendedKind.VOICE,
|
||||||
ExtendedKind.VOICE_COMMENT
|
ExtendedKind.VOICE_COMMENT,
|
||||||
|
ExtendedKind.RELAY_REVIEW
|
||||||
].includes(event.kind)
|
].includes(event.kind)
|
||||||
) {
|
) {
|
||||||
return <NormalContentPreview event={event} className={className} />
|
return <NormalContentPreview event={event} className={className} />
|
||||||
|
|||||||
16
src/components/Note/RelayReview.tsx
Normal file
16
src/components/Note/RelayReview.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
|
||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import Content from '../Content'
|
||||||
|
import Stars from '../Stars'
|
||||||
|
|
||||||
|
export default function RelayReview({ event, className }: { event: Event; className?: string }) {
|
||||||
|
const stars = useMemo(() => getStarsFromRelayReviewEvent(event), [event])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<Stars stars={stars} className="mt-2" />
|
||||||
|
<Content event={event} className="mt-2" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import PictureNote from './PictureNote'
|
|||||||
import Poll from './Poll'
|
import Poll from './Poll'
|
||||||
import UnknownNote from './UnknownNote'
|
import UnknownNote from './UnknownNote'
|
||||||
import VideoNote from './VideoNote'
|
import VideoNote from './VideoNote'
|
||||||
|
import RelayReview from './RelayReview'
|
||||||
|
|
||||||
export default function Note({
|
export default function Note({
|
||||||
event,
|
event,
|
||||||
@@ -98,6 +99,8 @@ export default function Note({
|
|||||||
content = <PictureNote className="mt-2" event={event} />
|
content = <PictureNote className="mt-2" event={event} />
|
||||||
} else if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) {
|
} else if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) {
|
||||||
content = <VideoNote className="mt-2" event={event} />
|
content = <VideoNote className="mt-2" event={event} />
|
||||||
|
} else if (event.kind === ExtendedKind.RELAY_REVIEW) {
|
||||||
|
content = <RelayReview className="mt-2" event={event} />
|
||||||
} else {
|
} else {
|
||||||
content = <Content className="mt-2" event={event} />
|
content = <Content className="mt-2" event={event} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
isReplaceableEvent,
|
isReplaceableEvent,
|
||||||
isReplyNoteEvent
|
isReplyNoteEvent
|
||||||
} from '@/lib/event'
|
} from '@/lib/event'
|
||||||
|
import { isTouchDevice } from '@/lib/utils'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
@@ -27,7 +28,6 @@ import {
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import PullToRefresh from 'react-simple-pull-to-refresh'
|
import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||||
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
|
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
|
||||||
import { isTouchDevice } from '@/lib/utils'
|
|
||||||
|
|
||||||
const LIMIT = 200
|
const LIMIT = 200
|
||||||
const ALGO_LIMIT = 500
|
const ALGO_LIMIT = 500
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function RelayItem({ relay }: { relay: TMailboxRelay }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 rounded-lg border clickable space-y-1" onClick={() => push(toRelay(url))}>
|
<div className="p-4 rounded-lg border clickable space-y-1" onClick={() => push(toRelay(url))}>
|
||||||
<RelaySimpleInfo relayInfo={relayInfo} hideBadge />
|
<RelaySimpleInfo relayInfo={relayInfo} />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{['both', 'read'].includes(scope) && (
|
{['both', 'read'].includes(scope) && (
|
||||||
<Badge className="bg-blue-400 hover:bg-blue-400/80">{t('Read')}</Badge>
|
<Badge className="bg-blue-400 hover:bg-blue-400/80">{t('Read')}</Badge>
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { TRelayInfo } from '@/types'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
export default function RelayBadges({ relayInfo }: { relayInfo: TRelayInfo }) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
const badges = useMemo(() => {
|
|
||||||
const b: string[] = []
|
|
||||||
if (relayInfo.limitation?.payment_required) {
|
|
||||||
b.push('Payment')
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}, [relayInfo])
|
|
||||||
|
|
||||||
if (!badges.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{badges.includes('Payment') && (
|
|
||||||
<Badge className="bg-orange-400 hover:bg-orange-400/80">{t('relayInfoBadgePayment')}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
57
src/components/RelayInfo/RelayReviewCard.tsx
Normal file
57
src/components/RelayInfo/RelayReviewCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
|
||||||
|
import { toNote } from '@/lib/link'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import ClientTag from '../ClientTag'
|
||||||
|
import ContentPreview from '../ContentPreview'
|
||||||
|
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||||
|
import Nip05 from '../Nip05'
|
||||||
|
import Stars from '../Stars'
|
||||||
|
import TranslateButton from '../TranslateButton'
|
||||||
|
import { SimpleUserAvatar } from '../UserAvatar'
|
||||||
|
import { SimpleUsername } from '../Username'
|
||||||
|
|
||||||
|
export default function RelayReviewCard({
|
||||||
|
event,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
event: NostrEvent
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { push } = useSecondaryPage()
|
||||||
|
const stars = useMemo(() => getStarsFromRelayReviewEvent(event), [event])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('clickable border rounded-lg bg-muted/20 p-3 h-full', className)}
|
||||||
|
onClick={() => push(toNote(event))}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start gap-2">
|
||||||
|
<div className="flex items-center space-x-2 flex-1">
|
||||||
|
<SimpleUserAvatar userId={event.pubkey} size="medium" />
|
||||||
|
<div className="flex-1 w-0">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<SimpleUsername
|
||||||
|
userId={event.pubkey}
|
||||||
|
className="font-semibold flex truncate text-sm"
|
||||||
|
skeletonClassName="h-3"
|
||||||
|
/>
|
||||||
|
<ClientTag event={event} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
|
<Nip05 pubkey={event.pubkey} append="·" />
|
||||||
|
<FormattedTimestamp timestamp={event.created_at} className="shrink-0" short />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<TranslateButton event={event} className="pr-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Stars stars={stars} className="mt-2 gap-0.5 [&_svg]:size-3" />
|
||||||
|
<ContentPreview className="mt-2 line-clamp-4" event={event} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
200
src/components/RelayInfo/RelayReviewsPreview.tsx
Normal file
200
src/components/RelayInfo/RelayReviewsPreview.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselNext,
|
||||||
|
CarouselPrevious
|
||||||
|
} from '@/components/ui/carousel'
|
||||||
|
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||||
|
import { compareEvents } from '@/lib/event'
|
||||||
|
import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
|
||||||
|
import { toRelayReviews } from '@/lib/link'
|
||||||
|
import { cn, isTouchDevice } from '@/lib/utils'
|
||||||
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'
|
||||||
|
import { Filter, NostrEvent } from 'nostr-tools'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Stars from '../Stars'
|
||||||
|
import RelayReviewCard from './RelayReviewCard'
|
||||||
|
import ReviewEditor from './ReviewEditor'
|
||||||
|
|
||||||
|
export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { push } = useSecondaryPage()
|
||||||
|
const { pubkey, checkLogin } = useNostr()
|
||||||
|
const { hideUntrustedNotes, isUserTrusted } = useUserTrust()
|
||||||
|
const { mutePubkeySet } = useMuteList()
|
||||||
|
const [showEditor, setShowEditor] = useState(false)
|
||||||
|
const [myReview, setMyReview] = useState<NostrEvent | null>(null)
|
||||||
|
const [reviews, setReviews] = useState<NostrEvent[]>([])
|
||||||
|
const [initialized, setInitialized] = useState(false)
|
||||||
|
const { stars, count } = useMemo(() => {
|
||||||
|
let totalStars = 0
|
||||||
|
let totalCount = 0
|
||||||
|
;[myReview, ...reviews].forEach((evt) => {
|
||||||
|
if (!evt) return
|
||||||
|
const stars = getStarsFromRelayReviewEvent(evt)
|
||||||
|
if (stars) {
|
||||||
|
totalStars += stars
|
||||||
|
totalCount += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
stars: totalCount > 0 ? +(totalStars / totalCount).toFixed(1) : 0,
|
||||||
|
count: totalCount
|
||||||
|
}
|
||||||
|
}, [myReview, reviews])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
const filters: Filter[] = [
|
||||||
|
{ kinds: [ExtendedKind.RELAY_REVIEW], '#d': [relayUrl], limit: 100 }
|
||||||
|
]
|
||||||
|
if (pubkey) {
|
||||||
|
filters.push({ kinds: [ExtendedKind.RELAY_REVIEW], authors: [pubkey], '#d': [relayUrl] })
|
||||||
|
}
|
||||||
|
const events = await client.fetchEvents([relayUrl, ...BIG_RELAY_URLS], filters, {
|
||||||
|
cache: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const pubkeySet = new Set<string>()
|
||||||
|
const reviews: NostrEvent[] = []
|
||||||
|
let myReview: NostrEvent | null = null
|
||||||
|
|
||||||
|
events.sort((a, b) => compareEvents(b, a))
|
||||||
|
for (const evt of events) {
|
||||||
|
if (
|
||||||
|
mutePubkeySet.has(evt.pubkey) ||
|
||||||
|
pubkeySet.has(evt.pubkey) ||
|
||||||
|
(hideUntrustedNotes && !isUserTrusted(evt.pubkey))
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const stars = getStarsFromRelayReviewEvent(evt)
|
||||||
|
if (!stars) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pubkeySet.add(evt.pubkey)
|
||||||
|
if (evt.pubkey === pubkey) {
|
||||||
|
myReview = evt
|
||||||
|
} else {
|
||||||
|
reviews.push(evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMyReview(myReview)
|
||||||
|
setReviews(reviews)
|
||||||
|
setInitialized(true)
|
||||||
|
}
|
||||||
|
init()
|
||||||
|
}, [relayUrl, pubkey, mutePubkeySet, hideUntrustedNotes, isUserTrusted])
|
||||||
|
|
||||||
|
const handleReviewed = (evt: NostrEvent) => {
|
||||||
|
setMyReview(evt)
|
||||||
|
setShowEditor(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="px-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-lg font-semibold">{stars}</div>
|
||||||
|
<Stars stars={stars} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-sm text-muted-foreground',
|
||||||
|
count > 0 && 'underline cursor-pointer hover:text-foreground'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (count > 0) {
|
||||||
|
push(toRelayReviews(relayUrl))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('{{count}} reviews', { count })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!showEditor && !myReview && (
|
||||||
|
<Button variant="outline" onClick={() => checkLogin(() => setShowEditor(true))}>
|
||||||
|
{t('Write a review')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEditor && <ReviewEditor relayUrl={relayUrl} onReviewed={handleReviewed} />}
|
||||||
|
|
||||||
|
{myReview || reviews.length > 0 ? (
|
||||||
|
<ReviewCarousel relayUrl={relayUrl} myReview={myReview} reviews={reviews} />
|
||||||
|
) : !showEditor ? (
|
||||||
|
<div className="flex items-center justify-center text-sm text-muted-foreground p-4">
|
||||||
|
{initialized ? t('No reviews yet. Be the first to write one!') : t('Loading...')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewCarousel({
|
||||||
|
relayUrl,
|
||||||
|
myReview,
|
||||||
|
reviews
|
||||||
|
}: {
|
||||||
|
relayUrl: string
|
||||||
|
myReview: NostrEvent | null
|
||||||
|
reviews: NostrEvent[]
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { push } = useSecondaryPage()
|
||||||
|
const showPreviousAndNext = useMemo(() => !isTouchDevice(), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Carousel
|
||||||
|
opts={{
|
||||||
|
skipSnaps: true
|
||||||
|
}}
|
||||||
|
plugins={[WheelGesturesPlugin()]}
|
||||||
|
>
|
||||||
|
<CarouselContent className="ml-4 mr-2">
|
||||||
|
{myReview && (
|
||||||
|
<Item key={myReview.id}>
|
||||||
|
<RelayReviewCard event={myReview} className="border-primary/60 bg-primary/5" />
|
||||||
|
</Item>
|
||||||
|
)}
|
||||||
|
{reviews.slice(0, 10).map((evt) => (
|
||||||
|
<Item key={evt.id}>
|
||||||
|
<RelayReviewCard event={evt} />
|
||||||
|
</Item>
|
||||||
|
))}
|
||||||
|
{reviews.length > 10 && (
|
||||||
|
<Item>
|
||||||
|
<div
|
||||||
|
className="border rounded-lg bg-muted/20 p-3 flex items-center justify-center h-full hover:bg-muted cursor-pointer"
|
||||||
|
onClick={() => push(toRelayReviews(relayUrl))}
|
||||||
|
>
|
||||||
|
<div className="text-sm text-muted-foreground">{t('View more reviews')}</div>
|
||||||
|
</div>
|
||||||
|
</Item>
|
||||||
|
)}
|
||||||
|
</CarouselContent>
|
||||||
|
{showPreviousAndNext && <CarouselPrevious />}
|
||||||
|
{showPreviousAndNext && <CarouselNext />}
|
||||||
|
</Carousel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Item({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<CarouselItem className="basis-11/12 lg:basis-2/3 2xl:basis-5/12 pl-0 pr-2">
|
||||||
|
{children}
|
||||||
|
</CarouselItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
src/components/RelayInfo/ReviewEditor.tsx
Normal file
90
src/components/RelayInfo/ReviewEditor.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { BIG_RELAY_URLS } from '@/constants'
|
||||||
|
import { createRelayReviewDraftEvent } from '@/lib/draft-event'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { Loader2, Star } from 'lucide-react'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export default function ReviewEditor({
|
||||||
|
relayUrl,
|
||||||
|
onReviewed
|
||||||
|
}: {
|
||||||
|
relayUrl: string
|
||||||
|
onReviewed: (evt: NostrEvent) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { publish } = useNostr()
|
||||||
|
const [stars, setStars] = useState(0)
|
||||||
|
const [hoverStars, setHoverStars] = useState(0)
|
||||||
|
const [review, setReview] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const canSubmit = useMemo(() => stars > 0 && !!review.trim(), [stars, review])
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!canSubmit) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const draftEvent = createRelayReviewDraftEvent(relayUrl, review, stars)
|
||||||
|
const evt = await publish(draftEvent, { specifiedRelayUrls: [relayUrl, ...BIG_RELAY_URLS] })
|
||||||
|
onReviewed(evt)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AggregateError) {
|
||||||
|
error.errors.forEach((e) => toast.error(`${t('Failed to review')}: ${e.message}`))
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
toast.error(`${t('Failed to review')}: ${error.message}`)
|
||||||
|
}
|
||||||
|
console.error(error)
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 space-y-2">
|
||||||
|
<Textarea
|
||||||
|
className="min-h-36"
|
||||||
|
placeholder={t('Write a review and pick a star rating')}
|
||||||
|
value={review}
|
||||||
|
onChange={(e) => setReview(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="pr-2 cursor-pointer"
|
||||||
|
onMouseEnter={() => setHoverStars(index + 1)}
|
||||||
|
onMouseLeave={() => setHoverStars(0)}
|
||||||
|
>
|
||||||
|
{index < (hoverStars || stars) ? (
|
||||||
|
<Star
|
||||||
|
className="size-6 text-yellow-400 fill-yellow-400"
|
||||||
|
onClick={() => setStars(index + 1)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Star
|
||||||
|
className="size-6 text-muted-foreground"
|
||||||
|
onClick={() => setStars(index + 1)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={!canSubmit}
|
||||||
|
variant={canSubmit ? 'default' : 'secondary'}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="animate-spin" />}
|
||||||
|
{t('Submit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,21 +1,24 @@
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||||
import { useFetchRelayInfo } from '@/hooks'
|
import { useFetchRelayInfo } from '@/hooks'
|
||||||
import { normalizeHttpUrl } from '@/lib/url'
|
import { normalizeHttpUrl } from '@/lib/url'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { Check, Copy, GitBranch, Link, Mail, SquareCode } from 'lucide-react'
|
import { Check, Copy, GitBranch, Link, Mail, SquareCode } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import PostEditor from '../PostEditor'
|
import PostEditor from '../PostEditor'
|
||||||
import RelayBadges from '../RelayBadges'
|
|
||||||
import RelayIcon from '../RelayIcon'
|
import RelayIcon from '../RelayIcon'
|
||||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
import RelayReviewsPreview from './RelayReviewsPreview'
|
||||||
|
|
||||||
export default function RelayInfo({ url, className }: { url: string; className?: string }) {
|
export default function RelayInfo({ url, className }: { url: string; className?: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { checkLogin } = useNostr()
|
||||||
const { relayInfo, isFetching } = useFetchRelayInfo(url)
|
const { relayInfo, isFetching } = useFetchRelayInfo(url)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
@@ -24,7 +27,8 @@ export default function RelayInfo({ url, className }: { url: string; className?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('px-4 space-y-4 mb-2', className)}>
|
<div className={cn('space-y-4 mb-2', className)}>
|
||||||
|
<div className="px-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 justify-between">
|
<div className="flex items-center gap-2 justify-between">
|
||||||
<div className="flex gap-2 items-center truncate">
|
<div className="flex gap-2 items-center truncate">
|
||||||
@@ -35,7 +39,6 @@ export default function RelayInfo({ url, className }: { url: string; className?:
|
|||||||
</div>
|
</div>
|
||||||
<RelayControls url={relayInfo.url} />
|
<RelayControls url={relayInfo.url} />
|
||||||
</div>
|
</div>
|
||||||
<RelayBadges relayInfo={relayInfo} />
|
|
||||||
{!!relayInfo.tags?.length && (
|
{!!relayInfo.tags?.length && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{relayInfo.tags.map((tag) => (
|
{relayInfo.tags.map((tag) => (
|
||||||
@@ -51,7 +54,7 @@ export default function RelayInfo({ url, className }: { url: string; className?:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-semibold text-muted-foreground">{t('Homepage')}:</div>
|
<div className="text-sm font-semibold text-muted-foreground">{t('Homepage')}</div>
|
||||||
<a
|
<a
|
||||||
href={normalizeHttpUrl(relayInfo.url)}
|
href={normalizeHttpUrl(relayInfo.url)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -61,61 +64,58 @@ export default function RelayInfo({ url, className }: { url: string; className?:
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{relayInfo.payments_url && (
|
<ScrollArea className="overflow-x-auto">
|
||||||
<div className="space-y-2">
|
<div className="flex gap-8 pb-2">
|
||||||
<div className="text-sm font-semibold text-muted-foreground">{t('Payment page')}:</div>
|
|
||||||
<a
|
|
||||||
href={normalizeHttpUrl(relayInfo.payments_url)}
|
|
||||||
target="_blank"
|
|
||||||
className="hover:underline text-primary select-text"
|
|
||||||
>
|
|
||||||
{relayInfo.payments_url}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
{relayInfo.pubkey && (
|
{relayInfo.pubkey && (
|
||||||
<div className="space-y-2 flex-1">
|
<div className="space-y-2 w-fit">
|
||||||
<div className="text-sm font-semibold text-muted-foreground">{t('Operator')}</div>
|
<div className="text-sm font-semibold text-muted-foreground">{t('Operator')}</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<UserAvatar userId={relayInfo.pubkey} size="small" />
|
<UserAvatar userId={relayInfo.pubkey} size="small" />
|
||||||
<Username userId={relayInfo.pubkey} className="font-semibold" />
|
<Username userId={relayInfo.pubkey} className="font-semibold text-nowrap" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{relayInfo.contact && (
|
{relayInfo.contact && (
|
||||||
<div className="space-y-2 flex-1">
|
<div className="space-y-2 w-fit">
|
||||||
<div className="text-sm font-semibold text-muted-foreground">{t('Contact')}</div>
|
<div className="text-sm font-semibold text-muted-foreground">{t('Contact')}</div>
|
||||||
<div className="flex gap-2 items-center font-semibold select-text">
|
<div className="flex gap-2 items-center font-semibold select-text text-nowrap">
|
||||||
<Mail />
|
<Mail />
|
||||||
{relayInfo.contact}
|
{relayInfo.contact}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{relayInfo.software && (
|
{relayInfo.software && (
|
||||||
<div className="space-y-2 flex-1">
|
<div className="space-y-2 w-fit">
|
||||||
<div className="text-sm font-semibold text-muted-foreground">{t('Software')}</div>
|
<div className="text-sm font-semibold text-muted-foreground">{t('Software')}</div>
|
||||||
<div className="flex gap-2 items-center font-semibold select-text">
|
<div className="flex gap-2 items-center font-semibold select-text text-nowrap">
|
||||||
<SquareCode />
|
<SquareCode />
|
||||||
{formatSoftware(relayInfo.software)}
|
{formatSoftware(relayInfo.software)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{relayInfo.version && (
|
{relayInfo.version && (
|
||||||
<div className="space-y-2 flex-1">
|
<div className="space-y-2 w-fit">
|
||||||
<div className="text-sm font-semibold text-muted-foreground">{t('Version')}</div>
|
<div className="text-sm font-semibold text-muted-foreground">{t('Version')}</div>
|
||||||
<div className="flex gap-2 items-center font-semibold select-text">
|
<div className="flex gap-2 items-center font-semibold select-text text-nowrap">
|
||||||
<GitBranch />
|
<GitBranch />
|
||||||
{relayInfo.version}
|
{relayInfo.version}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" className="w-full" onClick={() => setOpen(true)}>
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => checkLogin(() => setOpen(true))}
|
||||||
|
>
|
||||||
{t('Share something on this Relay')}
|
{t('Share something on this Relay')}
|
||||||
</Button>
|
</Button>
|
||||||
<PostEditor open={open} setOpen={setOpen} openFrom={[relayInfo.url]} />
|
<PostEditor open={open} setOpen={setOpen} openFrom={[relayInfo.url]} />
|
||||||
</div>
|
</div>
|
||||||
|
<RelayReviewsPreview relayUrl={url} />
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { cn } from '@/lib/utils'
|
|||||||
import { TRelayInfo } from '@/types'
|
import { TRelayInfo } from '@/types'
|
||||||
import { HTMLProps } from 'react'
|
import { HTMLProps } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import RelayBadges from '../RelayBadges'
|
|
||||||
import RelayIcon from '../RelayIcon'
|
import RelayIcon from '../RelayIcon'
|
||||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
||||||
import { SimpleUserAvatar } from '../UserAvatar'
|
import { SimpleUserAvatar } from '../UserAvatar'
|
||||||
@@ -11,13 +10,11 @@ import { SimpleUserAvatar } from '../UserAvatar'
|
|||||||
export default function RelaySimpleInfo({
|
export default function RelaySimpleInfo({
|
||||||
relayInfo,
|
relayInfo,
|
||||||
users,
|
users,
|
||||||
hideBadge = false,
|
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: HTMLProps<HTMLDivElement> & {
|
}: HTMLProps<HTMLDivElement> & {
|
||||||
relayInfo?: TRelayInfo
|
relayInfo?: TRelayInfo
|
||||||
users?: string[]
|
users?: string[]
|
||||||
hideBadge?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -35,7 +32,6 @@ export default function RelaySimpleInfo({
|
|||||||
</div>
|
</div>
|
||||||
{relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />}
|
{relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />}
|
||||||
</div>
|
</div>
|
||||||
{!hideBadge && relayInfo && <RelayBadges relayInfo={relayInfo} />}
|
|
||||||
{!!relayInfo?.description && <div className="line-clamp-3">{relayInfo.description}</div>}
|
{!!relayInfo?.description && <div className="line-clamp-3">{relayInfo.description}</div>}
|
||||||
{!!users?.length && (
|
{!!users?.length && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
19
src/components/Stars/index.tsx
Normal file
19
src/components/Stars/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Star } from 'lucide-react'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
export default function Stars({ stars, className }: { stars: number; className?: string }) {
|
||||||
|
const roundedStars = useMemo(() => Math.round(stars), [stars])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-1', className)}>
|
||||||
|
{Array.from({ length: 5 }).map((_, index) =>
|
||||||
|
index < roundedStars ? (
|
||||||
|
<Star key={index} className="size-4 text-foreground fill-foreground" />
|
||||||
|
) : (
|
||||||
|
<Star key={index} className="size-4 text-muted-foreground" />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,7 +29,8 @@ export default function TranslateButton({
|
|||||||
kinds.Highlights,
|
kinds.Highlights,
|
||||||
ExtendedKind.COMMENT,
|
ExtendedKind.COMMENT,
|
||||||
ExtendedKind.PICTURE,
|
ExtendedKind.PICTURE,
|
||||||
ExtendedKind.POLL
|
ExtendedKind.POLL,
|
||||||
|
ExtendedKind.RELAY_REVIEW
|
||||||
].includes(event.kind),
|
].includes(event.kind),
|
||||||
[event]
|
[event]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function SimpleUserAvatar({
|
|||||||
onClick
|
onClick
|
||||||
}: {
|
}: {
|
||||||
userId: string
|
userId: string
|
||||||
size?: 'large' | 'big' | 'normal' | 'small' | 'xSmall' | 'tiny'
|
size?: 'large' | 'big' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
|
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
235
src/components/ui/carousel.tsx
Normal file
235
src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react'
|
||||||
|
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1]
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||||
|
type CarouselOptions = UseCarouselParameters[0]
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1]
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
opts?: CarouselOptions
|
||||||
|
plugins?: CarouselPlugin
|
||||||
|
orientation?: 'horizontal' | 'vertical'
|
||||||
|
setApi?: (api: CarouselApi) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||||
|
scrollPrev: () => void
|
||||||
|
scrollNext: () => void
|
||||||
|
canScrollPrev: boolean
|
||||||
|
canScrollNext: boolean
|
||||||
|
} & CarouselProps
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useCarousel must be used within a <Carousel />')
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const Carousel = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||||
|
>(({ orientation = 'horizontal', opts, setApi, plugins, className, children, ...props }, ref) => {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === 'horizontal' ? 'x' : 'y'
|
||||||
|
},
|
||||||
|
plugins
|
||||||
|
)
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanScrollPrev(api.canScrollPrev())
|
||||||
|
setCanScrollNext(api.canScrollNext())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollPrev()
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollNext()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setApi(api)
|
||||||
|
}, [api, setApi])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(api)
|
||||||
|
api.on('reInit', onSelect)
|
||||||
|
api.on('select', onSelect)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off('select', onSelect)
|
||||||
|
}
|
||||||
|
}, [api, onSelect])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation: orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn('relative', className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Carousel.displayName = 'Carousel'
|
||||||
|
|
||||||
|
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const { carouselRef, orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={carouselRef} className="overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex',
|
||||||
|
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
CarouselContent.displayName = 'CarouselContent'
|
||||||
|
|
||||||
|
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const { orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 shrink-0 grow-0 basis-full',
|
||||||
|
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
CarouselItem.displayName = 'CarouselItem'
|
||||||
|
|
||||||
|
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||||
|
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
'absolute h-8 w-8 rounded-full',
|
||||||
|
orientation === 'horizontal'
|
||||||
|
? 'left-4 top-1/2 -translate-y-1/2'
|
||||||
|
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||||
|
canScrollPrev ? '' : 'invisible',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
CarouselPrevious.displayName = 'CarouselPrevious'
|
||||||
|
|
||||||
|
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||||
|
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
'absolute h-8 w-8 rounded-full',
|
||||||
|
orientation === 'horizontal'
|
||||||
|
? 'right-4 top-1/2 -translate-y-1/2'
|
||||||
|
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||||
|
canScrollNext ? '' : 'invisible',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
CarouselNext.displayName = 'CarouselNext'
|
||||||
|
|
||||||
|
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }
|
||||||
@@ -84,6 +84,7 @@ export const ExtendedKind = {
|
|||||||
VOICE_COMMENT: 1244,
|
VOICE_COMMENT: 1244,
|
||||||
FAVORITE_RELAYS: 10012,
|
FAVORITE_RELAYS: 10012,
|
||||||
BLOSSOM_SERVER_LIST: 10063,
|
BLOSSOM_SERVER_LIST: 10063,
|
||||||
|
RELAY_REVIEW: 31987,
|
||||||
GROUP_METADATA: 39000
|
GROUP_METADATA: 39000
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +99,8 @@ export const SUPPORTED_KINDS = [
|
|||||||
ExtendedKind.VOICE,
|
ExtendedKind.VOICE,
|
||||||
ExtendedKind.VOICE_COMMENT,
|
ExtendedKind.VOICE_COMMENT,
|
||||||
kinds.Highlights,
|
kinds.Highlights,
|
||||||
kinds.LongFormArticle
|
kinds.LongFormArticle,
|
||||||
|
ExtendedKind.RELAY_REVIEW
|
||||||
]
|
]
|
||||||
|
|
||||||
export const URL_REGEX =
|
export const URL_REGEX =
|
||||||
|
|||||||
@@ -407,6 +407,14 @@ export default {
|
|||||||
Never: 'أبداً',
|
Never: 'أبداً',
|
||||||
'Click to load image': 'انقر لتحميل الصورة',
|
'Click to load image': 'انقر لتحميل الصورة',
|
||||||
'Click to load media': 'انقر لتحميل الوسائط',
|
'Click to load media': 'انقر لتحميل الوسائط',
|
||||||
'Click to load YouTube video': 'انقر لتحميل فيديو YouTube'
|
'Click to load YouTube video': 'انقر لتحميل فيديو YouTube',
|
||||||
|
'{{count}} reviews': '{{count}} مراجعة',
|
||||||
|
'Write a review': 'كتابة مراجعة',
|
||||||
|
'No reviews yet. Be the first to write one!': 'لا توجد مراجعات بعد. كن أول من يكتب واحدة!',
|
||||||
|
'View more reviews': 'عرض المزيد من المراجعات',
|
||||||
|
'Failed to review': 'فشل في المراجعة',
|
||||||
|
'Write a review and pick a star rating': 'اكتب مراجعة واختر تقييماً بالنجوم',
|
||||||
|
Submit: 'إرسال',
|
||||||
|
'Reviews for {{relay}}': 'مراجعات لـ {{relay}}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,6 +417,16 @@ export default {
|
|||||||
Never: 'Nie',
|
Never: 'Nie',
|
||||||
'Click to load image': 'Klicken, um Bild zu laden',
|
'Click to load image': 'Klicken, um Bild zu laden',
|
||||||
'Click to load media': 'Klicken, um Medien zu laden',
|
'Click to load media': 'Klicken, um Medien zu laden',
|
||||||
'Click to load YouTube video': 'Klicken, um YouTube-Video zu laden'
|
'Click to load YouTube video': 'Klicken, um YouTube-Video zu laden',
|
||||||
|
'{{count}} reviews': '{{count}} Bewertungen',
|
||||||
|
'Write a review': 'Eine Bewertung schreiben',
|
||||||
|
'No reviews yet. Be the first to write one!':
|
||||||
|
'Noch keine Bewertungen. Seien Sie der Erste, der eine schreibt!',
|
||||||
|
'View more reviews': 'Weitere Bewertungen anzeigen',
|
||||||
|
'Failed to review': 'Bewertung fehlgeschlagen',
|
||||||
|
'Write a review and pick a star rating':
|
||||||
|
'Schreiben Sie eine Bewertung und wählen Sie eine Sternebewertung',
|
||||||
|
Submit: 'Absenden',
|
||||||
|
'Reviews for {{relay}}': 'Bewertungen für {{relay}}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,6 +406,14 @@ export default {
|
|||||||
Never: 'Never',
|
Never: 'Never',
|
||||||
'Click to load image': 'Click to load image',
|
'Click to load image': 'Click to load image',
|
||||||
'Click to load media': 'Click to load media',
|
'Click to load media': 'Click to load media',
|
||||||
'Click to load YouTube video': 'Click to load YouTube video'
|
'Click to load YouTube video': 'Click to load YouTube video',
|
||||||
|
'{{count}} reviews': '{{count}} reviews',
|
||||||
|
'Write a review': 'Write a review',
|
||||||
|
'No reviews yet. Be the first to write one!': 'No reviews yet. Be the first to write one!',
|
||||||
|
'View more reviews': 'View more reviews',
|
||||||
|
'Failed to review': 'Failed to review',
|
||||||
|
'Write a review and pick a star rating': 'Write a review and pick a star rating',
|
||||||
|
Submit: 'Submit',
|
||||||
|
'Reviews for {{relay}}': 'Reviews for {{relay}}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,6 +412,16 @@ export default {
|
|||||||
Never: 'Nunca',
|
Never: 'Nunca',
|
||||||
'Click to load image': 'Haz clic para cargar la imagen',
|
'Click to load image': 'Haz clic para cargar la imagen',
|
||||||
'Click to load media': 'Haz clic para cargar los medios',
|
'Click to load media': 'Haz clic para cargar los medios',
|
||||||
'Click to load YouTube video': 'Haz clic para cargar el video de YouTube'
|
'Click to load YouTube video': 'Haz clic para cargar el video de YouTube',
|
||||||
|
'{{count}} reviews': '{{count}} reseñas',
|
||||||
|
'Write a review': 'Escribir una reseña',
|
||||||
|
'No reviews yet. Be the first to write one!':
|
||||||
|
'¡Aún no hay reseñas. Sé el primero en escribir una!',
|
||||||
|
'View more reviews': 'Ver más reseñas',
|
||||||
|
'Failed to review': 'Error al reseñar',
|
||||||
|
'Write a review and pick a star rating':
|
||||||
|
'Escriba una reseña y elija una calificación de estrellas',
|
||||||
|
Submit: 'Enviar',
|
||||||
|
'Reviews for {{relay}}': 'Reseñas para {{relay}}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,6 +408,15 @@ export default {
|
|||||||
Never: 'هرگز',
|
Never: 'هرگز',
|
||||||
'Click to load image': 'برای بارگذاری تصویر کلیک کنید',
|
'Click to load image': 'برای بارگذاری تصویر کلیک کنید',
|
||||||
'Click to load media': 'برای بارگذاری رسانه کلیک کنید',
|
'Click to load media': 'برای بارگذاری رسانه کلیک کنید',
|
||||||
'Click to load YouTube video': 'برای بارگذاری ویدیو YouTube کلیک کنید'
|
'Click to load YouTube video': 'برای بارگذاری ویدیو YouTube کلیک کنید',
|
||||||
|
'{{count}} reviews': '{{count}} نقد',
|
||||||
|
'Write a review': 'نوشتن نقد',
|
||||||
|
'No reviews yet. Be the first to write one!':
|
||||||
|
'هنوز نقدی وجود ندارد. اولین نفری باشید که مینویسد!',
|
||||||
|
'View more reviews': 'مشاهده نقدهای بیشتر',
|
||||||
|
'Failed to review': 'نقد ناموفق',
|
||||||
|
'Write a review and pick a star rating': 'نقدی بنویسید و امتیاز ستارهای انتخاب کنید',
|
||||||
|
Submit: 'ارسال',
|
||||||
|
'Reviews for {{relay}}': 'نقدها برای {{relay}}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,6 +417,15 @@ export default {
|
|||||||
Never: 'Jamais',
|
Never: 'Jamais',
|
||||||
'Click to load image': 'Cliquez pour charger l’image',
|
'Click to load image': 'Cliquez pour charger l’image',
|
||||||
'Click to load media': 'Cliquez pour charger les médias',
|
'Click to load media': 'Cliquez pour charger les médias',
|
||||||
'Click to load YouTube video': 'Cliquez pour charger la vidéo YouTube'
|
'Click to load YouTube video': 'Cliquez pour charger la vidéo YouTube',
|
||||||
|
'{{count}} reviews': '{{count}} avis',
|
||||||
|
'Write a review': 'Écrire un avis',
|
||||||
|
'No reviews yet. Be the first to write one!':
|
||||||
|
'Pas encore d’avis. Soyez le premier à en écrire un !',
|
||||||
|
'View more reviews': 'Voir plus d’avis',
|
||||||
|
'Failed to review': 'Échec de l’avis',
|
||||||
|
'Write a review and pick a star rating': 'Écrivez un avis et choisissez une note en étoiles',
|
||||||
|
Submit: 'Soumettre',
|
||||||
|
'Reviews for {{relay}}': 'Avis pour {{relay}}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -411,6 +411,14 @@ export default {
|
|||||||
Never: 'कभी नहीं',
|
Never: 'कभी नहीं',
|
||||||
'Click to load image': 'इमेज लोड करने के लिए क्लिक करें',
|
'Click to load image': 'इमेज लोड करने के लिए क्लिक करें',
|
||||||
'Click to load media': 'मीडिया लोड करने के लिए क्लिक करें',
|
'Click to load media': 'मीडिया लोड करने के लिए क्लिक करें',
|
||||||
'Click to load YouTube video': 'YouTube वीडियो लोड करने के लिए क्लिक करें'
|
'Click to load YouTube video': 'YouTube वीडियो लोड करने के लिए क्लिक करें',
|
||||||
|
'{{count}} reviews': '{{count}} समीक्षाएं',
|
||||||
|
'Write a review': 'समीक्षा लिखें',
|
||||||
|
'No reviews yet. Be the first to write one!': 'अभी तक कोई समीक्षा नहीं। पहले लिखने वाले बनें!',
|
||||||
|
'View more reviews': 'और समीक्षाएं देखें',
|
||||||
|
'Failed to review': 'समीक्षा असफल',
|
||||||
|
'Write a review and pick a star rating': 'एक समीक्षा लिखें और स्टार रेटिंग चुनें',
|
||||||
|
Submit: 'सबमिट करें',
|
||||||
|
'Reviews for {{relay}}': '{{relay}} के लिए समीक्षाएं'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,6 +412,16 @@ export default {
|
|||||||
Never: 'Mai',
|
Never: 'Mai',
|
||||||
'Click to load image': "Clicca per caricare l'immagine",
|
'Click to load image': "Clicca per caricare l'immagine",
|
||||||
'Click to load media': 'Clicca per caricare i media',
|
'Click to load media': 'Clicca per caricare i media',
|
||||||
'Click to load YouTube video': 'Clicca per caricare il video di YouTube'
|
'Click to load YouTube video': 'Clicca per caricare il video di YouTube',
|
||||||
|
'{{count}} reviews': '{{count}} recensioni',
|
||||||
|
'Write a review': 'Scrivi una recensione',
|
||||||
|
'No reviews yet. Be the first to write one!':
|
||||||
|
'Nessuna recensione ancora. Sii il primo a scriverne una!',
|
||||||
|
'View more reviews': 'Visualizza più recensioni',
|
||||||
|
'Failed to review': 'Recensione fallita',
|
||||||
|
'Write a review and pick a star rating':
|
||||||
|
'Scrivi una recensione e scegli una valutazione a stelle',
|
||||||
|
Submit: 'Invia',
|
||||||
|
'Reviews for {{relay}}': 'Recensioni per {{relay}}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,6 +409,15 @@ export default {
|
|||||||
Never: 'しない',
|
Never: 'しない',
|
||||||
'Click to load image': 'クリックして画像を読み込む',
|
'Click to load image': 'クリックして画像を読み込む',
|
||||||
'Click to load media': 'クリックしてメディアを読み込む',
|
'Click to load media': 'クリックしてメディアを読み込む',
|
||||||
'Click to load YouTube video': 'クリックしてYouTubeビデオを読み込む'
|
'Click to load YouTube video': 'クリックしてYouTubeビデオを読み込む',
|
||||||
|
'{{count}} reviews': '{{count}}件のレビュー',
|
||||||
|
'Write a review': 'レビューを書く',
|
||||||
|
'No reviews yet. Be the first to write one!':
|
||||||
|
'まだレビューがありません。最初のレビューを書いてみませんか!',
|
||||||
|
'View more reviews': 'もっとレビューを見る',
|
||||||
|
'Failed to review': 'レビュー失敗',
|
||||||
|
'Write a review and pick a star rating': 'レビューを書いて星評価を選択してください',
|
||||||
|
Submit: '送信',
|
||||||
|
'Reviews for {{relay}}': '{{relay}} のレビュー'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,6 +409,15 @@ export default {
|
|||||||
Never: '안함',
|
Never: '안함',
|
||||||
'Click to load image': '이미지 로드하려면 클릭',
|
'Click to load image': '이미지 로드하려면 클릭',
|
||||||
'Click to load media': '미디어 로드하려면 클릭',
|
'Click to load media': '미디어 로드하려면 클릭',
|
||||||
'Click to load YouTube video': 'YouTube 비디오 로드하려면 클릭'
|
'Click to load YouTube video': 'YouTube 비디오 로드하려면 클릭',
|
||||||
|
'{{count}} reviews': '{{count}}개 리뷰',
|
||||||
|
'Write a review': '리뷰 작성',
|
||||||
|
'No reviews yet. Be the first to write one!':
|
||||||
|
'아직 리뷰가 없습니다. 첫 번째 리뷰를 작성해보세요!',
|
||||||
|
'View more reviews': '더 많은 리뷰 보기',
|
||||||
|
'Failed to review': '리뷰 실패',
|
||||||
|
'Write a review and pick a star rating': '리뷰를 작성하고 별점을 선택하세요',
|
||||||
|
Submit: '제출',
|
||||||
|
'Reviews for {{relay}}': '{{relay}}에 대한 리뷰'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -413,6 +413,15 @@ export default {
|
|||||||
Never: 'Nigdy',
|
Never: 'Nigdy',
|
||||||
'Click to load image': 'Kliknij, aby załadować obraz',
|
'Click to load image': 'Kliknij, aby załadować obraz',
|
||||||
'Click to load media': 'Kliknij, aby załadować media',
|
'Click to load media': 'Kliknij, aby załadować media',
|
||||||
'Click to load YouTube video': 'Kliknij, aby załadować wideo z YouTube'
|
'Click to load YouTube video': 'Kliknij, aby załadować wideo z YouTube',
|
||||||
|
'{{count}} reviews': '{{count}} opinii',
|
||||||
|
'Write a review': 'Napisz opinię',
|
||||||
|
'No reviews yet. Be the first to write one!':
|
||||||
|
'Jeszcze brak opinii. Bądź pierwszym, który napisze!',
|
||||||
|
'View more reviews': 'Zobacz więcej opinii',
|
||||||
|
'Failed to review': 'Błąd opinii',
|
||||||
|
'Write a review and pick a star rating': 'Napisz opinię i wybierz ocenę gwiazdkową',
|
||||||
|
Submit: 'Prześlij',
|
||||||
|
'Reviews for {{relay}}': 'Opinie o {{relay}}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,6 +409,16 @@ export default {
|
|||||||
Never: 'Nunca',
|
Never: 'Nunca',
|
||||||
'Click to load image': 'Clique para carregar a imagem',
|
'Click to load image': 'Clique para carregar a imagem',
|
||||||
'Click to load media': 'Clique para carregar a mídia',
|
'Click to load media': 'Clique para carregar a mídia',
|
||||||
'Click to load YouTube video': 'Clique para carregar o vídeo do YouTube'
|
'Click to load YouTube video': 'Clique para carregar o vídeo do YouTube',
|
||||||
|
'{{count}} reviews': '{{count}} avaliações',
|
||||||
|
'Write a review': 'Escrever uma avaliação',
|
||||||
|
'No reviews yet. Be the first to write one!':
|
||||||
|
'Ainda não há avaliações. Seja o primeiro a escrever uma!',
|
||||||
|
'View more reviews': 'Ver mais avaliações',
|
||||||
|
'Failed to review': 'Falha ao avaliar',
|
||||||
|
'Write a review and pick a star rating':
|
||||||
|
'Escreva uma avaliação e escolha uma classificação por estrelas',
|
||||||
|
Submit: 'Enviar',
|
||||||
|
'Reviews for {{relay}}': 'Avaliações para {{relay}}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,6 +412,16 @@ export default {
|
|||||||
Never: 'Nunca',
|
Never: 'Nunca',
|
||||||
'Click to load image': 'Clique para carregar a imagem',
|
'Click to load image': 'Clique para carregar a imagem',
|
||||||
'Click to load media': 'Clique para carregar a mídia',
|
'Click to load media': 'Clique para carregar a mídia',
|
||||||
'Click to load YouTube video': 'Clique para carregar o vídeo do YouTube'
|
'Click to load YouTube video': 'Clique para carregar o vídeo do YouTube',
|
||||||
|
'{{count}} reviews': '{{count}} avaliações',
|
||||||
|
'Write a review': 'Escrever uma avaliação',
|
||||||
|
'No reviews yet. Be the first to write one!':
|
||||||
|
'Ainda não há avaliações. Seja o primeiro a escrever uma!',
|
||||||
|
'View more reviews': 'Ver mais avaliações',
|
||||||
|
'Failed to review': 'Falha ao avaliar',
|
||||||
|
'Write a review and pick a star rating':
|
||||||
|
'Escreva uma avaliação e escolha uma classificação por estrelas',
|
||||||
|
Submit: 'Enviar',
|
||||||
|
'Reviews for {{relay}}': 'Avaliações para {{relay}}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -414,6 +414,15 @@ export default {
|
|||||||
Never: 'Никогда',
|
Never: 'Никогда',
|
||||||
'Click to load image': 'Нажмите, чтобы загрузить изображение',
|
'Click to load image': 'Нажмите, чтобы загрузить изображение',
|
||||||
'Click to load media': 'Нажмите, чтобы загрузить медиа',
|
'Click to load media': 'Нажмите, чтобы загрузить медиа',
|
||||||
'Click to load YouTube video': 'Нажмите, чтобы загрузить видео YouTube'
|
'Click to load YouTube video': 'Нажмите, чтобы загрузить видео YouTube',
|
||||||
|
'{{count}} reviews': '{{count}} отзывов',
|
||||||
|
'Write a review': 'Написать отзыв',
|
||||||
|
'No reviews yet. Be the first to write one!':
|
||||||
|
'Отзывов пока нет. Станьте первым, кто напишет отзыв!',
|
||||||
|
'View more reviews': 'Посмотреть больше отзывов',
|
||||||
|
'Failed to review': 'Ошибка отзыва',
|
||||||
|
'Write a review and pick a star rating': 'Напишите отзыв и выберите звездный рейтинг',
|
||||||
|
Submit: 'Отправить',
|
||||||
|
'Reviews for {{relay}}': 'Отзывы для {{relay}}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,6 +405,14 @@ export default {
|
|||||||
Never: 'ไม่เลย',
|
Never: 'ไม่เลย',
|
||||||
'Click to load image': 'คลิกเพื่อโหลดรูปภาพ',
|
'Click to load image': 'คลิกเพื่อโหลดรูปภาพ',
|
||||||
'Click to load media': 'คลิกเพื่อโหลดสื่อ',
|
'Click to load media': 'คลิกเพื่อโหลดสื่อ',
|
||||||
'Click to load YouTube video': 'คลิกเพื่อโหลดวิดีโอ YouTube'
|
'Click to load YouTube video': 'คลิกเพื่อโหลดวิดีโอ YouTube',
|
||||||
|
'{{count}} reviews': '{{count}} รีวิว',
|
||||||
|
'Write a review': 'เขียนรีวิว',
|
||||||
|
'No reviews yet. Be the first to write one!': 'ยังไม่มีรีวิว เป็นคนแรกที่เขียนรีวิวสิ!',
|
||||||
|
'View more reviews': 'ดูรีวิวเพิ่มเติม',
|
||||||
|
'Failed to review': 'รีวิวล้มเหลว',
|
||||||
|
'Write a review and pick a star rating': 'เขียนรีวิวและเลือกคะแนนดาว',
|
||||||
|
Submit: 'ส่ง',
|
||||||
|
'Reviews for {{relay}}': 'รีวิวสำหรับ {{relay}}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,6 +403,14 @@ export default {
|
|||||||
Never: '从不',
|
Never: '从不',
|
||||||
'Click to load image': '点击加载图片',
|
'Click to load image': '点击加载图片',
|
||||||
'Click to load media': '点击加载音视频',
|
'Click to load media': '点击加载音视频',
|
||||||
'Click to load YouTube video': '点击加载 YouTube 视频'
|
'Click to load YouTube video': '点击加载 YouTube 视频',
|
||||||
|
'{{count}} reviews': '{{count}} 条评价',
|
||||||
|
'Write a review': '写评价',
|
||||||
|
'No reviews yet. Be the first to write one!': '还没有评价,成为第一个评价的人吧!',
|
||||||
|
'View more reviews': '查看更多评价',
|
||||||
|
'Failed to review': '评价失败',
|
||||||
|
'Write a review and pick a star rating': '写下评价并选择星级评分',
|
||||||
|
Submit: '提交',
|
||||||
|
'Reviews for {{relay}}': '关于 {{relay}} 的评价'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,6 +459,22 @@ export function createReportDraftEvent(event: Event, reason: string): TDraftEven
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createRelayReviewDraftEvent(
|
||||||
|
relay: string,
|
||||||
|
review: string,
|
||||||
|
stars: number
|
||||||
|
): TDraftEvent {
|
||||||
|
return {
|
||||||
|
kind: ExtendedKind.RELAY_REVIEW,
|
||||||
|
content: review,
|
||||||
|
tags: [
|
||||||
|
['d', relay],
|
||||||
|
['rating', (stars / 5).toString()]
|
||||||
|
],
|
||||||
|
created_at: dayjs().unix()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function generateImetaTags(imageUrls: string[]) {
|
function generateImetaTags(imageUrls: string[]) {
|
||||||
return imageUrls
|
return imageUrls
|
||||||
.map((imageUrl) => {
|
.map((imageUrl) => {
|
||||||
|
|||||||
@@ -369,3 +369,14 @@ export function getEmojisFromEvent(event: Event): TEmoji[] {
|
|||||||
|
|
||||||
return emojis
|
return emojis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStarsFromRelayReviewEvent(event: Event): number {
|
||||||
|
const ratingTag = event.tags.find((t) => t[0] === 'rating')
|
||||||
|
if (ratingTag) {
|
||||||
|
const stars = parseFloat(ratingTag[1]) * 5
|
||||||
|
if (stars > 0 && stars <= 5) {
|
||||||
|
return stars
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export const toGeneralSettings = () => '/settings/general'
|
|||||||
export const toTranslation = () => '/settings/translation'
|
export const toTranslation = () => '/settings/translation'
|
||||||
export const toProfileEditor = () => '/profile-editor'
|
export const toProfileEditor = () => '/profile-editor'
|
||||||
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
|
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
|
||||||
|
export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews`
|
||||||
export const toMuteList = () => '/mutes'
|
export const toMuteList = () => '/mutes'
|
||||||
|
|
||||||
export const toChachiChat = (relay: string, d: string) => {
|
export const toChachiChat = (relay: string, d: string) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import BookmarkList from '@/components/BookmarkList'
|
import BookmarkList from '@/components/BookmarkList'
|
||||||
import PostEditor from '@/components/PostEditor'
|
import PostEditor from '@/components/PostEditor'
|
||||||
|
import RelayInfo from '@/components/RelayInfo'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
||||||
import { toSearch } from '@/lib/link'
|
import { toSearch } from '@/lib/link'
|
||||||
@@ -8,8 +9,16 @@ import { useFeed } from '@/providers/FeedProvider'
|
|||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { TPageRef } from '@/types'
|
import { TPageRef } from '@/types'
|
||||||
import { PencilLine, Search } from 'lucide-react'
|
import { Info, PencilLine, Search } from 'lucide-react'
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
import {
|
||||||
|
Dispatch,
|
||||||
|
forwardRef,
|
||||||
|
SetStateAction,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import FeedButton from './FeedButton'
|
import FeedButton from './FeedButton'
|
||||||
import FollowingFeed from './FollowingFeed'
|
import FollowingFeed from './FollowingFeed'
|
||||||
@@ -20,6 +29,7 @@ const NoteListPage = forwardRef((_, ref) => {
|
|||||||
const layoutRef = useRef<TPageRef>(null)
|
const layoutRef = useRef<TPageRef>(null)
|
||||||
const { pubkey, checkLogin } = useNostr()
|
const { pubkey, checkLogin } = useNostr()
|
||||||
const { feedInfo, relayUrls, isReady } = useFeed()
|
const { feedInfo, relayUrls, isReady } = useFeed()
|
||||||
|
const [showRelayDetails, setShowRelayDetails] = useState(false)
|
||||||
useImperativeHandle(ref, () => layoutRef.current)
|
useImperativeHandle(ref, () => layoutRef.current)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,14 +64,29 @@ const NoteListPage = forwardRef((_, ref) => {
|
|||||||
} else if (feedInfo.feedType === 'following') {
|
} else if (feedInfo.feedType === 'following') {
|
||||||
content = <FollowingFeed />
|
content = <FollowingFeed />
|
||||||
} else {
|
} else {
|
||||||
content = <RelaysFeed />
|
content = (
|
||||||
|
<>
|
||||||
|
{showRelayDetails && feedInfo.feedType === 'relay' && !!feedInfo.id && (
|
||||||
|
<RelayInfo url={feedInfo.id!} className="mb-2 pt-3" />
|
||||||
|
)}
|
||||||
|
<RelaysFeed />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrimaryPageLayout
|
<PrimaryPageLayout
|
||||||
pageName="home"
|
pageName="home"
|
||||||
ref={layoutRef}
|
ref={layoutRef}
|
||||||
titlebar={<NoteListPageTitlebar />}
|
titlebar={
|
||||||
|
<NoteListPageTitlebar
|
||||||
|
layoutRef={layoutRef}
|
||||||
|
showRelayDetails={showRelayDetails}
|
||||||
|
setShowRelayDetails={
|
||||||
|
feedInfo.feedType === 'relay' && !!feedInfo.id ? setShowRelayDetails : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
displayScrollToTopButton
|
displayScrollToTopButton
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
@@ -71,19 +96,46 @@ const NoteListPage = forwardRef((_, ref) => {
|
|||||||
NoteListPage.displayName = 'NoteListPage'
|
NoteListPage.displayName = 'NoteListPage'
|
||||||
export default NoteListPage
|
export default NoteListPage
|
||||||
|
|
||||||
function NoteListPageTitlebar() {
|
function NoteListPageTitlebar({
|
||||||
|
layoutRef,
|
||||||
|
showRelayDetails,
|
||||||
|
setShowRelayDetails
|
||||||
|
}: {
|
||||||
|
layoutRef?: React.RefObject<TPageRef>
|
||||||
|
showRelayDetails?: boolean
|
||||||
|
setShowRelayDetails?: Dispatch<SetStateAction<boolean>>
|
||||||
|
}) {
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 items-center h-full justify-between">
|
<div className="flex gap-1 items-center h-full justify-between">
|
||||||
<FeedButton className="flex-1 max-w-fit w-0" />
|
<FeedButton className="flex-1 max-w-fit w-0" />
|
||||||
{isSmallScreen && (
|
|
||||||
<div className="shrink-0 flex gap-1 items-center">
|
<div className="shrink-0 flex gap-1 items-center">
|
||||||
|
{setShowRelayDetails && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="titlebar-icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowRelayDetails((show) => !show)
|
||||||
|
|
||||||
|
if (!showRelayDetails) {
|
||||||
|
layoutRef?.current?.scrollToTop('smooth')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={showRelayDetails ? 'bg-accent/50' : ''}
|
||||||
|
>
|
||||||
|
<Info />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isSmallScreen && (
|
||||||
|
<>
|
||||||
<SearchButton />
|
<SearchButton />
|
||||||
<PostButton />
|
<PostButton />
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
36
src/pages/secondary/RelayReviewsPage/index.tsx
Normal file
36
src/pages/secondary/RelayReviewsPage/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import NoteList from '@/components/NoteList'
|
||||||
|
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||||
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
|
import { normalizeUrl, simplifyUrl } from '@/lib/url'
|
||||||
|
import { forwardRef, useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import NotFoundPage from '../NotFoundPage'
|
||||||
|
|
||||||
|
const RelayReviewsPage = forwardRef(({ url, index }: { url?: string; index?: number }, ref) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url])
|
||||||
|
const title = useMemo(
|
||||||
|
() => (url ? t('Reviews for {{relay}}', { relay: simplifyUrl(url) }) : undefined),
|
||||||
|
[url]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!normalizedUrl) {
|
||||||
|
return <NotFoundPage ref={ref} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton>
|
||||||
|
<NoteList
|
||||||
|
showKinds={[ExtendedKind.RELAY_REVIEW]}
|
||||||
|
subRequests={[
|
||||||
|
{
|
||||||
|
urls: [normalizedUrl, ...BIG_RELAY_URLS],
|
||||||
|
filter: { '#d': [normalizedUrl] }
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SecondaryPageLayout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RelayReviewsPage.displayName = 'RelayReviewsPage'
|
||||||
|
export default RelayReviewsPage
|
||||||
@@ -11,6 +11,7 @@ import ProfileEditorPage from './pages/secondary/ProfileEditorPage'
|
|||||||
import ProfileListPage from './pages/secondary/ProfileListPage'
|
import ProfileListPage from './pages/secondary/ProfileListPage'
|
||||||
import ProfilePage from './pages/secondary/ProfilePage'
|
import ProfilePage from './pages/secondary/ProfilePage'
|
||||||
import RelayPage from './pages/secondary/RelayPage'
|
import RelayPage from './pages/secondary/RelayPage'
|
||||||
|
import RelayReviewsPage from './pages/secondary/RelayReviewsPage'
|
||||||
import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
|
import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
|
||||||
import SearchPage from './pages/secondary/SearchPage'
|
import SearchPage from './pages/secondary/SearchPage'
|
||||||
import SettingsPage from './pages/secondary/SettingsPage'
|
import SettingsPage from './pages/secondary/SettingsPage'
|
||||||
@@ -25,6 +26,7 @@ const ROUTES = [
|
|||||||
{ path: '/users/:id/following', element: <FollowingListPage /> },
|
{ path: '/users/:id/following', element: <FollowingListPage /> },
|
||||||
{ path: '/users/:id/relays', element: <OthersRelaySettingsPage /> },
|
{ path: '/users/:id/relays', element: <OthersRelaySettingsPage /> },
|
||||||
{ path: '/relays/:url', element: <RelayPage /> },
|
{ path: '/relays/:url', element: <RelayPage /> },
|
||||||
|
{ path: '/relays/:url/reviews', element: <RelayReviewsPage /> },
|
||||||
{ path: '/search', element: <SearchPage /> },
|
{ path: '/search', element: <SearchPage /> },
|
||||||
{ path: '/settings', element: <SettingsPage /> },
|
{ path: '/settings', element: <SettingsPage /> },
|
||||||
{ path: '/settings/relays', element: <RelaySettingsPage /> },
|
{ path: '/settings/relays', element: <RelaySettingsPage /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user