feat: search
This commit is contained in:
361
package-lock.json
generated
361
package-lock.json
generated
@@ -29,6 +29,7 @@
|
|||||||
"@radix-ui/react-toast": "^1.2.2",
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.0.0",
|
||||||
"dataloader": "^2.2.2",
|
"dataloader": "^2.2.2",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"framer-motion": "^11.11.17",
|
"framer-motion": "^11.11.17",
|
||||||
@@ -4693,6 +4694,366 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cmdk": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "1.0.5",
|
||||||
|
"@radix-ui/react-primitive": "1.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-dialog": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/primitive": "1.0.1",
|
||||||
|
"@radix-ui/react-compose-refs": "1.0.1",
|
||||||
|
"@radix-ui/react-context": "1.0.1",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.0.5",
|
||||||
|
"@radix-ui/react-focus-guards": "1.0.1",
|
||||||
|
"@radix-ui/react-focus-scope": "1.0.4",
|
||||||
|
"@radix-ui/react-id": "1.0.1",
|
||||||
|
"@radix-ui/react-portal": "1.0.4",
|
||||||
|
"@radix-ui/react-presence": "1.0.1",
|
||||||
|
"@radix-ui/react-primitive": "1.0.3",
|
||||||
|
"@radix-ui/react-slot": "1.0.2",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.0.1",
|
||||||
|
"aria-hidden": "^1.1.1",
|
||||||
|
"react-remove-scroll": "2.5.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/primitive": "1.0.1",
|
||||||
|
"@radix-ui/react-compose-refs": "1.0.1",
|
||||||
|
"@radix-ui/react-primitive": "1.0.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.0.1",
|
||||||
|
"@radix-ui/react-use-escape-keydown": "1.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-compose-refs": "1.0.1",
|
||||||
|
"@radix-ui/react-primitive": "1.0.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-id": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-portal": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-primitive": "1.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-compose-refs": "1.0.1",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-slot": "1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-compose-refs": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/react-remove-scroll": {
|
||||||
|
"version": "2.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz",
|
||||||
|
"integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==",
|
||||||
|
"dependencies": {
|
||||||
|
"react-remove-scroll-bar": "^2.3.3",
|
||||||
|
"react-style-singleton": "^2.2.1",
|
||||||
|
"tslib": "^2.1.0",
|
||||||
|
"use-callback-ref": "^1.3.0",
|
||||||
|
"use-sidecar": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color": {
|
"node_modules/color": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"@radix-ui/react-toast": "^1.2.2",
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.0.0",
|
||||||
"dataloader": "^2.2.2",
|
"dataloader": "^2.2.2",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"framer-motion": "^11.11.17",
|
"framer-motion": "^11.11.17",
|
||||||
|
|||||||
@@ -56,9 +56,8 @@ export function PageManager({
|
|||||||
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
|
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = window.location.pathname
|
if (window.location.pathname !== '/') {
|
||||||
if (url !== '/') {
|
pushSecondary(window.location.pathname + window.location.search)
|
||||||
pushSecondary(url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPopState = (e: PopStateEvent) => {
|
const onPopState = (e: PopStateEvent) => {
|
||||||
@@ -175,8 +174,9 @@ function isCurrentPage(stack: TStackItem[], url: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findAndCreateComponent(url: string) {
|
function findAndCreateComponent(url: string) {
|
||||||
|
const path = url.split('?')[0]
|
||||||
for (const { matcher, element } of routes) {
|
for (const { matcher, element } of routes) {
|
||||||
const match = matcher(url)
|
const match = matcher(path)
|
||||||
if (!match) continue
|
if (!match) continue
|
||||||
|
|
||||||
if (!element) return <NotFoundPage />
|
if (!element) return <NotFoundPage />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { toHashtag } from '@renderer/lib/link'
|
import { toNoteList } from '@renderer/lib/link'
|
||||||
import { SecondaryPageLink } from '@renderer/PageManager'
|
import { SecondaryPageLink } from '@renderer/PageManager'
|
||||||
import { TEmbeddedRenderer } from './types'
|
import { TEmbeddedRenderer } from './types'
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {
|
|||||||
return (
|
return (
|
||||||
<SecondaryPageLink
|
<SecondaryPageLink
|
||||||
className="text-highlight hover:underline"
|
className="text-highlight hover:underline"
|
||||||
to={toHashtag(hashtag)}
|
to={toNoteList({ hashtag })}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
#{hashtag}
|
#{hashtag}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
import { Input } from '@renderer/components/ui/input'
|
import { Input } from '@renderer/components/ui/input'
|
||||||
|
import { useFetchRelayInfos } from '@renderer/hooks'
|
||||||
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
|
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
|
||||||
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||||
import client from '@renderer/services/client.service'
|
import client from '@renderer/services/client.service'
|
||||||
import { CircleX } from 'lucide-react'
|
import { CircleX, SearchCheck } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -111,6 +112,9 @@ function RelayUrl({
|
|||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [relayInfo] = useFetchRelayInfos([url])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
@@ -122,6 +126,11 @@ function RelayUrl({
|
|||||||
<div className="text-red-500 text-xs">●</div>
|
<div className="text-red-500 text-xs">●</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-muted-foreground text-sm">{url}</div>
|
<div className="text-muted-foreground text-sm">{url}</div>
|
||||||
|
{relayInfo?.supported_nips?.includes(50) && (
|
||||||
|
<div title={t('supports search')} className="text-highlight">
|
||||||
|
<SearchCheck size={14} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CircleX
|
<CircleX
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { Button } from '@renderer/components/ui/button'
|
||||||
|
import { useFetchRelayInfos } from '@renderer/hooks'
|
||||||
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||||
import client from '@renderer/services/client.service'
|
import client from '@renderer/services/client.service'
|
||||||
import { Save } from 'lucide-react'
|
import { Save, SearchCheck } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Button } from '../ui/button'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function TemporaryRelayGroup() {
|
export default function TemporaryRelayGroup() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { temporaryRelayUrls, relayGroups, addRelayGroup, switchRelayGroup } = useRelaySettings()
|
const { temporaryRelayUrls, relayGroups, addRelayGroup, switchRelayGroup } = useRelaySettings()
|
||||||
const [relays, setRelays] = useState<
|
const [relays, setRelays] = useState<
|
||||||
{
|
{
|
||||||
@@ -12,6 +15,7 @@ export default function TemporaryRelayGroup() {
|
|||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
}[]
|
}[]
|
||||||
>(temporaryRelayUrls.map((url) => ({ url, isConnected: false })))
|
>(temporaryRelayUrls.map((url) => ({ url, isConnected: false })))
|
||||||
|
const relayInfos = useFetchRelayInfos(relays.map((relay) => relay.url))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -64,6 +68,11 @@ export default function TemporaryRelayGroup() {
|
|||||||
<div className="text-red-500 text-xs">●</div>
|
<div className="text-red-500 text-xs">●</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-muted-foreground text-sm">{relay.url}</div>
|
<div className="text-muted-foreground text-sm">{relay.url}</div>
|
||||||
|
{relayInfos[index]?.supported_nips?.includes(50) && (
|
||||||
|
<div title={t('supports search')} className="text-highlight">
|
||||||
|
<SearchCheck size={14} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
24
src/renderer/src/components/SearchButton/index.tsx
Normal file
24
src/renderer/src/components/SearchButton/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Button } from '@renderer/components/ui/button'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { SearchDialog } from '../SearchDialog'
|
||||||
|
|
||||||
|
export default function RefreshButton({
|
||||||
|
variant = 'titlebar'
|
||||||
|
}: {
|
||||||
|
variant?: 'titlebar' | 'sidebar'
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant={variant} size={variant} onClick={() => setOpen(true)} title={t('Search')}>
|
||||||
|
<Search />
|
||||||
|
{variant === 'sidebar' && <div>{t('Search')}</div>}
|
||||||
|
</Button>
|
||||||
|
<SearchDialog open={open} setOpen={setOpen} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
160
src/renderer/src/components/SearchDialog/index.tsx
Normal file
160
src/renderer/src/components/SearchDialog/index.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { SecondaryPageLink } from '@renderer/PageManager'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList
|
||||||
|
} from '@renderer/components/ui/command'
|
||||||
|
import { useSearchProfiles } from '@renderer/hooks'
|
||||||
|
import { toNote, toNoteList, toProfile, toProfileList } from '@renderer/lib/link'
|
||||||
|
import { generateImageByPubkey } from '@renderer/lib/pubkey'
|
||||||
|
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||||
|
import { TProfile } from '@renderer/types'
|
||||||
|
import { Hash, Notebook, UserRound } from 'lucide-react'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { Dispatch, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export function SearchDialog({ open, setOpen }: { open: boolean; setOpen: Dispatch<boolean> }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [debouncedInput, setDebouncedInput] = useState(input)
|
||||||
|
const { profiles } = useSearchProfiles(debouncedInput, 10)
|
||||||
|
|
||||||
|
const list = useMemo(() => {
|
||||||
|
const search = input.trim()
|
||||||
|
if (!search) return
|
||||||
|
|
||||||
|
if (/^[0-9a-f]{64}$/.test(search)) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NoteItem id={search} onClick={() => setOpen(false)} />
|
||||||
|
<ProfileIdItem id={search} onClick={() => setOpen(false)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let id = search
|
||||||
|
if (id.startsWith('nostr:')) {
|
||||||
|
id = id.slice(6)
|
||||||
|
}
|
||||||
|
const { type } = nip19.decode(id)
|
||||||
|
if (['nprofile', 'npub'].includes(type)) {
|
||||||
|
return <ProfileIdItem id={id} onClick={() => setOpen(false)} />
|
||||||
|
}
|
||||||
|
if (['nevent', 'naddr', 'note'].includes(type)) {
|
||||||
|
return <NoteItem id={id} onClick={() => setOpen(false)} />
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalItem search={search} onClick={() => setOpen(false)} />
|
||||||
|
<HashtagItem search={search} onClick={() => setOpen(false)} />
|
||||||
|
{profiles.map((profile) => (
|
||||||
|
<ProfileItem key={profile.pubkey} profile={profile} onClick={() => setOpen(false)} />
|
||||||
|
))}
|
||||||
|
{profiles.length >= 10 && (
|
||||||
|
<SecondaryPageLink to={toProfileList({ search })} onClick={() => setOpen(false)}>
|
||||||
|
<CommandItem onClick={() => setOpen(false)} className="text-center">
|
||||||
|
<div className="font-semibold">{t('Show more...')}</div>
|
||||||
|
</CommandItem>
|
||||||
|
</SecondaryPageLink>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}, [input, profiles])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedInput(input)
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler)
|
||||||
|
}
|
||||||
|
}, [input])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<CommandInput value={input} onValueChange={setInput} />
|
||||||
|
<CommandList>{list}</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) {
|
||||||
|
const { searchableRelayUrls } = useRelaySettings()
|
||||||
|
|
||||||
|
if (searchableRelayUrls.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryPageLink to={toNoteList({ search })} onClick={onClick}>
|
||||||
|
<CommandItem>
|
||||||
|
<Notebook className="text-muted-foreground" />
|
||||||
|
<div className="font-semibold">{search}</div>
|
||||||
|
</CommandItem>
|
||||||
|
</SecondaryPageLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) {
|
||||||
|
const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase()
|
||||||
|
return (
|
||||||
|
<SecondaryPageLink to={toNoteList({ hashtag })} onClick={onClick}>
|
||||||
|
<CommandItem value={`hashtag-${hashtag}`}>
|
||||||
|
<Hash className="text-muted-foreground" />
|
||||||
|
<div className="font-semibold">{hashtag}</div>
|
||||||
|
</CommandItem>
|
||||||
|
</SecondaryPageLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) {
|
||||||
|
return (
|
||||||
|
<SecondaryPageLink to={toNote(id)} onClick={onClick}>
|
||||||
|
<CommandItem>
|
||||||
|
<Notebook className="text-muted-foreground" />
|
||||||
|
<div className="font-semibold truncate">{id}</div>
|
||||||
|
</CommandItem>
|
||||||
|
</SecondaryPageLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) {
|
||||||
|
return (
|
||||||
|
<SecondaryPageLink to={toProfile(id)} onClick={onClick}>
|
||||||
|
<CommandItem>
|
||||||
|
<UserRound className="text-muted-foreground" />
|
||||||
|
<div className="font-semibold truncate">{id}</div>
|
||||||
|
</CommandItem>
|
||||||
|
</SecondaryPageLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileItem({ profile, onClick }: { profile: TProfile; onClick?: () => void }) {
|
||||||
|
return (
|
||||||
|
<SecondaryPageLink to={toProfile(profile.pubkey)} onClick={onClick}>
|
||||||
|
<CommandItem>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={profile.avatar} alt={profile.username} />
|
||||||
|
<AvatarFallback>
|
||||||
|
<img src={generateImageByPubkey(profile.pubkey)} alt={profile.username} />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{profile.username}</div>
|
||||||
|
<div className="line-clamp-1 text-muted-foreground">{profile.about}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
</SecondaryPageLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@ import { IS_ELECTRON } from '@renderer/lib/env'
|
|||||||
import { toHome } from '@renderer/lib/link'
|
import { toHome } from '@renderer/lib/link'
|
||||||
import { SecondaryPageLink } from '@renderer/PageManager'
|
import { SecondaryPageLink } from '@renderer/PageManager'
|
||||||
import { Info } from 'lucide-react'
|
import { Info } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import AboutInfoDialog from '../AboutInfoDialog'
|
import AboutInfoDialog from '../AboutInfoDialog'
|
||||||
import AccountButton from '../AccountButton'
|
import AccountButton from '../AccountButton'
|
||||||
import PostButton from '../PostButton'
|
import PostButton from '../PostButton'
|
||||||
import RefreshButton from '../RefreshButton'
|
import RefreshButton from '../RefreshButton'
|
||||||
import RelaySettingsPopover from '../RelaySettingsPopover'
|
import RelaySettingsPopover from '../RelaySettingsPopover'
|
||||||
import { useTranslation } from 'react-i18next'
|
import SearchButton from '../SearchButton'
|
||||||
|
|
||||||
export default function PrimaryPageSidebar() {
|
export default function PrimaryPageSidebar() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -20,6 +21,7 @@ export default function PrimaryPageSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
<PostButton variant="sidebar" />
|
<PostButton variant="sidebar" />
|
||||||
<RelaySettingsPopover variant="sidebar" />
|
<RelaySettingsPopover variant="sidebar" />
|
||||||
|
<SearchButton variant="sidebar" />
|
||||||
<RefreshButton variant="sidebar" />
|
<RefreshButton variant="sidebar" />
|
||||||
{!IS_ELECTRON && (
|
{!IS_ELECTRON && (
|
||||||
<AboutInfoDialog>
|
<AboutInfoDialog>
|
||||||
|
|||||||
22
src/renderer/src/components/UserItem/index.tsx
Normal file
22
src/renderer/src/components/UserItem/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import FollowButton from '@renderer/components/FollowButton'
|
||||||
|
import Nip05 from '@renderer/components/Nip05'
|
||||||
|
import UserAvatar from '@renderer/components/UserAvatar'
|
||||||
|
import Username from '@renderer/components/Username'
|
||||||
|
import { useFetchProfile } from '@renderer/hooks'
|
||||||
|
|
||||||
|
export default function UserItem({ pubkey }: { pubkey: string }) {
|
||||||
|
const { profile } = useFetchProfile(pubkey)
|
||||||
|
const { nip05, about } = profile || {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 items-start">
|
||||||
|
<UserAvatar userId={pubkey} className="shrink-0" />
|
||||||
|
<div className="w-full overflow-hidden">
|
||||||
|
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
|
||||||
|
<Nip05 nip05={nip05} pubkey={pubkey} />
|
||||||
|
<div className="truncate text-muted-foreground text-sm">{about}</div>
|
||||||
|
</div>
|
||||||
|
<FollowButton pubkey={pubkey} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
src/renderer/src/components/ui/command.tsx
Normal file
143
src/renderer/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { type DialogProps } from '@radix-ui/react-dialog'
|
||||||
|
import { Command as CommandPrimitive } from 'cmdk'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { Dialog, DialogContent } from '@renderer/components/ui/dialog'
|
||||||
|
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||||
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Command.displayName = CommandPrimitive.displayName
|
||||||
|
|
||||||
|
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent className="overflow-hidden p-0 shadow-lg top-4 translate-y-0 data-[state=closed]:slide-out-to-top-0 data-[state=open]:slide-in-from-top-0">
|
||||||
|
<Command
|
||||||
|
shouldFilter={false}
|
||||||
|
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 pr-6',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ScrollArea className="max-h-[80vh]">
|
||||||
|
<CommandPrimitive.List ref={ref} className={cn('overflow-x-hidden', className)} {...props} />
|
||||||
|
</ScrollArea>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 h-px bg-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CommandShortcut.displayName = 'CommandShortcut'
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
CommandShortcut
|
||||||
|
}
|
||||||
@@ -3,3 +3,6 @@ export * from './useFetchEventById'
|
|||||||
export * from './useFetchFollowings'
|
export * from './useFetchFollowings'
|
||||||
export * from './useFetchNip05'
|
export * from './useFetchNip05'
|
||||||
export * from './useFetchProfile'
|
export * from './useFetchProfile'
|
||||||
|
export * from './useFetchRelayInfos'
|
||||||
|
export * from './useSearchParams'
|
||||||
|
export * from './useSearchProfiles'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function useFetchEventById(id?: string) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchEvent = async () => {
|
const fetchEvent = async () => {
|
||||||
|
setIsFetching(true)
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setIsFetching(false)
|
setIsFetching(false)
|
||||||
setError(new Error('No id provided'))
|
setError(new Error('No id provided'))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function useFetchProfile(id?: string) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
|
setIsFetching(true)
|
||||||
try {
|
try {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setIsFetching(false)
|
setIsFetching(false)
|
||||||
|
|||||||
23
src/renderer/src/hooks/useFetchRelayInfos.tsx
Normal file
23
src/renderer/src/hooks/useFetchRelayInfos.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import client from '@renderer/services/client.service'
|
||||||
|
import { TRelayInfo } from '@renderer/types'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function useFetchRelayInfos(urls: string[]) {
|
||||||
|
const [relayInfos, setRelayInfos] = useState<(TRelayInfo | undefined)[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRelayInfos = async () => {
|
||||||
|
if (urls.length === 0) return
|
||||||
|
try {
|
||||||
|
const relayInfos = await client.fetchRelayInfos(urls)
|
||||||
|
setRelayInfos(relayInfos)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRelayInfos()
|
||||||
|
}, [JSON.stringify(urls)])
|
||||||
|
|
||||||
|
return relayInfos
|
||||||
|
}
|
||||||
24
src/renderer/src/hooks/useSearchParams.tsx
Normal file
24
src/renderer/src/hooks/useSearchParams.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export function useSearchParams() {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchParams,
|
||||||
|
get: (key: string) => searchParams.get(key),
|
||||||
|
set: (key: string, value: string) => {
|
||||||
|
searchParams.set(key, value)
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
`${window.location.pathname}?${searchParams.toString()}`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
delete: (key: string) => {
|
||||||
|
searchParams.delete(key)
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
`${window.location.pathname}?${searchParams.toString()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/renderer/src/hooks/useSearchProfiles.tsx
Normal file
39
src/renderer/src/hooks/useSearchProfiles.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||||
|
import client from '@renderer/services/client.service'
|
||||||
|
import { TProfile } from '@renderer/types'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function useSearchProfiles(search: string, limit: number) {
|
||||||
|
const { searchableRelayUrls } = useRelaySettings()
|
||||||
|
const [isFetching, setIsFetching] = useState(true)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
const [profiles, setProfiles] = useState<TProfile[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfiles = async () => {
|
||||||
|
setIsFetching(true)
|
||||||
|
setProfiles([])
|
||||||
|
if (searchableRelayUrls.length === 0) {
|
||||||
|
setIsFetching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const profiles = await client.fetchProfiles(searchableRelayUrls, {
|
||||||
|
search,
|
||||||
|
limit
|
||||||
|
})
|
||||||
|
if (profiles) {
|
||||||
|
setProfiles(profiles)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err as Error)
|
||||||
|
} finally {
|
||||||
|
setIsFetching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProfiles()
|
||||||
|
}, [searchableRelayUrls, search, limit])
|
||||||
|
|
||||||
|
return { isFetching, error, profiles }
|
||||||
|
}
|
||||||
@@ -62,6 +62,13 @@ export default {
|
|||||||
'Lost in the void': 'Lost in the void',
|
'Lost in the void': 'Lost in the void',
|
||||||
'Carry me home': 'Carry me home',
|
'Carry me home': 'Carry me home',
|
||||||
'no replies': 'no replies',
|
'no replies': 'no replies',
|
||||||
'Reply to': 'Reply to'
|
'Reply to': 'Reply to',
|
||||||
|
Search: 'Search',
|
||||||
|
search: 'search',
|
||||||
|
'The relays you are connected to do not support search':
|
||||||
|
'The relays you are connected to do not support search',
|
||||||
|
'supports search': 'supports search',
|
||||||
|
'Show more...': 'Show more...',
|
||||||
|
'all users': 'all users'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export default {
|
|||||||
'Lost in the void': '迷失在虚空中',
|
'Lost in the void': '迷失在虚空中',
|
||||||
'Carry me home': '带我回家',
|
'Carry me home': '带我回家',
|
||||||
'no replies': '暂无回复',
|
'no replies': '暂无回复',
|
||||||
'Reply to': '回复'
|
'Reply to': '回复',
|
||||||
|
Search: '搜索',
|
||||||
|
search: '搜索',
|
||||||
|
'The relays you are connected to do not support search': '您连接的服务器不支持搜索',
|
||||||
|
'supports search': '支持搜索',
|
||||||
|
'Show more...': '查看更多...',
|
||||||
|
'all users': '所有用户'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,40 +3,33 @@ import PostButton from '@renderer/components/PostButton'
|
|||||||
import RefreshButton from '@renderer/components/RefreshButton'
|
import RefreshButton from '@renderer/components/RefreshButton'
|
||||||
import RelaySettingsPopover from '@renderer/components/RelaySettingsPopover'
|
import RelaySettingsPopover from '@renderer/components/RelaySettingsPopover'
|
||||||
import ScrollToTopButton from '@renderer/components/ScrollToTopButton'
|
import ScrollToTopButton from '@renderer/components/ScrollToTopButton'
|
||||||
|
import SearchButton from '@renderer/components/SearchButton'
|
||||||
import { Titlebar } from '@renderer/components/Titlebar'
|
import { Titlebar } from '@renderer/components/Titlebar'
|
||||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||||
import { isMacOS } from '@renderer/lib/env'
|
import { isMacOS } from '@renderer/lib/env'
|
||||||
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||||
|
|
||||||
const PrimaryPageLayout = forwardRef(
|
const PrimaryPageLayout = forwardRef(({ children }: { children?: React.ReactNode }, ref) => {
|
||||||
(
|
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||||
{
|
|
||||||
children,
|
|
||||||
titlebarContent
|
|
||||||
}: { children?: React.ReactNode; titlebarContent?: React.ReactNode },
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
scrollToTop: () => {
|
scrollToTop: () => {
|
||||||
scrollAreaRef.current?.scrollTo({ top: 0 })
|
scrollAreaRef.current?.scrollTo({ top: 0 })
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea ref={scrollAreaRef} className="h-full w-full" scrollBarClassName="pt-9 xl:pt-0">
|
<ScrollArea ref={scrollAreaRef} className="h-full w-full" scrollBarClassName="pt-9 xl:pt-0">
|
||||||
<PrimaryPageTitlebar content={titlebarContent} />
|
<PrimaryPageTitlebar />
|
||||||
<div className="px-4 pb-4 pt-11 xl:pt-4">{children}</div>
|
<div className="px-4 pb-4 pt-11 xl:pt-4">{children}</div>
|
||||||
<ScrollToTopButton scrollAreaRef={scrollAreaRef} />
|
<ScrollToTopButton scrollAreaRef={scrollAreaRef} />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
)
|
|
||||||
PrimaryPageLayout.displayName = 'PrimaryPageLayout'
|
PrimaryPageLayout.displayName = 'PrimaryPageLayout'
|
||||||
export default PrimaryPageLayout
|
export default PrimaryPageLayout
|
||||||
|
|
||||||
@@ -44,13 +37,13 @@ export type TPrimaryPageLayoutRef = {
|
|||||||
scrollToTop: () => void
|
scrollToTop: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode }) {
|
export function PrimaryPageTitlebar() {
|
||||||
return (
|
return (
|
||||||
<Titlebar className={`justify-between xl:hidden ${isMacOS() ? 'pl-20' : ''}`}>
|
<Titlebar className={`justify-between xl:hidden ${isMacOS() ? 'pl-20' : ''}`}>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<AccountButton />
|
<AccountButton />
|
||||||
<PostButton />
|
<PostButton />
|
||||||
{content}
|
<SearchButton />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<RefreshButton />
|
<RefreshButton />
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
export const toHome = () => '/'
|
export const toHome = () => '/'
|
||||||
export const toProfile = (pubkey: string) => `/user/${pubkey}`
|
|
||||||
export const toNote = (eventId: string) => `/note/${eventId}`
|
export const toNote = (eventId: string) => `/note/${eventId}`
|
||||||
export const toHashtag = (hashtag: string) => `/hashtag/${hashtag}`
|
export const toNoteList = ({ hashtag, search }: { hashtag?: string; search?: string }) => {
|
||||||
|
const path = '/note'
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (hashtag) query.set('t', hashtag.toLowerCase())
|
||||||
|
if (search) query.set('s', search)
|
||||||
|
return `${path}?${query.toString()}`
|
||||||
|
}
|
||||||
|
export const toProfile = (pubkey: string) => `/user/${pubkey}`
|
||||||
|
export const toProfileList = ({ search }: { search?: string }) => {
|
||||||
|
const path = '/user'
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (search) query.set('s', search)
|
||||||
|
return `${path}?${query.toString()}`
|
||||||
|
}
|
||||||
export const toFollowingList = (pubkey: string) => `/user/${pubkey}/following`
|
export const toFollowingList = (pubkey: string) => `/user/${pubkey}/following`
|
||||||
|
|
||||||
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
|
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import FollowButton from '@renderer/components/FollowButton'
|
import UserItem from '@renderer/components/UserItem'
|
||||||
import Nip05 from '@renderer/components/Nip05'
|
|
||||||
import UserAvatar from '@renderer/components/UserAvatar'
|
|
||||||
import Username from '@renderer/components/Username'
|
|
||||||
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
|
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
|
||||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
@@ -56,27 +53,10 @@ export default function FollowingListPage({ id }: { id?: string }) {
|
|||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{visibleFollowings.map((pubkey, index) => (
|
{visibleFollowings.map((pubkey, index) => (
|
||||||
<FollowingItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||||
))}
|
))}
|
||||||
{followings.length > visibleFollowings.length && <div ref={bottomRef} />}
|
{followings.length > visibleFollowings.length && <div ref={bottomRef} />}
|
||||||
</div>
|
</div>
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FollowingItem({ pubkey }: { pubkey: string }) {
|
|
||||||
const { profile } = useFetchProfile(pubkey)
|
|
||||||
const { nip05, about } = profile || {}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2 items-start">
|
|
||||||
<UserAvatar userId={pubkey} className="shrink-0" />
|
|
||||||
<div className="w-full overflow-hidden">
|
|
||||||
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
|
|
||||||
<Nip05 nip05={nip05} pubkey={pubkey} />
|
|
||||||
<div className="truncate text-muted-foreground text-sm">{about}</div>
|
|
||||||
</div>
|
|
||||||
<FollowButton pubkey={pubkey} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import NoteList from '@renderer/components/NoteList'
|
|
||||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
|
||||||
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
|
||||||
import NotFoundPage from '../NotFoundPage'
|
|
||||||
|
|
||||||
export default function HashtagPage({ id }: { id?: string }) {
|
|
||||||
const { relayUrls } = useRelaySettings()
|
|
||||||
if (!id) {
|
|
||||||
return <NotFoundPage />
|
|
||||||
}
|
|
||||||
const hashtag = id.toLowerCase()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SecondaryPageLayout titlebarContent={`# ${hashtag}`}>
|
|
||||||
<NoteList key={hashtag} filter={{ '#t': [hashtag] }} relayUrls={relayUrls} />
|
|
||||||
</SecondaryPageLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,14 @@
|
|||||||
import { Button } from '@renderer/components/ui/button'
|
|
||||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||||
import { toHome } from '@renderer/lib/link'
|
|
||||||
import { useSecondaryPage } from '@renderer/PageManager'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
export default function NotFoundPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { push } = useSecondaryPage()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout hideBackButton>
|
<SecondaryPageLayout hideBackButton>
|
||||||
<div className="text-muted-foreground w-full h-full flex flex-col items-center justify-center gap-2">
|
<div className="text-muted-foreground w-full h-full flex flex-col items-center justify-center gap-2">
|
||||||
<div>{t('Lost in the void')} 🌌</div>
|
<div>{t('Lost in the void')} 🌌</div>
|
||||||
<div>(404)</div>
|
<div>(404)</div>
|
||||||
<Button variant="secondary" onClick={() => push(toHome())}>
|
|
||||||
{t('Carry me home')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
40
src/renderer/src/pages/secondary/NoteListPage/index.tsx
Normal file
40
src/renderer/src/pages/secondary/NoteListPage/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import NoteList from '@renderer/components/NoteList'
|
||||||
|
import { useSearchParams } from '@renderer/hooks'
|
||||||
|
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||||
|
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||||
|
import { Filter } from 'nostr-tools'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function NoteListPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { relayUrls, searchableRelayUrls } = useRelaySettings()
|
||||||
|
const { searchParams } = useSearchParams()
|
||||||
|
const [title, filter] = useMemo<[string, Filter] | [undefined, undefined]>(() => {
|
||||||
|
const hashtag = searchParams.get('t')
|
||||||
|
if (hashtag) {
|
||||||
|
return [`# ${hashtag}`, { '#t': [hashtag] }]
|
||||||
|
}
|
||||||
|
const search = searchParams.get('s')
|
||||||
|
if (search) {
|
||||||
|
return [`${t('search')}: ${search}`, { search }]
|
||||||
|
}
|
||||||
|
return [undefined, undefined]
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
if (!filter || (filter.search && searchableRelayUrls.length === 0)) {
|
||||||
|
return (
|
||||||
|
<SecondaryPageLayout titlebarContent={title}>
|
||||||
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
{t('The relays you are connected to do not support search')}
|
||||||
|
</div>
|
||||||
|
</SecondaryPageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryPageLayout titlebarContent={title}>
|
||||||
|
<NoteList key={title} filter={filter} relayUrls={relayUrls} />
|
||||||
|
</SecondaryPageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
src/renderer/src/pages/secondary/ProfileListPage/index.tsx
Normal file
89
src/renderer/src/pages/secondary/ProfileListPage/index.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import UserItem from '@renderer/components/UserItem'
|
||||||
|
import { useSearchParams } from '@renderer/hooks'
|
||||||
|
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||||
|
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||||
|
import client from '@renderer/services/client.service'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { Filter } from 'nostr-tools'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const LIMIT = 50
|
||||||
|
|
||||||
|
export default function ProfileListPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { searchParams } = useSearchParams()
|
||||||
|
const { relayUrls, searchableRelayUrls } = useRelaySettings()
|
||||||
|
const [until, setUntil] = useState<number>(() => dayjs().unix())
|
||||||
|
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||||
|
const [pubkeySet, setPubkeySet] = useState(new Set<string>())
|
||||||
|
const observer = useRef<IntersectionObserver | null>(null)
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const filter = useMemo(() => {
|
||||||
|
const f: Filter = { until }
|
||||||
|
const search = searchParams.get('s')
|
||||||
|
if (search) {
|
||||||
|
f.search = search
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}, [searchParams, until])
|
||||||
|
const urls = useMemo(() => {
|
||||||
|
return filter.search ? searchableRelayUrls : relayUrls
|
||||||
|
}, [relayUrls, searchableRelayUrls, filter])
|
||||||
|
const title = useMemo(() => {
|
||||||
|
return filter.search ? `${t('search')}: ${filter.search}` : t('all users')
|
||||||
|
}, [filter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasMore) return
|
||||||
|
const options = {
|
||||||
|
root: null,
|
||||||
|
rootMargin: '10px',
|
||||||
|
threshold: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
observer.current = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasMore) {
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
}, options)
|
||||||
|
|
||||||
|
if (bottomRef.current) {
|
||||||
|
observer.current.observe(bottomRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (observer.current && bottomRef.current) {
|
||||||
|
observer.current.unobserve(bottomRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [filter, hasMore])
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (urls.length === 0) {
|
||||||
|
return setHasMore(false)
|
||||||
|
}
|
||||||
|
const profiles = await client.fetchProfiles(urls, { ...filter, limit: LIMIT })
|
||||||
|
const newPubkeySet = new Set<string>()
|
||||||
|
profiles.forEach((profile) => {
|
||||||
|
if (!pubkeySet.has(profile.pubkey)) {
|
||||||
|
newPubkeySet.add(profile.pubkey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setPubkeySet((prev) => new Set([...prev, ...newPubkeySet]))
|
||||||
|
setHasMore(profiles.length >= LIMIT)
|
||||||
|
const lastProfileCreatedAt = profiles[profiles.length - 1].created_at
|
||||||
|
setUntil(lastProfileCreatedAt ? lastProfileCreatedAt - 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryPageLayout titlebarContent={title}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from(pubkeySet).map((pubkey, index) => (
|
||||||
|
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||||
|
))}
|
||||||
|
{hasMore && <div ref={bottomRef} />}
|
||||||
|
</div>
|
||||||
|
</SecondaryPageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,11 +19,13 @@ import NotFoundPage from '../NotFoundPage'
|
|||||||
import PubkeyCopy from './PubkeyCopy'
|
import PubkeyCopy from './PubkeyCopy'
|
||||||
import QrCodePopover from './QrCodePopover'
|
import QrCodePopover from './QrCodePopover'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||||
|
|
||||||
export default function ProfilePage({ id }: { id?: string }) {
|
export default function ProfilePage({ id }: { id?: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { profile, isFetching } = useFetchProfile(id)
|
const { profile, isFetching } = useFetchProfile(id)
|
||||||
const relayList = useFetchRelayList(profile?.pubkey)
|
const relayList = useFetchRelayList(profile?.pubkey)
|
||||||
|
const { relayUrls: currentRelayUrls } = useRelaySettings()
|
||||||
const { pubkey: accountPubkey } = useNostr()
|
const { pubkey: accountPubkey } = useNostr()
|
||||||
const { followings: selfFollowings } = useFollowList()
|
const { followings: selfFollowings } = useFollowList()
|
||||||
const { followings } = useFetchFollowings(profile?.pubkey)
|
const { followings } = useFetchFollowings(profile?.pubkey)
|
||||||
@@ -96,7 +98,7 @@ export default function ProfilePage({ id }: { id?: string }) {
|
|||||||
<NoteList
|
<NoteList
|
||||||
key={pubkey}
|
key={pubkey}
|
||||||
filter={{ authors: [pubkey] }}
|
filter={{ authors: [pubkey] }}
|
||||||
relayUrls={relayList.write.slice(0, 5)}
|
relayUrls={relayList.write.slice(0, 5).concat(currentRelayUrls)}
|
||||||
/>
|
/>
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { TRelayGroup } from '@common/types'
|
import { TRelayGroup } from '@common/types'
|
||||||
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
|
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
|
||||||
|
import client from '@renderer/services/client.service'
|
||||||
import storage from '@renderer/services/storage.service'
|
import storage from '@renderer/services/storage.service'
|
||||||
import { createContext, Dispatch, useContext, useEffect, useState } from 'react'
|
import { createContext, Dispatch, useContext, useEffect, useState } from 'react'
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ type TRelaySettingsContext = {
|
|||||||
relayGroups: TRelayGroup[]
|
relayGroups: TRelayGroup[]
|
||||||
temporaryRelayUrls: string[]
|
temporaryRelayUrls: string[]
|
||||||
relayUrls: string[]
|
relayUrls: string[]
|
||||||
|
searchableRelayUrls: string[]
|
||||||
switchRelayGroup: (groupName: string) => void
|
switchRelayGroup: (groupName: string) => void
|
||||||
renameRelayGroup: (oldGroupName: string, newGroupName: string) => string | null
|
renameRelayGroup: (oldGroupName: string, newGroupName: string) => string | null
|
||||||
deleteRelayGroup: (groupName: string) => void
|
deleteRelayGroup: (groupName: string) => void
|
||||||
@@ -33,6 +35,7 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
|
|||||||
? temporaryRelayUrls
|
? temporaryRelayUrls
|
||||||
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
|
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
|
||||||
)
|
)
|
||||||
|
const [searchableRelayUrls, setSearchableRelayUrls] = useState<string[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@@ -67,6 +70,17 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
|
|||||||
)
|
)
|
||||||
}, [relayGroups, temporaryRelayUrls])
|
}, [relayGroups, temporaryRelayUrls])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = async () => {
|
||||||
|
setSearchableRelayUrls([])
|
||||||
|
const relayInfos = await client.fetchRelayInfos(relayUrls)
|
||||||
|
setSearchableRelayUrls(
|
||||||
|
relayUrls.filter((_, index) => relayInfos[index]?.supported_nips?.includes(50))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
handler()
|
||||||
|
}, [relayUrls])
|
||||||
|
|
||||||
const updateGroups = async (fn: (pre: TRelayGroup[]) => TRelayGroup[]) => {
|
const updateGroups = async (fn: (pre: TRelayGroup[]) => TRelayGroup[]) => {
|
||||||
let newGroups = relayGroups
|
let newGroups = relayGroups
|
||||||
setRelayGroups((pre) => {
|
setRelayGroups((pre) => {
|
||||||
@@ -147,6 +161,7 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
|
|||||||
relayGroups,
|
relayGroups,
|
||||||
temporaryRelayUrls,
|
temporaryRelayUrls,
|
||||||
relayUrls,
|
relayUrls,
|
||||||
|
searchableRelayUrls,
|
||||||
switchRelayGroup,
|
switchRelayGroup,
|
||||||
renameRelayGroup,
|
renameRelayGroup,
|
||||||
deleteRelayGroup,
|
deleteRelayGroup,
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { match } from 'path-to-regexp'
|
import { match } from 'path-to-regexp'
|
||||||
import { isValidElement } from 'react'
|
import { isValidElement } from 'react'
|
||||||
import FollowingListPage from './pages/secondary/FollowingListPage'
|
import FollowingListPage from './pages/secondary/FollowingListPage'
|
||||||
import HashtagPage from './pages/secondary/HashtagPage'
|
|
||||||
import HomePage from './pages/secondary/HomePage'
|
import HomePage from './pages/secondary/HomePage'
|
||||||
|
import NoteListPage from './pages/secondary/NoteListPage'
|
||||||
import NotePage from './pages/secondary/NotePage'
|
import NotePage from './pages/secondary/NotePage'
|
||||||
|
import ProfileListPage from './pages/secondary/ProfileListPage'
|
||||||
import ProfilePage from './pages/secondary/ProfilePage'
|
import ProfilePage from './pages/secondary/ProfilePage'
|
||||||
|
|
||||||
const ROUTES = [
|
const ROUTES = [
|
||||||
{ path: '/', element: <HomePage /> },
|
{ path: '/', element: <HomePage /> },
|
||||||
|
{ path: '/note', element: <NoteListPage /> },
|
||||||
{ path: '/note/:id', element: <NotePage /> },
|
{ path: '/note/:id', element: <NotePage /> },
|
||||||
|
{ path: '/user', element: <ProfileListPage /> },
|
||||||
{ path: '/user/:id', element: <ProfilePage /> },
|
{ path: '/user/:id', element: <ProfilePage /> },
|
||||||
{ path: '/user/:id/following', element: <FollowingListPage /> },
|
{ path: '/user/:id/following', element: <FollowingListPage /> }
|
||||||
{ path: '/hashtag/:id', element: <HashtagPage /> }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export const routes = ROUTES.map(({ path, element }) => ({
|
export const routes = ROUTES.map(({ path, element }) => ({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { isReplyNoteEvent } from '@renderer/lib/event'
|
|||||||
import { formatPubkey } from '@renderer/lib/pubkey'
|
import { formatPubkey } from '@renderer/lib/pubkey'
|
||||||
import { tagNameEquals } from '@renderer/lib/tag'
|
import { tagNameEquals } from '@renderer/lib/tag'
|
||||||
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
|
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
|
||||||
import { TProfile, TRelayList } from '@renderer/types'
|
import { TProfile, TRelayInfo, TRelayList } from '@renderer/types'
|
||||||
import DataLoader from 'dataloader'
|
import DataLoader from 'dataloader'
|
||||||
import { LRUCache } from 'lru-cache'
|
import { LRUCache } from 'lru-cache'
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +55,19 @@ class ClientService {
|
|||||||
cacheMap: new LRUCache<string, Promise<TRelayList>>({ max: 10000 })
|
cacheMap: new LRUCache<string, Promise<TRelayList>>({ max: 10000 })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
private relayInfoDataLoader = new DataLoader<string, TRelayInfo | undefined>(async (urls) => {
|
||||||
|
return await Promise.all(
|
||||||
|
urls.map(async (url) => {
|
||||||
|
return (await (
|
||||||
|
await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), {
|
||||||
|
headers: { Accept: 'application/nostr+json' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.json()
|
||||||
|
.catch(() => undefined)) as TRelayInfo | undefined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
||||||
max: 10000,
|
max: 10000,
|
||||||
fetchMethod: this._fetchFollowListEvent.bind(this)
|
fetchMethod: this._fetchFollowListEvent.bind(this)
|
||||||
@@ -329,6 +342,19 @@ class ClientService {
|
|||||||
return this.profileDataloader.load(id)
|
return this.profileDataloader.load(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchProfiles(relayUrls: string[], filter: Filter): Promise<TProfile[]> {
|
||||||
|
const events = await this.pool.querySync(relayUrls, {
|
||||||
|
...filter,
|
||||||
|
kinds: [kinds.Metadata]
|
||||||
|
})
|
||||||
|
|
||||||
|
const profiles = events
|
||||||
|
.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
.map((event) => this.parseProfileFromEvent(event))
|
||||||
|
profiles.forEach((profile) => this.profileDataloader.prime(profile.pubkey, profile))
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
|
||||||
async fetchRelayList(pubkey: string): Promise<TRelayList> {
|
async fetchRelayList(pubkey: string): Promise<TRelayList> {
|
||||||
return this.relayListDataLoader.load(pubkey)
|
return this.relayListDataLoader.load(pubkey)
|
||||||
}
|
}
|
||||||
@@ -341,6 +367,11 @@ class ClientService {
|
|||||||
this.followListCache.set(pubkey, Promise.resolve(event))
|
this.followListCache.set(pubkey, Promise.resolve(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchRelayInfos(urls: string[]) {
|
||||||
|
const infos = await this.relayInfoDataLoader.loadMany(urls)
|
||||||
|
return infos.map((info) => (info ? (info instanceof Error ? undefined : info) : undefined))
|
||||||
|
}
|
||||||
|
|
||||||
private async fetchEventById(relayUrls: string[], id: string): Promise<NEvent | undefined> {
|
private async fetchEventById(relayUrls: string[], id: string): Promise<NEvent | undefined> {
|
||||||
const event = await this.fetchEventFromBigRelaysDataloader.load(id)
|
const event = await this.fetchEventFromBigRelaysDataloader.load(id)
|
||||||
if (event) {
|
if (event) {
|
||||||
@@ -561,7 +592,8 @@ class ClientService {
|
|||||||
profileObj.nip05?.split('@')[0]?.trim() ||
|
profileObj.nip05?.split('@')[0]?.trim() ||
|
||||||
formatPubkey(event.pubkey),
|
formatPubkey(event.pubkey),
|
||||||
nip05: profileObj.nip05,
|
nip05: profileObj.nip05,
|
||||||
about: profileObj.about
|
about: profileObj.about,
|
||||||
|
created_at: event.created_at
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ export type TProfile = {
|
|||||||
avatar?: string
|
avatar?: string
|
||||||
nip05?: string
|
nip05?: string
|
||||||
about?: string
|
about?: string
|
||||||
|
created_at?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TRelayList = {
|
export type TRelayList = {
|
||||||
write: string[]
|
write: string[]
|
||||||
read: string[]
|
read: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TRelayInfo = {
|
||||||
|
supported_nips?: number[]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user