Compare commits
1 Commits
master
...
tgFullscre
| Author | SHA1 | Date |
|---|---|---|
|
|
9dcc6860f4 |
31
Dockerfile
31
Dockerfile
|
|
@ -1,31 +0,0 @@
|
||||||
FROM node:20-alpine AS build
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install deps first
|
|
||||||
COPY package.json package-lock.json* yarn.lock* ./
|
|
||||||
RUN if [ -f package-lock.json ]; then npm ci; \
|
|
||||||
elif [ -f yarn.lock ]; then yarn install --frozen-lockfile; \
|
|
||||||
else npm install; fi
|
|
||||||
|
|
||||||
# Copy sources
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build-time env vars for Vite
|
|
||||||
ARG VITE_SENTRY_DSN
|
|
||||||
ARG VITE_API_BASE_URL
|
|
||||||
ARG VITE_API_BASE_STORAGE_URL
|
|
||||||
ENV VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
|
|
||||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
|
||||||
ENV VITE_API_BASE_STORAGE_URL=${VITE_API_BASE_STORAGE_URL}
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM nginx:alpine AS runtime
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
# Minimal SPA-friendly Nginx config
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
|
|
||||||
10
README.md
10
README.md
|
|
@ -2,16 +2,6 @@
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
## Environment variables
|
|
||||||
|
|
||||||
Copy `.env.example` to `.env` (or provide the same variables through your deployment system) and adjust the URLs if needed. All frontend traffic (админка, загрузка, просмотр) завязан на `VITE_API_BASE_URL`, поэтому указывайте полный путь до `/api/v1` нужной ноды.
|
|
||||||
|
|
||||||
```
|
|
||||||
VITE_API_BASE_URL=https://my-public-node-103.projscale.dev/api/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
При таком значении фронт автоматически отправляет tus-запросы на `https://my-public-node-103.projscale.dev/tus/files`, а прогрессивные загрузки (обложки, метаданные) — на `https://my-public-node-103.projscale.dev/api/v1.5/storage`.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
|
|
||||||
23
nginx.conf
23
nginx.conf
|
|
@ -1,23 +0,0 @@
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
# Increase limits for large uploads if needed
|
|
||||||
client_max_body_size 10G;
|
|
||||||
client_body_timeout 300s;
|
|
||||||
client_header_timeout 300s;
|
|
||||||
|
|
||||||
# Access logs with request id
|
|
||||||
access_log /var/log/nginx/access.log main;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri /index.html;
|
|
||||||
# Propagate a request id to clients; backend (API) can echo as session id
|
|
||||||
add_header X-Request-Id $request_id always;
|
|
||||||
add_header X-Content-Type-Options nosniff always;
|
|
||||||
add_header Referrer-Policy no-referrer-when-downgrade always;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@sentry/react": "^9.1.0",
|
"@sentry/react": "^9.1.0",
|
||||||
|
"@telegram-apps/sdk": "^3.5.1",
|
||||||
"@ton/core": "^0.59.1",
|
"@ton/core": "^0.59.1",
|
||||||
"@tonconnect/ui-react": "^2.0.2",
|
"@tonconnect/ui-react": "^2.0.2",
|
||||||
"@vkruglikov/react-telegram-web-app": "^2.1.9",
|
"@vkruglikov/react-telegram-web-app": "^2.1.9",
|
||||||
|
|
@ -24,7 +25,6 @@
|
||||||
"react-router-dom": "^6.22.2",
|
"react-router-dom": "^6.22.2",
|
||||||
"react-tag-input": "^6.10.3",
|
"react-tag-input": "^6.10.3",
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"tus-js-client": "^4.3.1",
|
|
||||||
"zod": "^3.22.4",
|
"zod": "^3.22.4",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
|
|
@ -1404,6 +1404,113 @@
|
||||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@telegram-apps/bridge": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@telegram-apps/bridge/-/bridge-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-Lp/vhspF7okK8zXvSWWirunKXOPE6Gr11o9VBne4VmKG/yHRhEW7Pf07ncPtXLLzI6wW8+VYc3khsHPABJymEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@telegram-apps/signals": "^1.1.1",
|
||||||
|
"@telegram-apps/toolkit": "^2.0.0",
|
||||||
|
"@telegram-apps/transformers": "^2.2.0",
|
||||||
|
"@telegram-apps/types": "^2.0.0",
|
||||||
|
"better-promises": "^0.4.0",
|
||||||
|
"error-kid": "^0.0.4",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"valibot": "1.0.0-beta.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@telegram-apps/navigation": {
|
||||||
|
"version": "1.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@telegram-apps/navigation/-/navigation-1.0.13.tgz",
|
||||||
|
"integrity": "sha512-TsUueB5LQp77GQHoMa93nq26Uw7GJjrFCPbyseMVU7aBBxAc+8CV2IYytRwcVp5sv/q7ThK5X4JaKn2V1yBHDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@telegram-apps/bridge": "^1.9.2",
|
||||||
|
"@telegram-apps/signals": "^1.1.1",
|
||||||
|
"@telegram-apps/toolkit": "^1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@telegram-apps/navigation/node_modules/@telegram-apps/bridge": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@telegram-apps/bridge/-/bridge-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-SJLcNWLXhbbZr9MiqFH/g2ceuitSJKMxUIZysK4zUNyTUNuonrQG80Q/yrO+XiNbKUj8WdDNM86NBARhuyyinQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@telegram-apps/signals": "^1.1.1",
|
||||||
|
"@telegram-apps/toolkit": "^1.1.1",
|
||||||
|
"@telegram-apps/transformers": "^1.2.2",
|
||||||
|
"@telegram-apps/types": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@telegram-apps/navigation/node_modules/@telegram-apps/toolkit": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@telegram-apps/toolkit/-/toolkit-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-+vhKx6ngfvjyTE6Xagl3z1TPVbfx5s7xAkcYzCdHYUo6T60jLIqLgyZMcI1UPoIAMuMu1pHoO+p8QNCj/+tFmw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@telegram-apps/navigation/node_modules/@telegram-apps/transformers": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@telegram-apps/transformers/-/transformers-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-vvMwXckd1D7Ozc0h66PSUwF5QLrRV9HlGJFFeBuUex8QEk5mSPtsJkLiqB8aBbwuFDa91+TUSM/CxqPZO/e9YQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@telegram-apps/toolkit": "^1.1.1",
|
||||||
|
"@telegram-apps/types": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@telegram-apps/navigation/node_modules/@telegram-apps/types": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@telegram-apps/types/-/types-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-so4HLh7clur0YyMthi9KVIgWoGpZdXlFOuQjk3+Q5NAvJZ11nAheBSwPlGw/Ko92+zwvrSBE/lQyN2+p17RP+w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@telegram-apps/sdk": {
|
||||||
|
"version": "3.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@telegram-apps/sdk/-/sdk-3.5.1.tgz",
|
||||||
|
"integrity": "sha512-m/ynpSozXsqq6Kfb6M9fm8SD6x/+jqvTFT59FuuOBvC+G8eMd0F+fvZdr6Pj0I6IL9M67nrfm92JpTu/aVNEjw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@telegram-apps/bridge": "^2.4.0",
|
||||||
|
"@telegram-apps/navigation": "^1.0.13",
|
||||||
|
"@telegram-apps/signals": "^1.1.1",
|
||||||
|
"@telegram-apps/toolkit": "^2.0.0",
|
||||||
|
"@telegram-apps/transformers": "^2.2.0",
|
||||||
|
"@telegram-apps/types": "^2.0.0",
|
||||||
|
"better-promises": "^0.4.0",
|
||||||
|
"error-kid": "^0.0.4",
|
||||||
|
"valibot": "1.0.0-beta.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@telegram-apps/signals": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@telegram-apps/signals/-/signals-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-vz37r8lemGpPzDiBRfqpXYBynzmy3SFnY6zfHsTZABTYYt0b0WQZyU5mFDqqqugGhka78Gy11xmr9csgy4YgGA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@telegram-apps/toolkit": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@telegram-apps/toolkit/-/toolkit-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-1GKTLBNme1Phu/gFvgS9NWPq+LhPfzSIfnwhcF9I/6tCdufrLRcVaSMRiK9R4VDYD6iZUyj+a2l250qWAxxjQQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@telegram-apps/transformers": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@telegram-apps/transformers/-/transformers-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-wqXXOukhEjZKhzdq5vG1LkxWL11DApbmUKzk+3nA/ki3TLyD3awVTOXbpoNdOwFl2xliIooYcsUOEl4WCyyLGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@telegram-apps/toolkit": "^2.0.0",
|
||||||
|
"@telegram-apps/types": "^2.0.0",
|
||||||
|
"valibot": "1.0.0-beta.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@telegram-apps/types": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@telegram-apps/types/-/types-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-yF499FJK82a2IDNDAQdrmVH3sgFZl/QFNdVZKgWpgtunIVJ1fres5wi9+4aUBRVIdQwZOZZqB/AOvYYuYXsq3Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@ton/core": {
|
"node_modules/@ton/core": {
|
||||||
"version": "0.59.1",
|
"version": "0.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@ton/core/-/core-0.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@ton/core/-/core-0.59.1.tgz",
|
||||||
|
|
@ -2189,6 +2296,15 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/better-promises": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/better-promises/-/better-promises-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-AcKkTUSd4o1vMf41eBbHW1NkY7vrXeNI6etitGdQE54WFXsF2wkfonrKA06Za7lViRNyT/cMvj5z+DScqhYW8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"error-kid": "^0.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/big-integer": {
|
"node_modules/big-integer": {
|
||||||
"version": "1.6.52",
|
"version": "1.6.52",
|
||||||
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
|
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
|
||||||
|
|
@ -2298,12 +2414,6 @@
|
||||||
"ieee754": "^1.2.1"
|
"ieee754": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/buffer-from": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
||||||
|
|
@ -2440,15 +2550,6 @@
|
||||||
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/combine-errors": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/combine-errors/-/combine-errors-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==",
|
|
||||||
"dependencies": {
|
|
||||||
"custom-error-instance": "2.1.1",
|
|
||||||
"lodash.uniqby": "4.5.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
|
@ -2511,12 +2612,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||||
},
|
},
|
||||||
"node_modules/custom-error-instance": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/custom-error-instance/-/custom-error-instance-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.3.4",
|
"version": "4.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||||
|
|
@ -2661,6 +2756,12 @@
|
||||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/error-kid": {
|
||||||
|
"version": "0.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/error-kid/-/error-kid-0.0.4.tgz",
|
||||||
|
"integrity": "sha512-x+yQhY56SorLMnX6kOf+z3JCv2QBurcWEDcIjgxYtVr4fGeCfAtOdZOCyWttkHHDFPtL2PqnaRUmphbmALJd9w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/es-abstract": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.22.5",
|
"version": "1.22.5",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.5.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.5.tgz",
|
||||||
|
|
@ -3704,12 +3805,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/graceful-fs": {
|
|
||||||
"version": "4.2.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/graphemer": {
|
"node_modules/graphemer": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||||
|
|
@ -4089,18 +4184,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-stream": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-string": {
|
"node_modules/is-string": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
|
||||||
|
|
@ -4197,12 +4280,6 @@
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/js-base64": {
|
|
||||||
"version": "3.7.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
|
|
||||||
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/js-sha3": {
|
"node_modules/js-sha3": {
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
|
||||||
|
|
@ -4330,74 +4407,12 @@
|
||||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash._baseiteratee": {
|
|
||||||
"version": "4.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz",
|
|
||||||
"integrity": "sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"lodash._stringtopath": "~4.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lodash._basetostring": {
|
|
||||||
"version": "4.12.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz",
|
|
||||||
"integrity": "sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash._baseuniq": {
|
|
||||||
"version": "4.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz",
|
|
||||||
"integrity": "sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"lodash._createset": "~4.0.0",
|
|
||||||
"lodash._root": "~3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lodash._createset": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash._createset/-/lodash._createset-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash._root": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash._stringtopath": {
|
|
||||||
"version": "4.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash._stringtopath/-/lodash._stringtopath-4.8.0.tgz",
|
|
||||||
"integrity": "sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"lodash._basetostring": "~4.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash.throttle": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.uniqby": {
|
|
||||||
"version": "4.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.5.0.tgz",
|
|
||||||
"integrity": "sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"lodash._baseiteratee": "~4.7.0",
|
|
||||||
"lodash._baseuniq": "~4.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
|
|
@ -4511,6 +4526,12 @@
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mitt": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
|
|
@ -5151,23 +5172,6 @@
|
||||||
"react-is": "^16.13.1"
|
"react-is": "^16.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proper-lockfile": {
|
|
||||||
"version": "4.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
|
||||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"graceful-fs": "^4.2.4",
|
|
||||||
"retry": "^0.12.0",
|
|
||||||
"signal-exit": "^3.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/proper-lockfile/node_modules/signal-exit": {
|
|
||||||
"version": "3.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
|
||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
|
@ -5182,12 +5186,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/querystringify": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|
@ -5462,12 +5460,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
|
||||||
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="
|
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="
|
||||||
},
|
},
|
||||||
"node_modules/requires-port": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.8",
|
"version": "1.22.8",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
|
||||||
|
|
@ -5494,15 +5486,6 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/retry": {
|
|
||||||
"version": "0.12.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
|
||||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/reusify": {
|
"node_modules/reusify": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
|
||||||
|
|
@ -6176,24 +6159,6 @@
|
||||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
|
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/tus-js-client": {
|
|
||||||
"version": "4.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-4.3.1.tgz",
|
|
||||||
"integrity": "sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"buffer-from": "^1.1.2",
|
|
||||||
"combine-errors": "^3.0.3",
|
|
||||||
"is-stream": "^2.0.0",
|
|
||||||
"js-base64": "^3.7.2",
|
|
||||||
"lodash.throttle": "^4.1.1",
|
|
||||||
"proper-lockfile": "^4.1.2",
|
|
||||||
"url-parse": "^1.5.7"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tweetnacl": {
|
"node_modules/tweetnacl": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
|
@ -6307,7 +6272,7 @@
|
||||||
"version": "5.3.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
|
||||||
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
|
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -6411,16 +6376,6 @@
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/url-parse": {
|
|
||||||
"version": "1.5.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
|
||||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"querystringify": "^2.1.1",
|
|
||||||
"requires-port": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
|
||||||
|
|
@ -6435,6 +6390,20 @@
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/valibot": {
|
||||||
|
"version": "1.0.0-beta.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.0.0-beta.14.tgz",
|
||||||
|
"integrity": "sha512-tLyV2rE5QL6U29MFy3xt4AqMrn+/HErcp2ZThASnQvPMwfSozjV1uBGKIGiegtZIGjinJqn0SlBdannf18wENA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.1.4",
|
"version": "5.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@sentry/react": "^9.1.0",
|
"@sentry/react": "^9.1.0",
|
||||||
|
"@telegram-apps/sdk": "^3.5.1",
|
||||||
"@ton/core": "^0.59.1",
|
"@ton/core": "^0.59.1",
|
||||||
"@tonconnect/ui-react": "^2.0.2",
|
"@tonconnect/ui-react": "^2.0.2",
|
||||||
"@vkruglikov/react-telegram-web-app": "^2.1.9",
|
"@vkruglikov/react-telegram-web-app": "^2.1.9",
|
||||||
|
|
@ -26,7 +27,6 @@
|
||||||
"react-router-dom": "^6.22.2",
|
"react-router-dom": "^6.22.2",
|
||||||
"react-tag-input": "^6.10.3",
|
"react-tag-input": "^6.10.3",
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"tus-js-client": "^4.3.1",
|
|
||||||
"zod": "^3.22.4",
|
"zod": "^3.22.4",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,24 +7,19 @@ import { useExpand, useWebApp } from '@vkruglikov/react-telegram-web-app';
|
||||||
import { Providers } from '~/app/providers';
|
import { Providers } from '~/app/providers';
|
||||||
import { AppRouter } from '~/app/router';
|
import { AppRouter } from '~/app/router';
|
||||||
import { Notification } from '~/shared/ui/notification';
|
import { Notification } from '~/shared/ui/notification';
|
||||||
|
import { init } from '@telegram-apps/sdk';
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
const WebApp = useWebApp();
|
const WebApp = useWebApp();
|
||||||
const [, expand] = useExpand();
|
const [, expand] = useExpand();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!WebApp) {
|
init();
|
||||||
return;
|
WebApp.enableClosingConfirmation();
|
||||||
}
|
expand();
|
||||||
|
|
||||||
WebApp.enableClosingConfirmation?.();
|
WebApp.setHeaderColor('#1d1d1b');
|
||||||
if (typeof expand === 'function') {
|
WebApp.setBackgroundColor('#1d1d1b');
|
||||||
expand();
|
}, []);
|
||||||
}
|
|
||||||
|
|
||||||
WebApp.setHeaderColor?.('#1d1d1b');
|
|
||||||
WebApp.setBackgroundColor?.('#1d1d1b');
|
|
||||||
}, [WebApp, expand]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export const Providers = ({ children }: ProvidersProps) => {
|
||||||
<WebAppProvider options={{ smoothButtonsTransition: true }}>
|
<WebAppProvider options={{ smoothButtonsTransition: true }}>
|
||||||
<TonConnectUIProvider
|
<TonConnectUIProvider
|
||||||
manifestUrl={
|
manifestUrl={
|
||||||
"https://my-public-node-103.projscale.dev/api/tonconnect-manifest.json"
|
"https://my-public-node-1.projscale.dev/api/tonconnect-manifest.json"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,4 @@ export const Routes = {
|
||||||
Root: "/uploadContent",
|
Root: "/uploadContent",
|
||||||
ViewContent: "/viewContent",
|
ViewContent: "/viewContent",
|
||||||
SentryCheck: "/sentryCheck",
|
SentryCheck: "/sentryCheck",
|
||||||
Admin: "/admin",
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,6 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||||
import { Routes } from "~/app/router/constants";
|
import { Routes } from "~/app/router/constants";
|
||||||
import { RootPage } from "~/pages/root";
|
import { RootPage } from "~/pages/root";
|
||||||
import { ViewContentPage } from "~/pages/view-content";
|
import { ViewContentPage } from "~/pages/view-content";
|
||||||
import { AdminPage, AdminIndexRedirect } from "~/pages/admin";
|
|
||||||
import {
|
|
||||||
AdminOverviewPage,
|
|
||||||
AdminStoragePage,
|
|
||||||
AdminUploadsPage,
|
|
||||||
AdminUsersPage,
|
|
||||||
AdminEventsPage,
|
|
||||||
AdminLicensesPage,
|
|
||||||
AdminStarsPage,
|
|
||||||
AdminSystemPage,
|
|
||||||
AdminBlockchainPage,
|
|
||||||
AdminNodesPage,
|
|
||||||
AdminStatusPage,
|
|
||||||
AdminNetworkPage,
|
|
||||||
AdminNetworkSettingsPage,
|
|
||||||
} from "~/pages/admin/sections";
|
|
||||||
import { ProtectedLayout } from "./protected-layout";
|
import { ProtectedLayout } from "./protected-layout";
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
|
|
@ -27,8 +11,8 @@ const router = createBrowserRouter([
|
||||||
children: [
|
children: [
|
||||||
{ path: Routes.Root, element: <RootPage /> },
|
{ path: Routes.Root, element: <RootPage /> },
|
||||||
{ path: Routes.ViewContent, element: <ViewContentPage /> },
|
{ path: Routes.ViewContent, element: <ViewContentPage /> },
|
||||||
{
|
{
|
||||||
path: Routes.SentryCheck,
|
path: Routes.SentryCheck,
|
||||||
element: (
|
element: (
|
||||||
<div className="flex h-screen items-center justify-center">
|
<div className="flex h-screen items-center justify-center">
|
||||||
<button
|
<button
|
||||||
|
|
@ -45,26 +29,6 @@ const router = createBrowserRouter([
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: Routes.Admin,
|
|
||||||
element: <AdminPage />,
|
|
||||||
children: [
|
|
||||||
{ index: true, element: <AdminIndexRedirect /> },
|
|
||||||
{ path: "overview", element: <AdminOverviewPage /> },
|
|
||||||
{ path: "storage", element: <AdminStoragePage /> },
|
|
||||||
{ path: "uploads", element: <AdminUploadsPage /> },
|
|
||||||
{ path: "events", element: <AdminEventsPage /> },
|
|
||||||
{ path: "users", element: <AdminUsersPage /> },
|
|
||||||
{ path: "licenses", element: <AdminLicensesPage /> },
|
|
||||||
{ path: "stars", element: <AdminStarsPage /> },
|
|
||||||
{ path: "system", element: <AdminSystemPage /> },
|
|
||||||
{ path: "blockchain", element: <AdminBlockchainPage /> },
|
|
||||||
{ path: "nodes", element: <AdminNodesPage /> },
|
|
||||||
{ path: "status", element: <AdminStatusPage /> },
|
|
||||||
{ path: "network", element: <AdminNetworkPage /> },
|
|
||||||
{ path: "network-settings", element: <AdminNetworkSettingsPage /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const AppRouter = () => {
|
export const AppRouter = () => {
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
type BadgeTone = "neutral" | "success" | "danger" | "warn";
|
|
||||||
|
|
||||||
const toneClassMap: Record<BadgeTone, string> = {
|
|
||||||
neutral: "bg-slate-800 text-slate-200",
|
|
||||||
success: "bg-emerald-900/70 text-emerald-200",
|
|
||||||
danger: "bg-rose-900/60 text-rose-100",
|
|
||||||
warn: "bg-amber-900/60 text-amber-100",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Badge = ({ children, tone = "neutral" }: { children: ReactNode; tone?: BadgeTone }) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide",
|
|
||||||
toneClassMap[tone],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { ReactNode, useCallback } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
import { useAdminContext } from "../context";
|
|
||||||
|
|
||||||
type CopyButtonProps = {
|
|
||||||
value: string | number;
|
|
||||||
className?: string;
|
|
||||||
"aria-label"?: string;
|
|
||||||
successMessage?: string;
|
|
||||||
children?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyFallback = (text: string) => {
|
|
||||||
if (typeof document === "undefined") {
|
|
||||||
throw new Error("Clipboard API недоступен");
|
|
||||||
}
|
|
||||||
const textarea = document.createElement("textarea");
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.style.position = "fixed";
|
|
||||||
textarea.style.top = "-1000px";
|
|
||||||
textarea.style.left = "-1000px";
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.focus();
|
|
||||||
textarea.select();
|
|
||||||
try {
|
|
||||||
document.execCommand("copy");
|
|
||||||
} finally {
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CopyButton = ({ value, className, "aria-label": ariaLabel, successMessage, children }: CopyButtonProps) => {
|
|
||||||
const { pushFlash } = useAdminContext();
|
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
|
||||||
const text = String(value ?? "");
|
|
||||||
if (!text) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
} else {
|
|
||||||
copyFallback(text);
|
|
||||||
}
|
|
||||||
pushFlash({
|
|
||||||
type: "success",
|
|
||||||
message: successMessage ?? "Значение скопировано в буфер обмена",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
pushFlash({
|
|
||||||
type: "error",
|
|
||||||
message: error instanceof Error ? `Не удалось скопировать: ${error.message}` : "Не удалось скопировать значение",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [pushFlash, successMessage, value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={clsx(
|
|
||||||
"inline-flex shrink-0 items-center justify-center rounded-md border border-slate-700 bg-slate-900 text-slate-300 transition hover:border-sky-500 hover:text-white focus:outline-none focus:ring-2 focus:ring-sky-500/40",
|
|
||||||
children ? "h-7 px-3" : "h-7 w-7",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
aria-label={ariaLabel ?? "Скопировать значение"}
|
|
||||||
onClick={handleCopy}
|
|
||||||
>
|
|
||||||
{children ?? (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
import { CopyButton } from "./CopyButton";
|
|
||||||
|
|
||||||
type InfoRowProps = {
|
|
||||||
label: string;
|
|
||||||
children: ReactNode;
|
|
||||||
copyValue?: string | number | null;
|
|
||||||
copyMessage?: string;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InfoRow = ({ label, children, copyValue, copyMessage, className }: InfoRowProps) => {
|
|
||||||
const canCopy = copyValue !== null && copyValue !== undefined && `${copyValue}`.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"flex flex-col gap-1 overflow-hidden rounded-lg border border-slate-800 bg-slate-950/60 p-3",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
|
|
||||||
<div className="flex items-start gap-2 text-sm text-slate-100">
|
|
||||||
<div className="min-w-0 break-words text-left">{children}</div>
|
|
||||||
{canCopy ? (
|
|
||||||
<CopyButton
|
|
||||||
value={copyValue as string | number}
|
|
||||||
aria-label={`Скопировать значение «${label}»`}
|
|
||||||
successMessage={copyMessage}
|
|
||||||
className="mt-0.5"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
type PaginationControlsProps = {
|
|
||||||
total: number;
|
|
||||||
limit: number;
|
|
||||||
page: number;
|
|
||||||
onPageChange: (next: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const numberFormatter = new Intl.NumberFormat("ru-RU");
|
|
||||||
|
|
||||||
export const PaginationControls = ({ total, limit, page, onPageChange }: PaginationControlsProps) => {
|
|
||||||
const safeLimit = Math.max(limit, 1);
|
|
||||||
const totalPages = Math.max(1, Math.ceil(Math.max(total, 0) / safeLimit));
|
|
||||||
const clampedPage = Math.min(Math.max(page, 0), totalPages - 1);
|
|
||||||
const startIndex = total === 0 ? 0 : clampedPage * safeLimit + 1;
|
|
||||||
const endIndex = total === 0 ? 0 : Math.min(total, (clampedPage + 1) * safeLimit);
|
|
||||||
const canGoPrev = clampedPage > 0;
|
|
||||||
const canGoNext = clampedPage + 1 < totalPages;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 rounded-xl bg-slate-950/40 px-3 py-2 ring-1 ring-slate-800 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="text-xs text-slate-400">
|
|
||||||
{total === 0
|
|
||||||
? "Нет записей"
|
|
||||||
: `Показаны ${numberFormatter.format(startIndex)}–${numberFormatter.format(endIndex)} из ${numberFormatter.format(total)}`}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-slate-900/60 px-3 py-1 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800 disabled:bg-slate-900/30 disabled:text-slate-600 disabled:ring-slate-800"
|
|
||||||
onClick={() => onPageChange(Math.max(clampedPage - 1, 0))}
|
|
||||||
disabled={!canGoPrev}
|
|
||||||
>
|
|
||||||
Назад
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-slate-300">
|
|
||||||
Стр. {numberFormatter.format(clampedPage + 1)} из {numberFormatter.format(totalPages)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-slate-900/60 px-3 py-1 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800 disabled:bg-slate-900/30 disabled:text-slate-600 disabled:ring-slate-800"
|
|
||||||
onClick={() => onPageChange(Math.min(clampedPage + 1, totalPages - 1))}
|
|
||||||
disabled={!canGoNext}
|
|
||||||
>
|
|
||||||
Вперёд
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
type SectionProps = {
|
|
||||||
id?: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
actions?: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Section = ({ id, title, description, actions, children, className }: SectionProps) => {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id={id}
|
|
||||||
className={clsx(
|
|
||||||
"relative w-full overflow-hidden rounded-2xl bg-slate-900/60 p-6 shadow-inner shadow-black/40 ring-1 ring-slate-800",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h2 className="text-2xl font-semibold text-slate-100">{title}</h2>
|
|
||||||
{description ? <p className="mt-1 max-w-3xl break-words text-sm text-slate-400">{description}</p> : null}
|
|
||||||
</div>
|
|
||||||
{actions ? <div className="flex h-full shrink-0 flex-wrap items-center gap-3">{actions}</div> : null}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-6 text-sm text-slate-200">{children}</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
export * from "./Section";
|
|
||||||
export * from "./Badge";
|
|
||||||
export * from "./PaginationControls";
|
|
||||||
export * from "./InfoRow";
|
|
||||||
export * from "./CopyButton";
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export type AdminSectionId =
|
|
||||||
| "overview"
|
|
||||||
| "storage"
|
|
||||||
| "uploads"
|
|
||||||
| "events"
|
|
||||||
| "users"
|
|
||||||
| "licenses"
|
|
||||||
| "stars"
|
|
||||||
| "system"
|
|
||||||
| "blockchain"
|
|
||||||
| "nodes"
|
|
||||||
| "status"
|
|
||||||
| "network"
|
|
||||||
| "network-settings";
|
|
||||||
|
|
||||||
export type AdminSection = {
|
|
||||||
id: AdminSectionId;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
path: string;
|
|
||||||
icon?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ADMIN_SECTIONS: AdminSection[] = [
|
|
||||||
{ id: "overview", label: "Обзор", description: "Краткий срез состояния узла, окружения и служб", path: "overview" },
|
|
||||||
{ id: "storage", label: "Хранилище", description: "Загруженность диска и директории контента", path: "storage" },
|
|
||||||
{ id: "uploads", label: "Загрузки", description: "Отслеживание статуса загрузок и деривативов", path: "uploads" },
|
|
||||||
{ id: "events", label: "События", description: "Журнал действий ноды и сети", path: "events" },
|
|
||||||
{ id: "users", label: "Пользователи", description: "Мониторинг пользователей, кошельков и активности", path: "users" },
|
|
||||||
{ id: "licenses", label: "Лицензии", description: "Статус лицензий и фильтрация по типам", path: "licenses" },
|
|
||||||
{ id: "stars", label: "Платежи Stars", description: "Управление счетами и анализ платежей", path: "stars" },
|
|
||||||
{ id: "system", label: "Система", description: "Настройки окружения и состояние сервисов", path: "system" },
|
|
||||||
{ id: "blockchain", label: "Блокчейн", description: "История задач и метрики блокчейн-интеграции", path: "blockchain" },
|
|
||||||
{ id: "nodes", label: "Ноды", description: "Роли, версии и последнее появление узлов", path: "nodes" },
|
|
||||||
{ id: "status", label: "Статус & лимиты", description: "IPFS, очереди и лимиты синхронизации", path: "status" },
|
|
||||||
{ id: "network", label: "Состояние сети", description: "Мониторинг децентрализованного слоя и репликаций", path: "network" },
|
|
||||||
{ id: "network-settings", label: "Сеть → Настройки", description: "Интервалы heartbeat/gossip и бэк-офф", path: "network-settings" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DEFAULT_ADMIN_SECTION = ADMIN_SECTIONS[0];
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { createContext, useContext } from "react";
|
|
||||||
|
|
||||||
import type { AdminContextValue } from "./types";
|
|
||||||
|
|
||||||
export const AdminContext = createContext<AdminContextValue | undefined>(undefined);
|
|
||||||
|
|
||||||
export const useAdminContext = () => {
|
|
||||||
const ctx = useContext(AdminContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useAdminContext must be used within <AdminContext.Provider>");
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
};
|
|
||||||
|
|
@ -1,356 +0,0 @@
|
||||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { NavLink, Navigate, Outlet, useLocation } from "react-router-dom";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { useQueryClient } from "react-query";
|
|
||||||
|
|
||||||
import { Routes } from "~/app/router/constants";
|
|
||||||
import {
|
|
||||||
useAdminLogin,
|
|
||||||
useAdminLogout,
|
|
||||||
useAdminOverview,
|
|
||||||
} from "~/shared/services/admin";
|
|
||||||
import { clearAdminAuth, getAdminAuthSnapshot } from "~/shared/libs/admin-auth";
|
|
||||||
import { isUnauthorizedError } from "~/shared/services/admin";
|
|
||||||
import { ADMIN_SECTIONS, DEFAULT_ADMIN_SECTION, type AdminSection } from "./config";
|
|
||||||
import { AdminContext } from "./context";
|
|
||||||
import type { AuthState, FlashMessage } from "./types";
|
|
||||||
|
|
||||||
const initialAuthState: AuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized";
|
|
||||||
|
|
||||||
const resolveErrorMessage = (error: unknown) => {
|
|
||||||
if (error && typeof error === "object" && "message" in error && typeof (error as any).message === "string") {
|
|
||||||
return (error as any).message as string;
|
|
||||||
}
|
|
||||||
return "Неизвестная ошибка";
|
|
||||||
};
|
|
||||||
|
|
||||||
const AdminFlash = ({ flash, onClose }: { flash: FlashMessage | null; onClose: () => void }) => {
|
|
||||||
if (!flash) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const tone =
|
|
||||||
flash.type === "success"
|
|
||||||
? "border-emerald-500/60 bg-emerald-500/15 text-emerald-50"
|
|
||||||
: flash.type === "error"
|
|
||||||
? "border-rose-500/60 bg-rose-500/15 text-rose-50"
|
|
||||||
: "border-sky-500/60 bg-sky-500/15 text-sky-50";
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none fixed inset-x-4 top-4 z-50 flex justify-center sm:inset-x-auto sm:left-auto sm:right-6">
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"pointer-events-auto flex w-full max-w-md items-start gap-4 rounded-2xl border px-5 py-4 text-sm shadow-2xl shadow-black/40 backdrop-blur-md",
|
|
||||||
tone,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="flex-1 break-words">{flash.message}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs font-semibold uppercase tracking-wide text-slate-200 transition hover:text-white"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
Закрыть
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AdminLoginForm = ({
|
|
||||||
token,
|
|
||||||
onTokenChange,
|
|
||||||
onSubmit,
|
|
||||||
isSubmitting,
|
|
||||||
}: {
|
|
||||||
token: string;
|
|
||||||
onTokenChange: (value: string) => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
isSubmitting: boolean;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-xl rounded-2xl border border-slate-800 bg-slate-900/60 p-8 shadow-xl shadow-black/50">
|
|
||||||
<h1 className="text-3xl font-semibold text-slate-100">Админ-панель узла</h1>
|
|
||||||
<p className="mt-2 text-sm text-slate-400">
|
|
||||||
Введите секретный токен ADMIN_API_TOKEN, чтобы получить доступ к мониторингу и управлению.
|
|
||||||
</p>
|
|
||||||
<form
|
|
||||||
className="mt-6 space-y-4"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
onSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label className="block text-sm font-medium text-slate-200">
|
|
||||||
Админ-токен
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-base text-slate-100 shadow-inner focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/40"
|
|
||||||
value={token}
|
|
||||||
onChange={(event) => onTokenChange(event.target.value)}
|
|
||||||
placeholder="••••••"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="inline-flex items-center justify-center rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/60 disabled:cursor-not-allowed disabled:bg-slate-700"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Проверяем…" : "Войти"}
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
Cookie max-age {Math.round(172800 / 3600)} ч
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MobileNav = ({ sections, currentPath }: { sections: AdminSection[]; currentPath: string }) => (
|
|
||||||
<select
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-200 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:hidden"
|
|
||||||
value={currentPath}
|
|
||||||
onChange={(event) => {
|
|
||||||
const next = event.target.value;
|
|
||||||
if (next !== currentPath) {
|
|
||||||
window.location.assign(next);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sections.map((section) => {
|
|
||||||
const to = `${Routes.Admin}/${section.path}`;
|
|
||||||
return (
|
|
||||||
<option key={section.id} value={to}>
|
|
||||||
{section.label}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
|
|
||||||
const DesktopNav = ({
|
|
||||||
sections,
|
|
||||||
currentPath,
|
|
||||||
}: {
|
|
||||||
sections: AdminSection[];
|
|
||||||
currentPath: string;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<nav className="hidden w-60 shrink-0 flex-col gap-1 md:flex">
|
|
||||||
{sections.map((section) => {
|
|
||||||
const to = `${Routes.Admin}/${section.path}`;
|
|
||||||
const isActive = currentPath.startsWith(to);
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={section.id}
|
|
||||||
to={to}
|
|
||||||
className={clsx(
|
|
||||||
"rounded-xl px-4 py-3 text-sm font-medium transition",
|
|
||||||
isActive
|
|
||||||
? "bg-sky-500/10 text-sky-200 ring-1 ring-sky-500/40"
|
|
||||||
: "text-slate-300 hover:bg-slate-900/60 hover:text-white",
|
|
||||||
)}
|
|
||||||
end
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>{section.label}</span>
|
|
||||||
<span className="mt-1 text-xs text-slate-500">{section.description}</span>
|
|
||||||
</div>
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AdminLayoutFrame = ({ children }: { children: ReactNode }) => {
|
|
||||||
const location = useLocation();
|
|
||||||
const currentPath = location.pathname;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 text-slate-100">
|
|
||||||
<header className="border-b border-slate-800 bg-slate-950/70 backdrop-blur">
|
|
||||||
<div className="flex w-full items-center justify-between gap-4 px-4 py-4 sm:px-6 lg:px-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold text-white">MY Admin</h1>
|
|
||||||
<p className="text-xs text-slate-500">Мониторинг и управление узлом</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 md:hidden">
|
|
||||||
<MobileNav sections={ADMIN_SECTIONS} currentPath={currentPath} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main className="flex w-full flex-1 flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8 md:flex-row">
|
|
||||||
<DesktopNav sections={ADMIN_SECTIONS} currentPath={currentPath} />
|
|
||||||
<div className="w-full max-w-full md:flex-1">
|
|
||||||
<MobileNav sections={ADMIN_SECTIONS} currentPath={currentPath} />
|
|
||||||
<div className="mt-4 space-y-6 md:mt-0">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdminShell = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [authState, setAuthState] = useState<AuthState>(initialAuthState);
|
|
||||||
const [token, setToken] = useState("");
|
|
||||||
const [flash, setFlash] = useState<FlashMessage | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!flash) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timeout = setTimeout(() => setFlash(null), 6000);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [flash]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authState === "unauthorized") {
|
|
||||||
queryClient.removeQueries({
|
|
||||||
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [authState, queryClient]);
|
|
||||||
|
|
||||||
const pushFlash = useCallback((message: FlashMessage) => {
|
|
||||||
setFlash(message);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearFlash = useCallback(() => {
|
|
||||||
setFlash(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRequestError = useCallback(
|
|
||||||
(error: unknown, fallbackMessage: string) => {
|
|
||||||
if (isUnauthorizedError(error)) {
|
|
||||||
clearAdminAuth();
|
|
||||||
setAuthState("unauthorized");
|
|
||||||
pushFlash({ type: "info", message: "Сессия истекла. Введите админ-токен заново." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tail = resolveErrorMessage(error);
|
|
||||||
pushFlash({
|
|
||||||
type: "error",
|
|
||||||
message: `${fallbackMessage}: ${tail}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[pushFlash],
|
|
||||||
);
|
|
||||||
|
|
||||||
const invalidateAll = useCallback(async () => {
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin",
|
|
||||||
});
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
useAdminOverview({
|
|
||||||
enabled: authState === "checking",
|
|
||||||
retry: false,
|
|
||||||
onSuccess: () => {
|
|
||||||
setAuthState("authorized");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
handleRequestError(error, "Не удалось проверить админ-сессию");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const loginMutation = useAdminLogin({
|
|
||||||
onSuccess: async () => {
|
|
||||||
setAuthState("authorized");
|
|
||||||
setToken("");
|
|
||||||
pushFlash({ type: "success", message: "Админ-сессия активирована" });
|
|
||||||
await invalidateAll();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
if (isUnauthorizedError(error)) {
|
|
||||||
clearAdminAuth();
|
|
||||||
pushFlash({ type: "error", message: "Неверный токен" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleRequestError(error, "Ошибка входа");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const logoutMutation = useAdminLogout({
|
|
||||||
onSuccess: async () => {
|
|
||||||
setAuthState("unauthorized");
|
|
||||||
pushFlash({ type: "info", message: "Сессия завершена" });
|
|
||||||
await invalidateAll();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
handleRequestError(error, "Ошибка выхода");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const contextValue = useMemo(
|
|
||||||
() => ({
|
|
||||||
authState,
|
|
||||||
isAuthorized: authState === "authorized",
|
|
||||||
pushFlash,
|
|
||||||
clearFlash,
|
|
||||||
handleRequestError,
|
|
||||||
invalidateAll,
|
|
||||||
}),
|
|
||||||
[authState, pushFlash, clearFlash, handleRequestError, invalidateAll],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminContext.Provider value={contextValue}>
|
|
||||||
{authState !== "authorized" ? (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 px-6 py-10">
|
|
||||||
<div className="w-full max-w-xl space-y-6">
|
|
||||||
<AdminLoginForm
|
|
||||||
token={token}
|
|
||||||
onTokenChange={setToken}
|
|
||||||
onSubmit={() => {
|
|
||||||
if (!token) {
|
|
||||||
pushFlash({ type: "info", message: "Введите токен" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loginMutation.mutate({ secret: token });
|
|
||||||
}}
|
|
||||||
isSubmitting={loginMutation.isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<AdminLayoutFrame>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-200 ring-1 ring-emerald-500/40">
|
|
||||||
Авторизован
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg border border-slate-700 px-3 py-1 text-xs font-semibold text-slate-300 transition hover:border-sky-500 hover:text-white"
|
|
||||||
onClick={() => invalidateAll()}
|
|
||||||
>
|
|
||||||
Обновить все данные
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-rose-600 px-3 py-1 text-xs font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:bg-rose-900/50"
|
|
||||||
onClick={() => logoutMutation.mutate()}
|
|
||||||
disabled={logoutMutation.isLoading}
|
|
||||||
>
|
|
||||||
Выйти
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Outlet />
|
|
||||||
</AdminLayoutFrame>
|
|
||||||
)}
|
|
||||||
<AdminFlash flash={flash} onClose={clearFlash} />
|
|
||||||
</AdminContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdminPage = AdminShell;
|
|
||||||
|
|
||||||
export const AdminIndexRedirect = () => {
|
|
||||||
return <Navigate to={`${Routes.Admin}/${DEFAULT_ADMIN_SECTION.path}`} replace />;
|
|
||||||
};
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
import { useAdminBlockchain } from "~/shared/services/admin";
|
|
||||||
import { Section, Badge, CopyButton } from "../components";
|
|
||||||
import { useAdminContext } from "../context";
|
|
||||||
import { formatDate, formatUnknown, numberFormatter } from "../utils/format";
|
|
||||||
|
|
||||||
const MetricCard = ({ label, value }: { label: string; value: string }) => {
|
|
||||||
return (
|
|
||||||
<div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold text-slate-100">{value}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdminBlockchainPage = () => {
|
|
||||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
|
||||||
|
|
||||||
const blockchainQuery = useAdminBlockchain({
|
|
||||||
enabled: isAuthorized,
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
onError: (error) => handleRequestError(error, "Не удалось загрузить данные блокчейна"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!blockchainQuery.data) {
|
|
||||||
return blockchainQuery.isLoading ? (
|
|
||||||
<Section id="blockchain" title="Блокчейн" description="Очередь задач и последние транзакции">
|
|
||||||
Загрузка…
|
|
||||||
</Section>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { counts, recent } = blockchainQuery.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
id="blockchain"
|
|
||||||
title="Блокчейн"
|
|
||||||
description="Очередь задач и последние транзакции"
|
|
||||||
actions={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
|
|
||||||
onClick={() => blockchainQuery.refetch()}
|
|
||||||
disabled={blockchainQuery.isFetching}
|
|
||||||
>
|
|
||||||
{blockchainQuery.isFetching ? "Обновляем…" : "Обновить"}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{Object.entries(counts).map(([key, value]) => (
|
|
||||||
<MetricCard key={key} label={key} value={numberFormatter.format(value)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="hidden overflow-x-auto rounded-2xl border border-slate-800 md:block">
|
|
||||||
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
|
||||||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-3">ID</th>
|
|
||||||
<th className="px-3 py-3">Назначение</th>
|
|
||||||
<th className="px-3 py-3">Сумма</th>
|
|
||||||
<th className="px-3 py-3">Статус</th>
|
|
||||||
<th className="px-3 py-3">Epoch · Seqno</th>
|
|
||||||
<th className="px-3 py-3">Hash</th>
|
|
||||||
<th className="px-3 py-3">Обновлено</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-900/60">
|
|
||||||
{recent.map((task) => (
|
|
||||||
<tr key={task.id} className="hover:bg-slate-900/50">
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-mono text-xs text-slate-300">{task.id}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={task.id}
|
|
||||||
aria-label="Скопировать ID задачи"
|
|
||||||
successMessage="ID задачи скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 break-words">{task.destination || "—"}</td>
|
|
||||||
<td className="px-3 py-2">{formatUnknown(task.amount)}</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<Badge tone={task.status === "done" ? "success" : task.status === "failed" ? "danger" : "neutral"}>
|
|
||||||
{task.status}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
{task.epoch ?? "—"} · {task.seqno ?? "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
{task.transaction_hash ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="break-all font-mono text-xs text-slate-500">{task.transaction_hash}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={task.transaction_hash}
|
|
||||||
aria-label="Скопировать хеш транзакции"
|
|
||||||
successMessage="Хеш транзакции скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-slate-500">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">{formatDate(task.updated)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 md:hidden">
|
|
||||||
{recent.map((task) => (
|
|
||||||
<div key={`task-card-${task.id}`} className="rounded-2xl border border-slate-800 bg-slate-950/40 p-4 shadow-inner shadow-black/30">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-mono text-xs text-slate-300">{task.id}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={task.id}
|
|
||||||
aria-label="Скопировать ID задачи"
|
|
||||||
successMessage="ID задачи скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Badge tone={task.status === "done" ? "success" : task.status === "failed" ? "danger" : "neutral"}>
|
|
||||||
{task.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid gap-2 text-xs text-slate-300">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Назначение</span>
|
|
||||||
<span className="break-words">{task.destination || "—"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Сумма</span>
|
|
||||||
<span>{formatUnknown(task.amount)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Epoch · Seqno</span>
|
|
||||||
<span>
|
|
||||||
{task.epoch ?? "—"} · {task.seqno ?? "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Hash</span>
|
|
||||||
{task.transaction_hash ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="break-all font-mono text-[11px] text-slate-500">{task.transaction_hash}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={task.transaction_hash}
|
|
||||||
aria-label="Скопировать хеш транзакции"
|
|
||||||
successMessage="Хеш транзакции скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-slate-500">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Обновлено</span>
|
|
||||||
<span>{formatDate(task.updated)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { Routes } from "~/app/router/constants";
|
|
||||||
import { useAdminEvents } from "~/shared/services/admin";
|
|
||||||
import { Section, PaginationControls, CopyButton, Badge } from "../components";
|
|
||||||
import { useAdminContext } from "../context";
|
|
||||||
import { formatDate, numberFormatter } from "../utils/format";
|
|
||||||
|
|
||||||
const EVENT_TYPE_LABELS: Record<string, string> = {
|
|
||||||
content_uploaded: "Загрузка контента",
|
|
||||||
content_indexed: "Индексация контента",
|
|
||||||
stars_payment: "Платёж Stars",
|
|
||||||
node_registered: "Регистрация ноды",
|
|
||||||
user_role_changed: "Изменение ролей",
|
|
||||||
};
|
|
||||||
|
|
||||||
const EVENT_STATUS_TONES: Record<string, "success" | "warn" | "danger" | "neutral"> = {
|
|
||||||
local: "neutral",
|
|
||||||
recorded: "neutral",
|
|
||||||
processing: "warn",
|
|
||||||
applied: "success",
|
|
||||||
failed: "danger",
|
|
||||||
};
|
|
||||||
|
|
||||||
const linkLabels: Record<string, string> = {
|
|
||||||
admin_uploads: "К загрузкам",
|
|
||||||
content_view: "Просмотр контента",
|
|
||||||
admin_stars: "К платежам",
|
|
||||||
admin_user: "К пользователю",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdminEventsPage = () => {
|
|
||||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
|
||||||
const [originFilter, setOriginFilter] = useState<string | null>(null);
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const limit = 50;
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const normalizedSearch = search.trim() || null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(0);
|
|
||||||
}, [normalizedSearch, typeFilter, statusFilter, originFilter]);
|
|
||||||
|
|
||||||
const eventsQuery = useAdminEvents(
|
|
||||||
{
|
|
||||||
limit,
|
|
||||||
offset: page * limit,
|
|
||||||
search: normalizedSearch || undefined,
|
|
||||||
type: typeFilter || undefined,
|
|
||||||
status: statusFilter || undefined,
|
|
||||||
origin: originFilter || undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: isAuthorized,
|
|
||||||
keepPreviousData: true,
|
|
||||||
refetchInterval: 60_000,
|
|
||||||
onError: (error) => handleRequestError(error, "Не удалось загрузить события"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const availableFilters = eventsQuery.data?.available_filters ?? {
|
|
||||||
types: {},
|
|
||||||
statuses: {},
|
|
||||||
origins: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLink = (key: string, url: string | null) => {
|
|
||||||
if (!url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const label = linkLabels[key] ?? key;
|
|
||||||
if (url.startsWith("http")) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={`${key}-${url}`}
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-lg border border-sky-500/40 px-3 py-1 text-[11px] font-semibold text-sky-100 transition hover:border-sky-400 hover:text-white"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const normalized = url.startsWith("/") ? url.slice(1) : url;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${key}-${url}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate(`${Routes.Admin}/${normalized}`)}
|
|
||||||
className="rounded-lg border border-slate-700 px-3 py-1 text-[11px] font-semibold text-slate-200 transition hover:border-slate-500 hover:text-white"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const events = eventsQuery.data?.items ?? [];
|
|
||||||
const total = eventsQuery.data?.total ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
id="events"
|
|
||||||
title="События"
|
|
||||||
description="Журнал контента, платежей и сетевых операций"
|
|
||||||
actions={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
|
|
||||||
onClick={() => eventsQuery.refetch()}
|
|
||||||
disabled={eventsQuery.isFetching}
|
|
||||||
>
|
|
||||||
{eventsQuery.isFetching ? "Обновляем…" : "Обновить"}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">Всего событий</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold text-slate-100">{numberFormatter.format(total)}</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">На странице {numberFormatter.format(events.length)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">Типы</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold text-slate-100">{Object.keys(availableFilters.types).length}</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">Фильтр по типу помогает сузить список</p>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">Статусы</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold text-slate-100">{Object.keys(availableFilters.statuses).length}</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">Например, только требующие действий</p>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">Источники</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold text-slate-100">{Object.keys(availableFilters.origins).length}</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">Отслеживайте trusted-нод</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-3 md:grid-cols-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
|
|
||||||
<input
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
|
||||||
placeholder="UID, CID, invoice, json"
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Тип</label>
|
|
||||||
<select
|
|
||||||
value={typeFilter ?? ""}
|
|
||||||
onChange={(event) => setTypeFilter(event.target.value || null)}
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
|
||||||
>
|
|
||||||
<option value="">Все типы</option>
|
|
||||||
{Object.entries(availableFilters.types).map(([type, count]) => (
|
|
||||||
<option key={`event-type-${type}`} value={type}>
|
|
||||||
{EVENT_TYPE_LABELS[type] ?? type} — {numberFormatter.format(count)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Статус</label>
|
|
||||||
<select
|
|
||||||
value={statusFilter ?? ""}
|
|
||||||
onChange={(event) => setStatusFilter(event.target.value || null)}
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
|
||||||
>
|
|
||||||
<option value="">Все статусы</option>
|
|
||||||
{Object.entries(availableFilters.statuses).map(([status, count]) => (
|
|
||||||
<option key={`event-status-${status}`} value={status}>
|
|
||||||
{status} — {numberFormatter.format(count)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Источник</label>
|
|
||||||
<select
|
|
||||||
value={originFilter ?? ""}
|
|
||||||
onChange={(event) => setOriginFilter(event.target.value || null)}
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
|
||||||
>
|
|
||||||
<option value="">Все источники</option>
|
|
||||||
{Object.entries(availableFilters.origins).map(([origin, count]) => (
|
|
||||||
<option key={`event-origin-${origin}`} value={origin}>
|
|
||||||
{origin} — {numberFormatter.format(count)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
|
|
||||||
onClick={() => {
|
|
||||||
setSearch("");
|
|
||||||
setTypeFilter(null);
|
|
||||||
setStatusFilter(null);
|
|
||||||
setOriginFilter(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сбросить фильтры
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{eventsQuery.isLoading && !eventsQuery.data ? (
|
|
||||||
<div className="mt-6 rounded-2xl border border-slate-800 bg-slate-950/40 px-4 py-6 text-center text-sm text-slate-400">
|
|
||||||
Загружаем события…
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4">
|
|
||||||
{events.length === 0 ? (
|
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/40 px-4 py-6 text-center text-sm text-slate-400">
|
|
||||||
События не найдены.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
events.map((event) => {
|
|
||||||
const typeLabel = EVENT_TYPE_LABELS[event.event_type] ?? event.event_type;
|
|
||||||
const statusTone = EVENT_STATUS_TONES[event.status] ?? 'neutral';
|
|
||||||
const statusBadgeTone: "success" | "warn" | "danger" | "neutral" = statusTone;
|
|
||||||
const linkEntries = Object.entries(event.links ?? {});
|
|
||||||
return (
|
|
||||||
<div key={`event-${event.id}`} className="rounded-2xl border border-slate-800 bg-slate-950/50 p-5 shadow-inner shadow-black/40">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-200">
|
|
||||||
<span className="font-semibold">#{numberFormatter.format(event.seq)}</span>
|
|
||||||
<Badge tone="neutral">{typeLabel}</Badge>
|
|
||||||
<Badge tone={statusBadgeTone}>{event.status}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
|
|
||||||
<span>UID: {event.uid}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={event.uid}
|
|
||||||
aria-label="Скопировать UID"
|
|
||||||
successMessage="UID скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-400 text-right">
|
|
||||||
<div>Создано: {formatDate(event.created_at)}</div>
|
|
||||||
<div>Получено: {formatDate(event.received_at)}</div>
|
|
||||||
<div>Применено: {formatDate(event.applied_at)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid gap-2 text-xs text-slate-300 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Источник</span>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 break-all text-[11px] text-slate-300">
|
|
||||||
<span>{event.origin_public_key ?? "—"}</span>
|
|
||||||
{event.origin_public_key ? (
|
|
||||||
<CopyButton
|
|
||||||
value={event.origin_public_key}
|
|
||||||
aria-label="Скопировать ключ"
|
|
||||||
successMessage="Публичный ключ скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-slate-500">{event.origin_host ?? "—"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Ссылки</span>
|
|
||||||
{linkEntries.length === 0 ? (
|
|
||||||
<div className="mt-1 text-[11px] text-slate-500">Нет прямых ссылок</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
{linkEntries.map(([key, url]) => renderLink(key, url))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<details className="mt-4 rounded-xl border border-slate-800 bg-slate-950/40">
|
|
||||||
<summary className="cursor-pointer px-4 py-2 text-xs font-semibold text-slate-200">Показать payload</summary>
|
|
||||||
<pre className="max-h-60 overflow-auto px-4 py-3 text-[11px] leading-relaxed text-slate-200">
|
|
||||||
{JSON.stringify(event.payload ?? {}, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<PaginationControls
|
|
||||||
total={total}
|
|
||||||
limit={limit}
|
|
||||||
page={page}
|
|
||||||
onPageChange={(next) => setPage(next)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,385 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { useAdminLicenses } from "~/shared/services/admin";
|
|
||||||
import { Section, PaginationControls, CopyButton, Badge } from "../components";
|
|
||||||
import { useAdminContext } from "../context";
|
|
||||||
import { formatDate, numberFormatter } from "../utils/format";
|
|
||||||
|
|
||||||
export const AdminLicensesPage = () => {
|
|
||||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
|
||||||
const [typeFilter, setTypeFilter] = useState("all");
|
|
||||||
const [licenseTypeFilter, setLicenseTypeFilter] = useState("all");
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
|
|
||||||
const limit = 50;
|
|
||||||
const normalizedSearch = search.trim();
|
|
||||||
|
|
||||||
const licensesQuery = useAdminLicenses(
|
|
||||||
{
|
|
||||||
limit,
|
|
||||||
offset: page * limit,
|
|
||||||
search: normalizedSearch || undefined,
|
|
||||||
status: statusFilter !== "all" ? statusFilter : undefined,
|
|
||||||
type: typeFilter !== "all" ? typeFilter : undefined,
|
|
||||||
license_type: licenseTypeFilter !== "all" ? licenseTypeFilter : undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: isAuthorized,
|
|
||||||
keepPreviousData: true,
|
|
||||||
refetchInterval: 60_000,
|
|
||||||
onError: (error) => handleRequestError(error, "Не удалось загрузить лицензии"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(0);
|
|
||||||
}, [normalizedSearch, statusFilter, typeFilter, licenseTypeFilter]);
|
|
||||||
|
|
||||||
if (licensesQuery.isLoading && !licensesQuery.data) {
|
|
||||||
return (
|
|
||||||
<Section id="licenses" title="Лицензии" description="On-chain лицензии и связанные активы">
|
|
||||||
Загрузка…
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!licensesQuery.data) {
|
|
||||||
return (
|
|
||||||
<Section id="licenses" title="Лицензии" description="On-chain лицензии и связанные активы">
|
|
||||||
<p className="text-sm text-slate-400">Выберите вкладку «Лицензии», чтобы загрузить данные.</p>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { items, counts, total } = licensesQuery.data;
|
|
||||||
const statusOptions = Object.keys(counts.status || {});
|
|
||||||
const typeOptions = Object.keys(counts.type || {});
|
|
||||||
const kindOptions = Object.keys(counts.license_type || {});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
id="licenses"
|
|
||||||
title="Лицензии"
|
|
||||||
description="Привязанные лицензии со сведениями о владельцах и контенте"
|
|
||||||
actions={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
|
|
||||||
onClick={() => licensesQuery.refetch()}
|
|
||||||
disabled={licensesQuery.isFetching}
|
|
||||||
>
|
|
||||||
{licensesQuery.isFetching ? "Обновляем…" : "Обновить"}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
|
|
||||||
<input
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
|
||||||
placeholder="Адрес, владелец, hash"
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:w-64"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Статус</label>
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(event) => setStatusFilter(event.target.value)}
|
|
||||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
|
||||||
>
|
|
||||||
<option value="all">Все</option>
|
|
||||||
{statusOptions.map((option) => (
|
|
||||||
<option key={`license-status-${option}`} value={option}>
|
|
||||||
{option}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Тип</label>
|
|
||||||
<select
|
|
||||||
value={typeFilter}
|
|
||||||
onChange={(event) => setTypeFilter(event.target.value)}
|
|
||||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
|
||||||
>
|
|
||||||
<option value="all">Все</option>
|
|
||||||
{typeOptions.map((option) => (
|
|
||||||
<option key={`license-type-${option}`} value={option}>
|
|
||||||
{option}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">license_type</label>
|
|
||||||
<select
|
|
||||||
value={licenseTypeFilter}
|
|
||||||
onChange={(event) => setLicenseTypeFilter(event.target.value)}
|
|
||||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
|
||||||
>
|
|
||||||
<option value="all">Все</option>
|
|
||||||
{kindOptions.map((option) => (
|
|
||||||
<option key={`license-kind-${option}`} value={option}>
|
|
||||||
{option === "unknown" ? "Не указано" : option}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
|
|
||||||
onClick={() => {
|
|
||||||
setSearch("");
|
|
||||||
setStatusFilter("all");
|
|
||||||
setTypeFilter("all");
|
|
||||||
setLicenseTypeFilter("all");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сбросить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 hidden overflow-x-auto rounded-2xl border border-slate-800 md:block">
|
|
||||||
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
|
||||||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-3">Лицензия</th>
|
|
||||||
<th className="px-3 py-3">On-chain</th>
|
|
||||||
<th className="px-3 py-3">Пользователь</th>
|
|
||||||
<th className="px-3 py-3">Контент</th>
|
|
||||||
<th className="px-3 py-3">Дата</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-900/60">
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="px-3 py-6 text-center text-sm text-slate-400">
|
|
||||||
Ничего не найдено.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
items.map((license) => (
|
|
||||||
<tr key={`license-${license.id}`} className="hover:bg-slate-900/40">
|
|
||||||
<td className="px-3 py-3 align-top">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 font-semibold text-slate-100">
|
|
||||||
<span>#{numberFormatter.format(license.id)}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={license.id}
|
|
||||||
aria-label="Скопировать ID лицензии"
|
|
||||||
successMessage="ID лицензии скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-400">{license.type ?? "—"} · {license.status ?? "—"}</div>
|
|
||||||
<div className="text-[10px] uppercase tracking-wide text-slate-500">
|
|
||||||
license_type: {license.license_type ?? "—"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 align-top">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{license.onchain_address ? (
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
|
||||||
<a
|
|
||||||
href={`https://tonviewer.com/${license.onchain_address}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="break-all text-sky-300 hover:text-sky-200"
|
|
||||||
>
|
|
||||||
{license.onchain_address}
|
|
||||||
</a>
|
|
||||||
<CopyButton
|
|
||||||
value={license.onchain_address}
|
|
||||||
aria-label="Скопировать on-chain адрес"
|
|
||||||
successMessage="On-chain адрес скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-slate-500">—</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
|
|
||||||
<span className="break-all">
|
|
||||||
Владелец: {license.owner_address ?? "—"}
|
|
||||||
</span>
|
|
||||||
{license.owner_address ? (
|
|
||||||
<CopyButton
|
|
||||||
value={license.owner_address}
|
|
||||||
aria-label="Скопировать адрес владельца"
|
|
||||||
successMessage="Адрес владельца скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 align-top">
|
|
||||||
{license.user ? (
|
|
||||||
<div className="flex flex-col gap-1 text-xs text-slate-300">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span>ID {numberFormatter.format(license.user.id)}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={license.user.id}
|
|
||||||
aria-label="Скопировать ID пользователя"
|
|
||||||
successMessage="ID пользователя скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span>TG {numberFormatter.format(license.user.telegram_id)}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={license.user.telegram_id}
|
|
||||||
aria-label="Скопировать Telegram ID"
|
|
||||||
successMessage="Telegram ID скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-slate-500">—</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 align-top">
|
|
||||||
{license.content ? (
|
|
||||||
<>
|
|
||||||
<div className="text-xs text-slate-300">{license.content.title}</div>
|
|
||||||
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
|
|
||||||
<span className="break-all">{license.content.hash}</span>
|
|
||||||
{license.content.hash ? (
|
|
||||||
<CopyButton
|
|
||||||
value={license.content.hash}
|
|
||||||
aria-label="Скопировать хеш контента"
|
|
||||||
successMessage="Хеш контента скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-slate-500">—</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 align-top text-xs text-slate-400">
|
|
||||||
<div>Создано: {formatDate(license.created_at)}</div>
|
|
||||||
<div>Обновлено: {formatDate(license.updated_at)}</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-4 md:hidden">
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/40 px-4 py-6 text-center text-sm text-slate-400">
|
|
||||||
Ничего не найдено.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
items.map((license) => (
|
|
||||||
<div key={`license-card-${license.id}`} className="rounded-2xl border border-slate-800 bg-slate-950/40 p-4 shadow-inner shadow-black/30">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-slate-100">
|
|
||||||
<span>#{numberFormatter.format(license.id)}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={license.id}
|
|
||||||
aria-label="Скопировать ID лицензии"
|
|
||||||
successMessage="ID лицензии скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Badge tone={license.status === "active" ? "success" : "neutral"}>
|
|
||||||
{license.status ?? "—"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-slate-400">
|
|
||||||
<span>{license.type ?? "—"}</span> · <span>license_type: {license.license_type ?? "—"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 space-y-3 text-xs text-slate-300">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">On-chain адрес</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="break-all text-sky-300">
|
|
||||||
{license.onchain_address ?? "—"}
|
|
||||||
</span>
|
|
||||||
{license.onchain_address ? (
|
|
||||||
<CopyButton
|
|
||||||
value={license.onchain_address}
|
|
||||||
aria-label="Скопировать on-chain адрес"
|
|
||||||
successMessage="On-chain адрес скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Адрес владельца</span>
|
|
||||||
<div className="flex items-center gap-2 text-[11px] text-slate-400">
|
|
||||||
<span className="break-all">{license.owner_address ?? "—"}</span>
|
|
||||||
{license.owner_address ? (
|
|
||||||
<CopyButton
|
|
||||||
value={license.owner_address}
|
|
||||||
aria-label="Скопировать адрес владельца"
|
|
||||||
successMessage="Адрес владельца скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{license.user ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-slate-300">
|
|
||||||
<span>ID {numberFormatter.format(license.user.id)}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={license.user.id}
|
|
||||||
aria-label="Скопировать ID пользователя"
|
|
||||||
successMessage="ID пользователя скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
<span>· TG {numberFormatter.format(license.user.telegram_id)}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={license.user.telegram_id}
|
|
||||||
aria-label="Скопировать Telegram ID"
|
|
||||||
successMessage="Telegram ID скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{license.content ? (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Контент</span>
|
|
||||||
<span>{license.content.title}</span>
|
|
||||||
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
|
|
||||||
<span className="break-all">{license.content.hash}</span>
|
|
||||||
{license.content.hash ? (
|
|
||||||
<CopyButton
|
|
||||||
value={license.content.hash}
|
|
||||||
aria-label="Скопировать хеш контента"
|
|
||||||
successMessage="Хеш контента скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-[11px] text-slate-500">
|
|
||||||
<div>Создано: {formatDate(license.created_at)}</div>
|
|
||||||
<div>Обновлено: {formatDate(license.updated_at)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<PaginationControls total={total} limit={limit} page={page} onPageChange={setPage} />
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,329 +0,0 @@
|
||||||
import React, { useMemo, useState } from "react";
|
|
||||||
import { useQuery } from "react-query";
|
|
||||||
import { request } from "~/shared/libs/request";
|
|
||||||
|
|
||||||
const ratioTone = (v: number) => {
|
|
||||||
if (v >= 0.9) return "text-emerald-400";
|
|
||||||
if (v >= 0.6) return "text-yellow-400";
|
|
||||||
return "text-red-400";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdminNetworkPage: React.FC = () => {
|
|
||||||
const [filterText, setFilterText] = useState("");
|
|
||||||
const [reachabilityFilter, setReachabilityFilter] = useState<"all" | "healthy" | "islands">("all");
|
|
||||||
const [sortBy, setSortBy] = useState<"leases" | "leaderships" | "reachability">("leases");
|
|
||||||
const [roleFilter, setRoleFilter] = useState<"all" | "trusted" | "read-only" | "deny">("all");
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [pageSize, setPageSize] = useState(25);
|
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery(
|
|
||||||
["admin", "network", page, pageSize],
|
|
||||||
async () => {
|
|
||||||
const { data } = await request.get("/admin.network", { params: { page, page_size: pageSize } });
|
|
||||||
return data as any;
|
|
||||||
},
|
|
||||||
{ keepPreviousData: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const members = data?.members ?? [];
|
|
||||||
const replication = data?.per_node_replication ?? {};
|
|
||||||
const summary = data?.summary;
|
|
||||||
const receipts = data?.receipts ?? [];
|
|
||||||
|
|
||||||
const [selectedNode, setSelectedNode] = useState<any | null>(null);
|
|
||||||
|
|
||||||
const total = data?.paging?.total ?? (data?.members?.length ?? 0);
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ key: "node_id", label: "NodeID" },
|
|
||||||
{ key: "public_host", label: "Публичный адрес" },
|
|
||||||
{ key: "version", label: "Версия" },
|
|
||||||
{ key: "role", label: "Роль" },
|
|
||||||
{ key: "ip", label: "IP" },
|
|
||||||
{ key: "asn", label: "ASN" },
|
|
||||||
{ key: "reachability_ratio", label: "Достижимость" },
|
|
||||||
{ key: "leases", label: "Реплики" },
|
|
||||||
{ key: "leaderships", label: "Лидерства" },
|
|
||||||
{ key: "receipts", label: "Квитанции" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
|
||||||
let list = members.map((m: any) => {
|
|
||||||
const p = replication[m.node_id] ?? { leases_held: 0, leaderships: 0, sample_contents: [] };
|
|
||||||
return {
|
|
||||||
...m,
|
|
||||||
leases: p.leases_held,
|
|
||||||
leaderships: p.leaderships,
|
|
||||||
_samples: p.sample_contents,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
// filter by text
|
|
||||||
if (filterText.trim()) {
|
|
||||||
const t = filterText.trim().toLowerCase();
|
|
||||||
list = list.filter((r: any) =>
|
|
||||||
(r.public_host ?? "").toLowerCase().includes(t) ||
|
|
||||||
(r.ip ?? "").toLowerCase().includes(t) ||
|
|
||||||
(r.node_id ?? "").toLowerCase().includes(t)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// role filter
|
|
||||||
if (roleFilter !== "all") {
|
|
||||||
list = list.filter((r: any) => (r.role ?? "read-only") === roleFilter);
|
|
||||||
}
|
|
||||||
// reachability filter
|
|
||||||
if (reachabilityFilter === "healthy") {
|
|
||||||
list = list.filter((r: any) => r.reachability_ratio >= 0.6);
|
|
||||||
} else if (reachabilityFilter === "islands") {
|
|
||||||
list = list.filter((r: any) => r.reachability_ratio < 0.6);
|
|
||||||
}
|
|
||||||
// sorting
|
|
||||||
list.sort((a: any, b: any) => {
|
|
||||||
if (sortBy === "leases") return b.leases - a.leases;
|
|
||||||
if (sortBy === "leaderships") return b.leaderships - a.leaderships;
|
|
||||||
return b.reachability_ratio - a.reachability_ratio;
|
|
||||||
});
|
|
||||||
// reset page if filters reduce list
|
|
||||||
return list;
|
|
||||||
}, [members, replication, filterText, reachabilityFilter, sortBy, roleFilter]);
|
|
||||||
const paged = rows; // сервер уже порезал список
|
|
||||||
|
|
||||||
if (isLoading) return <div className="p-4">Загрузка сети…</div>;
|
|
||||||
if (error) return <div className="p-4 text-red-400">Ошибка загрузки: {String(error)}</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6 p-4">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
|
||||||
<div className="rounded-lg bg-slate-800/50 p-3">
|
|
||||||
<div className="text-xs text-slate-400">Оценка размера сети</div>
|
|
||||||
<div className="text-2xl font-semibold">{summary?.n_estimate ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-slate-800/50 p-3">
|
|
||||||
<div className="text-xs text-slate-400">Оценка trusted‑сети</div>
|
|
||||||
<div className="text-2xl font-semibold">{summary?.n_estimate_trusted ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-slate-800/50 p-3">
|
|
||||||
<div className="text-xs text-slate-400">Активные (все)</div>
|
|
||||||
<div className="text-2xl font-semibold">{summary?.active ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-slate-800/50 p-3">
|
|
||||||
<div className="text-xs text-slate-400">Активные (trusted)</div>
|
|
||||||
<div className="text-2xl font-semibold">{summary?.active_trusted ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-slate-800/50 p-3">
|
|
||||||
<div className="text-xs text-slate-400">Острова</div>
|
|
||||||
<div className="text-2xl font-semibold text-yellow-400">{summary?.islands ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-slate-800/50 p-3">
|
|
||||||
<div className="text-xs text-slate-400">Конфликты репликаций</div>
|
|
||||||
<div className="text-sm">Недобор: <span className="text-red-400">{summary?.replication_conflicts.under ?? 0}</span></div>
|
|
||||||
<div className="text-sm">Перебор: <span className="text-yellow-400">{summary?.replication_conflicts.over ?? 0}</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-slate-800/50 p-3">
|
|
||||||
<div className="text-xs text-slate-400">Gossip/Backoff</div>
|
|
||||||
<div className="text-sm">Интервал: {summary?.config?.gossip_interval_sec ?? '-'} c</div>
|
|
||||||
<div className="text-sm">База: {summary?.config?.gossip_backoff_base_sec ?? '-'} c</div>
|
|
||||||
<div className="text-sm">Потолок: {summary?.config?.gossip_backoff_cap_sec ?? '-' } c</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg bg-slate-800/50 overflow-hidden">
|
|
||||||
<div className="px-3 py-2 text-sm text-slate-300 border-b border-slate-700 flex justify-between items-center">
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<div>Узлы сети</div>
|
|
||||||
<input
|
|
||||||
className="bg-slate-900/50 text-sm px-2 py-1 rounded border border-slate-700 outline-none"
|
|
||||||
placeholder="Фильтр: host/IP/NodeID"
|
|
||||||
value={filterText}
|
|
||||||
onChange={(e) => setFilterText(e.target.value)}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="bg-slate-900/50 text-sm px-2 py-1 rounded border border-slate-700"
|
|
||||||
value={roleFilter}
|
|
||||||
onChange={(e) => setRoleFilter(e.target.value as any)}
|
|
||||||
>
|
|
||||||
<option value="all">Любая роль</option>
|
|
||||||
<option value="trusted">Только trusted</option>
|
|
||||||
<option value="read-only">Только read-only</option>
|
|
||||||
<option value="deny">Только deny</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="bg-slate-900/50 text-sm px-2 py-1 rounded border border-slate-700"
|
|
||||||
value={reachabilityFilter}
|
|
||||||
onChange={(e) => setReachabilityFilter(e.target.value as any)}
|
|
||||||
>
|
|
||||||
<option value="all">Все</option>
|
|
||||||
<option value="healthy">Только здоровые</option>
|
|
||||||
<option value="islands">Только острова</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="bg-slate-900/50 text-sm px-2 py-1 rounded border border-slate-700"
|
|
||||||
value={sortBy}
|
|
||||||
onChange={(e) => setSortBy(e.target.value as any)}
|
|
||||||
>
|
|
||||||
<option value="leases">По репликам</option>
|
|
||||||
<option value="leaderships">По лидерствам</option>
|
|
||||||
<option value="reachability">По достижимости</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="bg-slate-900/50 text-sm px-2 py-1 rounded border border-slate-700"
|
|
||||||
value={pageSize}
|
|
||||||
onChange={(e) => { setPage(1); setPageSize(parseInt(e.target.value)); }}
|
|
||||||
>
|
|
||||||
<option value={25}>25</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => refetch()} className="text-xs px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded">
|
|
||||||
Обновить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-slate-900/40">
|
|
||||||
{columns.map((c) => (
|
|
||||||
<th key={c.key} className="px-3 py-2 text-left text-slate-400 font-medium whitespace-nowrap">{c.label}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{paged.map((r: any) => (
|
|
||||||
<tr key={r.node_id} className="hover:bg-slate-900/30 cursor-pointer" onClick={() => setSelectedNode(r)}>
|
|
||||||
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{r.node_id.slice(0, 10)}…</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{r.public_host ?? '-'}</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{r.version ?? '-'}</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{r.role}</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{r.ip ?? '-'}</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{r.asn ?? '-'}</td>
|
|
||||||
<td className={`px-3 py-2 whitespace-nowrap ${ratioTone(r.reachability_ratio)}`}>{(r.reachability_ratio * 100).toFixed(0)}%</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{r.leases}</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{r.leaderships}</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{r.receipts_asn_unique ?? 0}/{r.receipts_total ?? 0}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/* Pagination controls */}
|
|
||||||
<div className="px-3 py-2 border-t border-slate-700 flex items-center justify-between text-xs text-slate-400">
|
|
||||||
<div>Стр. {page} из {totalPages} • Всего: {total}</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="px-2 py-1 bg-slate-700 rounded disabled:opacity-50" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>Назад</button>
|
|
||||||
<button className="px-2 py-1 bg-slate-700 rounded disabled:opacity-50" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>Вперёд</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Reachability receipts table */}
|
|
||||||
<div className="rounded-lg bg-slate-800/50 overflow-hidden">
|
|
||||||
<div className="px-3 py-2 text-sm text-slate-300 border-b border-slate-700 flex justify-between items-center">
|
|
||||||
<div>Квитанции достижимости</div>
|
|
||||||
<div className="text-xs text-slate-400">{receipts.length} шт.</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-slate-900/40">
|
|
||||||
<th className="px-3 py-2 text-left text-slate-400 font-medium whitespace-nowrap">Target</th>
|
|
||||||
<th className="px-3 py-2 text-left text-slate-400 font-medium whitespace-nowrap">Issuer</th>
|
|
||||||
<th className="px-3 py-2 text-left text-slate-400 font-medium whitespace-nowrap">ASN</th>
|
|
||||||
<th className="px-3 py-2 text-left text-slate-400 font-medium whitespace-nowrap">Статус</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{receipts.map((r: any, i: number) => (
|
|
||||||
<tr key={`${r.issuer_id}:${r.target_id}:${i}`} className="hover:bg-slate-900/30">
|
|
||||||
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{r.target_id.slice(0, 10)}…</td>
|
|
||||||
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{r.issuer_id.slice(0, 10)}…</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{r.asn ?? '-'}</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap">
|
|
||||||
{r.status === 'valid' && <span className="text-emerald-400">подпись ок</span>}
|
|
||||||
{r.status === 'bad_signature' && <span className="text-red-400">ошибка подписи</span>}
|
|
||||||
{r.status === 'unknown_issuer' && <span className="text-yellow-400">неизвестный эмитент</span>}
|
|
||||||
{r.status === 'mismatch_node_id' && <span className="text-orange-400">node_id≠pubkey</span>}
|
|
||||||
{r.status === 'unknown' && <span className="text-slate-400">—</span>}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-slate-500">
|
|
||||||
Подсказка: недобор/перебор репликаций сигнализируют о проблемах с диверсификацией или подсчётом N_estimate.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedNode && (
|
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-slate-900 rounded-lg border border-slate-700 w-[min(92vw,720px)] max-h-[80vh] overflow-auto">
|
|
||||||
<div className="px-4 py-3 border-b border-slate-700 flex items-center justify-between">
|
|
||||||
<div className="text-sm text-slate-300">Детали узла</div>
|
|
||||||
<button className="text-xs px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded" onClick={() => setSelectedNode(null)}>Закрыть</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 text-sm space-y-2">
|
|
||||||
<div><span className="text-slate-400">NodeID:</span> <span className="font-mono text-xs">{selectedNode.node_id}</span></div>
|
|
||||||
<div><span className="text-slate-400">Публичный адрес:</span> {selectedNode.public_host ?? '—'}</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div><span className="text-slate-400">Версия:</span> {selectedNode.version ?? '—'}</div>
|
|
||||||
<div><span className="text-slate-400">Роль:</span> {selectedNode.role}</div>
|
|
||||||
<div><span className="text-slate-400">IP:</span> {selectedNode.ip ?? '—'}</div>
|
|
||||||
<div><span className="text-slate-400">ASN:</span> {selectedNode.asn ?? '—'}</div>
|
|
||||||
<div><span className="text-slate-400">Достижимость:</span> {(selectedNode.reachability_ratio * 100).toFixed(0)}%</div>
|
|
||||||
<div><span className="text-slate-400">Квитанции:</span> {selectedNode.receipts_asn_unique ?? 0}/{selectedNode.receipts_total ?? 0}</div>
|
|
||||||
<div><span className="text-slate-400">Реплики:</span> {selectedNode.leases}</div>
|
|
||||||
<div><span className="text-slate-400">Лидерства:</span> {selectedNode.leaderships}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-slate-400 mb-1">Примеры контента:</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(selectedNode._samples ?? []).map((c: string) => (
|
|
||||||
<span key={c} className="px-2 py-1 bg-slate-800 rounded text-xs font-mono">{c.slice(0,10)}…</span>
|
|
||||||
))}
|
|
||||||
{(!selectedNode._samples || selectedNode._samples.length === 0) && (
|
|
||||||
<span className="text-slate-500">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-slate-400 mb-1">Конфликты:</div>
|
|
||||||
<div className="max-h-40 overflow-auto border border-slate-800 rounded">
|
|
||||||
<table className="min-w-full text-xs">
|
|
||||||
<thead className="bg-slate-900">
|
|
||||||
<tr>
|
|
||||||
<th className="px-2 py-1 text-left text-slate-400">Тип</th>
|
|
||||||
<th className="px-2 py-1 text-left text-slate-400">Время</th>
|
|
||||||
<th className="px-2 py-1 text-left text-slate-400">Content</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{(selectedNode.conflict_samples ?? []).map((item: any, idx: number) => (
|
|
||||||
<tr key={idx} className="odd:bg-slate-900/40">
|
|
||||||
<td className="px-2 py-1 whitespace-nowrap">{item.type}</td>
|
|
||||||
<td className="px-2 py-1 whitespace-nowrap">{item.ts ? new Date(item.ts * 1000).toLocaleString() : '—'}</td>
|
|
||||||
<td className="px-2 py-1 font-mono whitespace-nowrap">{(item.content_id || '').slice(0, 10)}…</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{(!selectedNode.conflict_samples || selectedNode.conflict_samples.length === 0) && (
|
|
||||||
<tr><td colSpan={3} className="px-2 py-2 text-slate-500 text-center">Нет данных</td></tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selectedNode.public_host && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<a href={selectedNode.public_host} target="_blank" rel="noreferrer" className="text-emerald-400 hover:underline">Открыть узел</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminNetworkPage;
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { request } from "~/shared/libs/request";
|
|
||||||
|
|
||||||
type Config = {
|
|
||||||
heartbeat_interval: number;
|
|
||||||
lease_ttl: number;
|
|
||||||
gossip_interval_sec: number;
|
|
||||||
gossip_backoff_base_sec: number;
|
|
||||||
gossip_backoff_cap_sec: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdminNetworkSettingsPage: React.FC = () => {
|
|
||||||
const [cfg, setCfg] = useState<Config | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await request.get("/admin.network.config");
|
|
||||||
setCfg(data.config);
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(String(e?.message || e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
useEffect(() => { void load(); }, []);
|
|
||||||
|
|
||||||
const update = async () => {
|
|
||||||
if (!cfg) return;
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await request.post("/admin.network.config.set", cfg);
|
|
||||||
await load();
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(String(e?.message || e));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!cfg) return <div className="p-4">Загрузка настроек…</div>;
|
|
||||||
|
|
||||||
const Field: React.FC<{ label: string; value: number; onChange: (v:number)=>void; tip?: string }> = ({label, value, onChange, tip}) => (
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-xs text-slate-400">{label}</span>
|
|
||||||
<input type="number" className="bg-slate-900/50 px-2 py-1 rounded border border-slate-700" value={value} onChange={(e)=>onChange(parseInt(e.target.value || '0'))} />
|
|
||||||
{tip && <span className="text-[10px] text-slate-500">{tip}</span>}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 flex flex-col gap-4">
|
|
||||||
<div className="text-lg font-semibold">Сеть → Настройки</div>
|
|
||||||
{error && <div className="text-sm text-red-400">Ошибка: {error}</div>}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<Field label="Интервал heartbeat (сек)" value={cfg.heartbeat_interval} onChange={(v)=>setCfg({...cfg, heartbeat_interval: v})} tip="Период обновления лизов" />
|
|
||||||
<Field label="TTL лиза (сек)" value={cfg.lease_ttl} onChange={(v)=>setCfg({...cfg, lease_ttl: v})} tip="Время жизни лиза до истечения" />
|
|
||||||
<Field label="Интервал gossip (сек)" value={cfg.gossip_interval_sec} onChange={(v)=>setCfg({...cfg, gossip_interval_sec: v})} tip="Период рассылки снимка DHT" />
|
|
||||||
<Field label="Бэк-офф база (сек)" value={cfg.gossip_backoff_base_sec} onChange={(v)=>setCfg({...cfg, gossip_backoff_base_sec: v})} tip="Начальный бэк-офф для неуспешных пиров" />
|
|
||||||
<Field label="Бэк-офф потолок (сек)" value={cfg.gossip_backoff_cap_sec} onChange={(v)=>setCfg({...cfg, gossip_backoff_cap_sec: v})} tip="Максимальный бэк-офф" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="px-3 py-2 bg-emerald-700 hover:bg-emerald-600 rounded disabled:opacity-50" onClick={()=>void update()} disabled={saving}>Сохранить</button>
|
|
||||||
<button className="px-3 py-2 bg-slate-700 hover:bg-slate-600 rounded" onClick={()=>void load()}>Сбросить</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-500">Примечание: некоторые параметры читаются задачами-демонами при каждом цикле и применяются без перезапуска.</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminNetworkSettingsPage;
|
|
||||||
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
import { ChangeEvent, useMemo } from "react";
|
|
||||||
|
|
||||||
import { useAdminNodeSetRole, useAdminNodes } from "~/shared/services/admin";
|
|
||||||
import { Section, CopyButton } from "../components";
|
|
||||||
import { useAdminContext } from "../context";
|
|
||||||
import { formatDate, formatUnknown } from "../utils/format";
|
|
||||||
|
|
||||||
export const AdminNodesPage = () => {
|
|
||||||
const { isAuthorized, handleRequestError, pushFlash } = useAdminContext();
|
|
||||||
|
|
||||||
const nodesQuery = useAdminNodes({
|
|
||||||
enabled: isAuthorized,
|
|
||||||
refetchInterval: 60_000,
|
|
||||||
onError: (error) => handleRequestError(error, "Не удалось загрузить список нод"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodeRoleMutation = useAdminNodeSetRole({
|
|
||||||
onSuccess: async ({ node }) => {
|
|
||||||
pushFlash({
|
|
||||||
type: "success",
|
|
||||||
message: `Роль узла ${node.public_key ?? node.ip ?? ""} обновлена до ${node.role}`,
|
|
||||||
});
|
|
||||||
await nodesQuery.refetch();
|
|
||||||
},
|
|
||||||
onError: (error) => handleRequestError(error, "Не удалось обновить роль узла"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const items = nodesQuery.data?.items ?? [];
|
|
||||||
|
|
||||||
const handleRoleChange = (publicKey: string | null, host: string | null) => {
|
|
||||||
return (event: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
const role = event.target.value as "trusted" | "read-only" | "deny";
|
|
||||||
nodeRoleMutation.mutate({
|
|
||||||
role,
|
|
||||||
public_key: publicKey ?? undefined,
|
|
||||||
host: host ?? undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedItems = useMemo(() => {
|
|
||||||
return [...items].sort((a, b) => {
|
|
||||||
const aSeen = a.last_seen ? new Date(a.last_seen).getTime() : 0;
|
|
||||||
const bSeen = b.last_seen ? new Date(b.last_seen).getTime() : 0;
|
|
||||||
return bSeen - aSeen;
|
|
||||||
});
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
if (!nodesQuery.data) {
|
|
||||||
return nodesQuery.isLoading ? (
|
|
||||||
<Section id="nodes" title="Ноды" description="Доверенные и внешние узлы">
|
|
||||||
Загрузка…
|
|
||||||
</Section>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section id="nodes" title="Ноды" description="Роли, версии и последнее появление">
|
|
||||||
<div className="hidden overflow-x-auto rounded-2xl border border-slate-800 md:block">
|
|
||||||
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
|
||||||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-3">IP</th>
|
|
||||||
<th className="px-3 py-3">Порт</th>
|
|
||||||
<th className="px-3 py-3">Публичный ключ</th>
|
|
||||||
<th className="px-3 py-3">Роль</th>
|
|
||||||
<th className="px-3 py-3">Версия</th>
|
|
||||||
<th className="px-3 py-3">Последний онлайн</th>
|
|
||||||
<th className="px-3 py-3">Заметки</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-900/60">
|
|
||||||
{sortedItems.map((node, index) => (
|
|
||||||
<tr key={node.public_key ?? node.ip ?? `node-${index}`} className="hover:bg-slate-900/50">
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
{node.ip ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-200">
|
|
||||||
<span className="break-all font-mono text-xs text-slate-300">{node.ip}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={node.ip}
|
|
||||||
aria-label="Скопировать IP-адрес"
|
|
||||||
successMessage="IP-адрес узла скопирован"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-slate-500">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
{node.port != null ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-200">
|
|
||||||
<span className="font-mono text-xs text-slate-300">{node.port}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={node.port}
|
|
||||||
aria-label="Скопировать порт"
|
|
||||||
successMessage="Порт узла скопирован"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-slate-500">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
{node.public_key ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="break-all font-mono text-[11px] text-slate-300">{node.public_key}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={node.public_key}
|
|
||||||
aria-label="Скопировать публичный ключ"
|
|
||||||
successMessage="Публичный ключ узла скопирован"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-slate-500">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<select
|
|
||||||
className="rounded-lg border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-100 focus:border-sky-500 focus:outline-none"
|
|
||||||
value={node.role}
|
|
||||||
onChange={handleRoleChange(node.public_key, node.ip)}
|
|
||||||
disabled={nodeRoleMutation.isLoading}
|
|
||||||
>
|
|
||||||
<option value="trusted">trusted</option>
|
|
||||||
<option value="read-only">read-only</option>
|
|
||||||
<option value="deny">deny</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">{node.version || "—"}</td>
|
|
||||||
<td className="px-3 py-2">{formatDate(node.last_seen)}</td>
|
|
||||||
<td className="px-3 py-2 break-words">{formatUnknown(node.notes)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 md:hidden">
|
|
||||||
{sortedItems.map((node, index) => (
|
|
||||||
<div
|
|
||||||
key={node.public_key ?? node.ip ?? `node-card-${index}`}
|
|
||||||
className="rounded-2xl border border-slate-800 bg-slate-950/50 p-4 shadow-inner shadow-black/30"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs uppercase tracking-wide text-slate-500">IP</span>
|
|
||||||
<span className="font-mono text-xs text-slate-200">{node.ip ?? "—"}</span>
|
|
||||||
{node.ip ? <CopyButton value={node.ip} aria-label="Скопировать IP-адрес" successMessage="IP-адрес узла скопирован" /> : null}
|
|
||||||
</div>
|
|
||||||
<span className="rounded-full bg-slate-900/80 px-3 py-1 text-[11px] uppercase tracking-wide text-slate-300">
|
|
||||||
{node.role}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid gap-3 text-xs text-slate-300">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-slate-500">Порт</span>
|
|
||||||
<span className="font-mono text-xs text-slate-200">{node.port ?? "—"}</span>
|
|
||||||
{node.port != null ? (
|
|
||||||
<CopyButton value={node.port} aria-label="Скопировать порт" successMessage="Порт узла скопирован" />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-slate-500">Публичный ключ</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="break-all font-mono text-[11px] text-slate-300">{node.public_key ?? "—"}</span>
|
|
||||||
{node.public_key ? (
|
|
||||||
<CopyButton
|
|
||||||
value={node.public_key}
|
|
||||||
aria-label="Скопировать публичный ключ"
|
|
||||||
successMessage="Публичный ключ узла скопирован"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Версия</span>
|
|
||||||
<span>{node.version || "—"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Последний онлайн</span>
|
|
||||||
<span>{formatDate(node.last_seen)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Заметки</span>
|
|
||||||
<span className="break-words text-slate-200">{formatUnknown(node.notes)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<label className="text-xs uppercase tracking-wide text-slate-500">Роль</label>
|
|
||||||
<select
|
|
||||||
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-2 py-2 text-xs text-slate-100 focus:border-sky-500 focus:outline-none"
|
|
||||||
value={node.role}
|
|
||||||
onChange={handleRoleChange(node.public_key, node.ip)}
|
|
||||||
disabled={nodeRoleMutation.isLoading}
|
|
||||||
>
|
|
||||||
<option value="trusted">trusted</option>
|
|
||||||
<option value="read-only">read-only</option>
|
|
||||||
<option value="deny">deny</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { useAdminOverview } from "~/shared/services/admin";
|
|
||||||
import { Section, Badge, InfoRow, CopyButton } from "../components";
|
|
||||||
import { useAdminContext } from "../context";
|
|
||||||
import { formatBytes, formatDate, formatUnknown, numberFormatter } from "../utils/format";
|
|
||||||
|
|
||||||
export const AdminOverviewPage = () => {
|
|
||||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
|
||||||
|
|
||||||
const overviewQuery = useAdminOverview({
|
|
||||||
enabled: isAuthorized,
|
|
||||||
retry: false,
|
|
||||||
refetchInterval: 60_000,
|
|
||||||
onError: (error) => {
|
|
||||||
handleRequestError(error, "Не удалось загрузить обзор");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const overviewCards = useMemo(() => {
|
|
||||||
const data = overviewQuery.data;
|
|
||||||
if (!data) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const { project, content, node, ipfs, codebase, runtime } = data;
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "Хост",
|
|
||||||
value: project.host || "локальный",
|
|
||||||
helper: project.name,
|
|
||||||
copyValue: project.host ?? null,
|
|
||||||
successMessage: "Хост скопирован",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "TON Master",
|
|
||||||
value: node.ton_master,
|
|
||||||
helper: "Платформа",
|
|
||||||
copyValue: node.ton_master ?? null,
|
|
||||||
successMessage: "TON Master скопирован",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Service Wallet",
|
|
||||||
value: node.service_wallet,
|
|
||||||
helper: node.id,
|
|
||||||
copyValue: node.service_wallet ?? null,
|
|
||||||
successMessage: "Service wallet скопирован",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Highload Wallet",
|
|
||||||
value: node.highload_wallet ?? "—",
|
|
||||||
helper: "Высоконагрузочный",
|
|
||||||
copyValue: node.highload_wallet ?? null,
|
|
||||||
successMessage: "Highload wallet скопирован",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Контент",
|
|
||||||
value: `${numberFormatter.format(content.encrypted_total)} зашифр.`,
|
|
||||||
helper: `${numberFormatter.format(content.derivatives_ready)} деривативов`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "IPFS Repo",
|
|
||||||
value: formatBytes(Number((ipfs.repo as Record<string, unknown>)?.RepoSize ?? 0)),
|
|
||||||
helper: "Размер репозитория",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Bitswap",
|
|
||||||
value: numberFormatter.format(Number((ipfs.bitswap as Record<string, unknown>)?.Peers ?? 0)),
|
|
||||||
helper: "Пиры",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Билд",
|
|
||||||
value: codebase.commit ?? "n/a",
|
|
||||||
helper: codebase.branch ?? "",
|
|
||||||
copyValue: codebase.commit ?? null,
|
|
||||||
successMessage: "Хеш коммита скопирован",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Python",
|
|
||||||
value: runtime.python,
|
|
||||||
helper: runtime.implementation,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [overviewQuery.data]);
|
|
||||||
|
|
||||||
if (!overviewQuery.data) {
|
|
||||||
return overviewQuery.isLoading ? (
|
|
||||||
<Section id="overview" title="Обзор" description="Краткий срез состояния узла, окружения и служб">
|
|
||||||
Загрузка…
|
|
||||||
</Section>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { project, services, ton, runtime, ipfs } = overviewQuery.data;
|
|
||||||
const ipfsIdentity = (ipfs.identity ?? {}) as Record<string, unknown>;
|
|
||||||
const ipfsBitswap = (ipfs.bitswap ?? {}) as Record<string, unknown>;
|
|
||||||
const ipfsRepo = (ipfs.repo ?? {}) as Record<string, unknown>;
|
|
||||||
const ipfsId = ipfsIdentity["ID"];
|
|
||||||
const ipfsAgent = ipfsIdentity["AgentVersion"];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
id="overview"
|
|
||||||
title="Обзор"
|
|
||||||
description="Краткий срез состояния узла, окружения и служб"
|
|
||||||
actions={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
|
|
||||||
onClick={() => overviewQuery.refetch()}
|
|
||||||
disabled={overviewQuery.isFetching}
|
|
||||||
>
|
|
||||||
{overviewQuery.isFetching ? "Обновляем…" : "Обновить"}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{overviewCards.map((card) => (
|
|
||||||
<div
|
|
||||||
key={card.label}
|
|
||||||
className="overflow-hidden rounded-xl bg-slate-950/60 p-4 shadow-inner shadow-black/40 ring-1 ring-slate-800"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
|
|
||||||
{card.copyValue ? (
|
|
||||||
<CopyButton
|
|
||||||
value={card.copyValue}
|
|
||||||
successMessage={card.successMessage}
|
|
||||||
aria-label={`Скопировать значение «${card.label}»`}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 break-all text-lg font-semibold text-slate-100">{card.value}</p>
|
|
||||||
{card.helper ? <p className="mt-1 break-words text-xs text-slate-500">{card.helper}</p> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-100">Проект</h3>
|
|
||||||
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<InfoRow label="Хост" copyValue={project.host ?? null}>
|
|
||||||
{project.host || "—"}
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="Имя">{project.name}</InfoRow>
|
|
||||||
<InfoRow label="Приватность">{project.privacy}</InfoRow>
|
|
||||||
<InfoRow label="TON Testnet">{ton.testnet ? "Да" : "Нет"}</InfoRow>
|
|
||||||
<InfoRow label="TON API Key">{ton.api_key_configured ? "Настроен" : "Нет"}</InfoRow>
|
|
||||||
<InfoRow label="TON Host" copyValue={ton.host ?? null}>
|
|
||||||
{ton.host || "—"}
|
|
||||||
</InfoRow>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-100">Среда выполнения</h3>
|
|
||||||
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<InfoRow label="Python">{runtime.python}</InfoRow>
|
|
||||||
<InfoRow label="Имплементация">{runtime.implementation}</InfoRow>
|
|
||||||
<InfoRow label="Платформа">{runtime.platform}</InfoRow>
|
|
||||||
<InfoRow label="UTC сейчас">{formatDate(runtime.utc_now)}</InfoRow>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-100">IPFS</h3>
|
|
||||||
<div className="grid gap-3 overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<InfoRow label="ID" copyValue={(ipfsId as string) ?? null}>
|
|
||||||
{formatUnknown(ipfsId)}
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="Agent">{formatUnknown(ipfsAgent)}</InfoRow>
|
|
||||||
<InfoRow label="Peers">
|
|
||||||
{numberFormatter.format(Number(ipfsBitswap?.Peers ?? 0))}
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="Repo size">
|
|
||||||
{formatBytes(Number(ipfsRepo?.RepoSize ?? 0))}
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="Storage max">
|
|
||||||
{formatBytes(Number(ipfsRepo?.StorageMax ?? 0))}
|
|
||||||
</InfoRow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-100">Сервисы</h3>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{services.length === 0 ? (
|
|
||||||
<li className="text-sm text-slate-400">Нет зарегистрированных сервисов</li>
|
|
||||||
) : (
|
|
||||||
services.map((service) => {
|
|
||||||
const status = service.status ?? "—";
|
|
||||||
const tone: "success" | "warn" | "danger" | "neutral" = status.includes("working")
|
|
||||||
? "success"
|
|
||||||
: status.includes("timeout")
|
|
||||||
? "danger"
|
|
||||||
: "neutral";
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={service.name}
|
|
||||||
className="flex items-center justify-between rounded-lg bg-slate-950/40 px-3 py-2 ring-1 ring-slate-800"
|
|
||||||
>
|
|
||||||
<span className="max-w-[50%] break-words text-sm font-medium text-slate-100">{service.name}</span>
|
|
||||||
<div className="flex items-center gap-3 text-xs text-slate-400">
|
|
||||||
<span>
|
|
||||||
{service.last_reported_seconds != null
|
|
||||||
? `Обновлён ${Math.round(service.last_reported_seconds)} сек назад`
|
|
||||||
: "Нет данных"}
|
|
||||||
</span>
|
|
||||||
<Badge tone={tone}>{status}</Badge>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
|
|
||||||
import { useAdminStars } from "~/shared/services/admin";
|
|
||||||
import { Section, PaginationControls, Badge } from "../components";
|
|
||||||
import { useAdminContext } from "../context";
|
|
||||||
import { formatDate, formatStars, numberFormatter } from "../utils/format";
|
|
||||||
|
|
||||||
export const AdminStarsPage = () => {
|
|
||||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
|
||||||
const location = useLocation();
|
|
||||||
const initialSearch = useMemo(() => {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
return params.get("search") ?? "";
|
|
||||||
}, [location.search]);
|
|
||||||
const [search, setSearch] = useState(initialSearch);
|
|
||||||
const [paidFilter, setPaidFilter] = useState<"all" | "paid" | "unpaid">("all");
|
|
||||||
const [typeFilter, setTypeFilter] = useState("all");
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
|
|
||||||
const limit = 50;
|
|
||||||
const normalizedSearch = search.trim();
|
|
||||||
|
|
||||||
const starsQuery = useAdminStars(
|
|
||||||
{
|
|
||||||
limit,
|
|
||||||
offset: page * limit,
|
|
||||||
search: normalizedSearch || undefined,
|
|
||||||
type: typeFilter !== "all" ? typeFilter : undefined,
|
|
||||||
paid: paidFilter === "all" ? undefined : paidFilter === "paid",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: isAuthorized,
|
|
||||||
keepPreviousData: true,
|
|
||||||
refetchInterval: 60_000,
|
|
||||||
onError: (error) => handleRequestError(error, "Не удалось загрузить платежи"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSearch(initialSearch);
|
|
||||||
}, [initialSearch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(0);
|
|
||||||
}, [normalizedSearch, paidFilter, typeFilter]);
|
|
||||||
|
|
||||||
if (starsQuery.isLoading && !starsQuery.data) {
|
|
||||||
return (
|
|
||||||
<Section id="stars" title="Платежи Stars" description="Телеграм Stars счета и связанные пользователи">
|
|
||||||
Загрузка…
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!starsQuery.data) {
|
|
||||||
return (
|
|
||||||
<Section id="stars" title="Платежи Stars" description="Телеграм Stars счета и связанные пользователи">
|
|
||||||
<p className="text-sm text-slate-400">Выберите вкладку «Платежи Stars», чтобы загрузить данные.</p>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { items, stats, total } = starsQuery.data;
|
|
||||||
const typeOptions = Object.keys(stats.by_type || {});
|
|
||||||
|
|
||||||
const statCards = [
|
|
||||||
{ label: "Всего платежей", value: numberFormatter.format(stats.total) },
|
|
||||||
{ label: "Оплачено", value: `${numberFormatter.format(stats.paid)} · ${formatStars(stats.amount_paid)}` },
|
|
||||||
{ label: "Неоплачено", value: `${numberFormatter.format(stats.unpaid)} · ${formatStars(stats.amount_unpaid)}` },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
id="stars"
|
|
||||||
title="Платежи Stars"
|
|
||||||
description="Телеграм Stars счета и связанные пользователи"
|
|
||||||
actions={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
|
|
||||||
onClick={() => starsQuery.refetch()}
|
|
||||||
disabled={starsQuery.isFetching}
|
|
||||||
>
|
|
||||||
{starsQuery.isFetching ? "Обновляем…" : "Обновить"}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
{statCards.map((card) => (
|
|
||||||
<div key={card.label} className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold text-slate-100">{card.value}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
|
|
||||||
<input
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
|
||||||
placeholder="ID, ссылка, контент"
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:w-64"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Статус</label>
|
|
||||||
<select
|
|
||||||
value={paidFilter}
|
|
||||||
onChange={(event) => setPaidFilter(event.target.value as "all" | "paid" | "unpaid")}
|
|
||||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
|
||||||
>
|
|
||||||
<option value="all">Все</option>
|
|
||||||
<option value="paid">Оплачено</option>
|
|
||||||
<option value="unpaid">Не оплачено</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Тип</label>
|
|
||||||
<select
|
|
||||||
value={typeFilter}
|
|
||||||
onChange={(event) => setTypeFilter(event.target.value)}
|
|
||||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
|
||||||
>
|
|
||||||
<option value="all">Все</option>
|
|
||||||
{typeOptions.map((option) => (
|
|
||||||
<option key={`stars-type-${option}`} value={option}>
|
|
||||||
{option}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
|
|
||||||
onClick={() => {
|
|
||||||
setSearch("");
|
|
||||||
setPaidFilter("all");
|
|
||||||
setTypeFilter("all");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сбросить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
|
||||||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-3">Инвойс</th>
|
|
||||||
<th className="px-3 py-3">Сумма</th>
|
|
||||||
<th className="px-3 py-3">Пользователь</th>
|
|
||||||
<th className="px-3 py-3">Контент</th>
|
|
||||||
<th className="px-3 py-3">Создан</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-900/60">
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="px-3 py-6 text-center text-sm text-slate-400">
|
|
||||||
Платежей не найдено.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
items.map((invoice) => (
|
|
||||||
<tr key={`stars-${invoice.id}`} className="hover:bg-slate-900/40">
|
|
||||||
<td className="px-3 py-3 align-top">
|
|
||||||
<div className="font-semibold text-slate-100">
|
|
||||||
#{numberFormatter.format(invoice.id)} · {invoice.type ?? "—"}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-400 break-all">{invoice.external_id}</div>
|
|
||||||
{invoice.invoice_url ? (
|
|
||||||
<a
|
|
||||||
href={invoice.invoice_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-xs text-sky-300 hover:text-sky-200"
|
|
||||||
>
|
|
||||||
Открыть счёт
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 align-top">
|
|
||||||
<div className="font-semibold text-slate-100">{formatStars(invoice.amount)}</div>
|
|
||||||
<Badge tone={invoice.paid ? "success" : "warn"}>{invoice.status}</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 align-top">
|
|
||||||
{invoice.user ? (
|
|
||||||
<div className="text-xs text-slate-300">
|
|
||||||
ID {numberFormatter.format(invoice.user.id)} · TG {numberFormatter.format(invoice.user.telegram_id)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-slate-500">—</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 align-top">
|
|
||||||
{invoice.content ? (
|
|
||||||
<>
|
|
||||||
<div className="text-xs text-slate-300">{invoice.content.title}</div>
|
|
||||||
<div className="break-all text-[10px] uppercase tracking-wide text-slate-500">
|
|
||||||
{invoice.content.hash}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-slate-500">—</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 align-top text-xs text-slate-400">{formatDate(invoice.created_at)}</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<PaginationControls total={total} limit={limit} page={page} onPageChange={setPage} />
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import {
|
|
||||||
useAdminCacheCleanup,
|
|
||||||
useAdminCacheSetLimits,
|
|
||||||
useAdminStatus,
|
|
||||||
useAdminSyncSetLimits,
|
|
||||||
} from "~/shared/services/admin";
|
|
||||||
import { Section, InfoRow } from "../components";
|
|
||||||
import { useAdminContext } from "../context";
|
|
||||||
import { formatBytes, numberFormatter } from "../utils/format";
|
|
||||||
|
|
||||||
type CacheLimitsFormValues = {
|
|
||||||
max_gb: number;
|
|
||||||
ttl_days: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CacheFitFormValues = {
|
|
||||||
max_gb: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SyncLimitsFormValues = {
|
|
||||||
max_concurrent_pins: number;
|
|
||||||
disk_low_watermark_pct: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdminStatusPage = () => {
|
|
||||||
const { isAuthorized, handleRequestError, pushFlash } = useAdminContext();
|
|
||||||
|
|
||||||
const statusQuery = useAdminStatus({
|
|
||||||
enabled: isAuthorized,
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
onError: (error) => handleRequestError(error, "Не удалось загрузить статус синхронизации"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const cacheLimitsForm = useForm<CacheLimitsFormValues>({
|
|
||||||
values: {
|
|
||||||
max_gb: statusQuery.data?.limits.DERIVATIVE_CACHE_MAX_GB ?? 50,
|
|
||||||
ttl_days: statusQuery.data?.limits.DERIVATIVE_CACHE_TTL_DAYS ?? 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const cacheFitForm = useForm<CacheFitFormValues>({
|
|
||||||
defaultValues: {
|
|
||||||
max_gb: statusQuery.data?.limits.DERIVATIVE_CACHE_MAX_GB ?? 50,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const syncLimitsForm = useForm<SyncLimitsFormValues>({
|
|
||||||
values: {
|
|
||||||
max_concurrent_pins: statusQuery.data?.limits.SYNC_MAX_CONCURRENT_PINS ?? 4,
|
|
||||||
disk_low_watermark_pct: statusQuery.data?.limits.SYNC_DISK_LOW_WATERMARK_PCT ?? 90,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!statusQuery.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cacheLimitsForm.reset({
|
|
||||||
max_gb: statusQuery.data.limits.DERIVATIVE_CACHE_MAX_GB ?? 50,
|
|
||||||
ttl_days: statusQuery.data.limits.DERIVATIVE_CACHE_TTL_DAYS ?? 0,
|
|
||||||
});
|
|
||||||
cacheFitForm.reset({
|
|
||||||
max_gb: statusQuery.data.limits.DERIVATIVE_CACHE_MAX_GB ?? 50,
|
|
||||||
});
|
|
||||||
syncLimitsForm.reset({
|
|
||||||
max_concurrent_pins: statusQuery.data.limits.SYNC_MAX_CONCURRENT_PINS ?? 4,
|
|
||||||
disk_low_watermark_pct: statusQuery.data.limits.SYNC_DISK_LOW_WATERMARK_PCT ?? 90,
|
|
||||||
});
|
|
||||||
}, [cacheFitForm, cacheLimitsForm, statusQuery.data, syncLimitsForm]);
|
|
||||||
|
|
||||||
const cacheLimitsMutation = useAdminCacheSetLimits({
|
|
||||||
onSuccess: async () => {
|
|
||||||
pushFlash({ type: "success", message: "Лимиты кэша обновлены" });
|
|
||||||
await statusQuery.refetch();
|
|
||||||
},
|
|
||||||
onError: (error) => handleRequestError(error, "Не удалось сохранить лимиты кэша"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const cacheCleanupMutation = useAdminCacheCleanup({
|
|
||||||
onSuccess: async (data) => {
|
|
||||||
const removed = typeof data.removed === "number" ? `Удалено файлов: ${data.removed}` : "";
|
|
||||||
pushFlash({ type: "success", message: `Очистка кэша выполнена. ${removed}`.trim() });
|
|
||||||
await statusQuery.refetch();
|
|
||||||
},
|
|
||||||
onError: (error) => handleRequestError(error, "Ошибка очистки кэша"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const syncLimitsMutation = useAdminSyncSetLimits({
|
|
||||||
onSuccess: async () => {
|
|
||||||
pushFlash({ type: "success", message: "Параметры синхронизации обновлены" });
|
|
||||||
await statusQuery.refetch();
|
|
||||||
},
|
|
||||||
onError: (error) => handleRequestError(error, "Не удалось обновить лимиты синхронизации"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!statusQuery.data) {
|
|
||||||
return statusQuery.isLoading ? (
|
|
||||||
<Section id="status" title="Статус & лимиты" description="IPFS, пины и лимиты">
|
|
||||||
Загрузка…
|
|
||||||
</Section>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { ipfs, pin_counts, derivatives, convert_backlog } = statusQuery.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section id="status" title="Статус & лимиты" description="Информация о синхронизации, кэше и лимитах">
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.8fr_1fr]">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">IPFS</h3>
|
|
||||||
<dl className="mt-3 grid grid-cols-2 gap-3 text-sm">
|
|
||||||
<InfoRow label="Repo Size">
|
|
||||||
{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.RepoSize ?? 0))}
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="Storage Max">
|
|
||||||
{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.StorageMax ?? 0))}
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="Bitswap Peers">
|
|
||||||
{numberFormatter.format(Number((ipfs.bitswap as Record<string, unknown>)?.Peers ?? 0))}
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="Blocks Received">
|
|
||||||
{numberFormatter.format(Number((ipfs.bitswap as Record<string, unknown>)?.BlocksReceived ?? 0))}
|
|
||||||
</InfoRow>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Pin-статистика</h3>
|
|
||||||
<dl className="mt-3 grid grid-cols-2 gap-3 text-sm">
|
|
||||||
{Object.entries(pin_counts).map(([key, value]) => (
|
|
||||||
<InfoRow key={key} label={key}>
|
|
||||||
{numberFormatter.format(value)}
|
|
||||||
</InfoRow>
|
|
||||||
))}
|
|
||||||
<InfoRow label="Backlog деривативов">{numberFormatter.format(convert_backlog)}</InfoRow>
|
|
||||||
<InfoRow label="Деривативы">{formatBytes(derivatives.total_bytes)}</InfoRow>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<form
|
|
||||||
className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"
|
|
||||||
onSubmit={cacheLimitsForm.handleSubmit((values) => {
|
|
||||||
cacheLimitsMutation.mutate(values);
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Лимиты кэша</h3>
|
|
||||||
<div className="mt-3 space-y-3 text-sm">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs uppercase text-slate-400">Максимум, GB</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:border-sky-500 focus:outline-none"
|
|
||||||
{...cacheLimitsForm.register("max_gb", { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs uppercase text-slate-400">TTL, дней</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:border-sky-500 focus:outline-none"
|
|
||||||
{...cacheLimitsForm.register("ttl_days", { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="mt-4 w-full rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:bg-slate-700"
|
|
||||||
disabled={cacheLimitsMutation.isLoading}
|
|
||||||
>
|
|
||||||
{cacheLimitsMutation.isLoading ? "Сохраняем…" : "Сохранить"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"
|
|
||||||
onSubmit={cacheFitForm.handleSubmit((values) => {
|
|
||||||
cacheCleanupMutation.mutate({ mode: "fit", max_gb: values.max_gb });
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Очистка по размеру</h3>
|
|
||||||
<label className="mt-3 block text-sm">
|
|
||||||
<span className="text-xs uppercase text-slate-400">Целевой размер, GB</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:border-sky-500 focus:outline-none"
|
|
||||||
{...cacheFitForm.register("max_gb", { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="mt-4 flex flex-col gap-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:bg-slate-700"
|
|
||||||
disabled={cacheCleanupMutation.isLoading}
|
|
||||||
>
|
|
||||||
{cacheCleanupMutation.isLoading ? "Очищаем…" : "Очистить до размера"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => cacheCleanupMutation.mutate({ mode: "ttl" })}
|
|
||||||
className="w-full rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:bg-slate-700"
|
|
||||||
disabled={cacheCleanupMutation.isLoading}
|
|
||||||
>
|
|
||||||
Очистить по TTL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"
|
|
||||||
onSubmit={syncLimitsForm.handleSubmit((values) => {
|
|
||||||
syncLimitsMutation.mutate(values);
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Sync лимиты</h3>
|
|
||||||
<div className="mt-3 space-y-3 text-sm">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs uppercase text-slate-400">Одновременные пины</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:border-sky-500 focus:outline-none"
|
|
||||||
{...syncLimitsForm.register("max_concurrent_pins", { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs uppercase text-slate-400">Нижний предел диска, %</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:border-sky-500 focus:outline-none"
|
|
||||||
{...syncLimitsForm.register("disk_low_watermark_pct", { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="mt-4 w-full rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:bg-slate-700"
|
|
||||||
disabled={syncLimitsMutation.isLoading}
|
|
||||||
>
|
|
||||||
{syncLimitsMutation.isLoading ? "Сохраняем…" : "Сохранить"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
import { useAdminStorage } from "~/shared/services/admin";
|
|
||||||
import { Section, Badge, InfoRow, CopyButton } from "../components";
|
|
||||||
import { useAdminContext } from "../context";
|
|
||||||
import { formatBytes, numberFormatter } from "../utils/format";
|
|
||||||
|
|
||||||
export const AdminStoragePage = () => {
|
|
||||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
|
||||||
|
|
||||||
const storageQuery = useAdminStorage({
|
|
||||||
enabled: isAuthorized,
|
|
||||||
refetchInterval: 60_000,
|
|
||||||
onError: (error) => {
|
|
||||||
handleRequestError(error, "Не удалось загрузить данные хранилища");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (storageQuery.isLoading && !storageQuery.data) {
|
|
||||||
return (
|
|
||||||
<Section id="storage" title="Хранилище" description="Пути, размеры и деривативы">
|
|
||||||
Загрузка…
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!storageQuery.data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { directories, disk, derivatives } = storageQuery.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section id="storage" title="Хранилище" description="Пути на диске, использование и кэш деривативов">
|
|
||||||
<div className="hidden overflow-x-auto rounded-2xl border border-slate-800 md:block">
|
|
||||||
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
|
||||||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-3">Путь</th>
|
|
||||||
<th className="px-3 py-3">Файлов</th>
|
|
||||||
<th className="px-3 py-3">Размер</th>
|
|
||||||
<th className="px-3 py-3">Состояние</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-900/60">
|
|
||||||
{directories.map((dir) => (
|
|
||||||
<tr key={dir.path} className="hover:bg-slate-900/50">
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="break-all font-mono text-xs text-slate-300">{dir.path}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={dir.path}
|
|
||||||
aria-label="Скопировать путь"
|
|
||||||
successMessage="Путь скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">{numberFormatter.format(dir.file_count)}</td>
|
|
||||||
<td className="px-3 py-2">{formatBytes(dir.size_bytes)}</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<Badge tone={dir.exists ? "success" : "danger"}>{dir.exists ? "OK" : "Нет"}</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 md:hidden">
|
|
||||||
{directories.map((dir) => (
|
|
||||||
<div key={`dir-card-${dir.path}`} className="rounded-2xl border border-slate-800 bg-slate-950/40 p-4 shadow-inner shadow-black/30">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="break-all font-mono text-[11px] text-slate-200">{dir.path}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={dir.path}
|
|
||||||
aria-label="Скопировать путь"
|
|
||||||
successMessage="Путь скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Badge tone={dir.exists ? "success" : "danger"}>{dir.exists ? "OK" : "Нет"}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-300">
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Файлов</span>
|
|
||||||
<div>{numberFormatter.format(dir.file_count)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Размер</span>
|
|
||||||
<div>{formatBytes(dir.size_bytes)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{disk ? (
|
|
||||||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Снимок диска</h3>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
|
|
||||||
<InfoRow label="Путь" copyValue={disk.path}>
|
|
||||||
{disk.path}
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="Свободно">{formatBytes(disk.free_bytes)}</InfoRow>
|
|
||||||
<InfoRow label="Занято">{formatBytes(disk.used_bytes)}</InfoRow>
|
|
||||||
<InfoRow label="Всего">{formatBytes(disk.total_bytes)}</InfoRow>
|
|
||||||
<InfoRow label="Загруженность">{disk.percent_used != null ? `${disk.percent_used}%` : "—"}</InfoRow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Деривативы</h3>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
|
|
||||||
<InfoRow label="Готово">{numberFormatter.format(derivatives.ready)}</InfoRow>
|
|
||||||
<InfoRow label="В обработке">{numberFormatter.format(derivatives.processing)}</InfoRow>
|
|
||||||
<InfoRow label="Ожидает">{numberFormatter.format(derivatives.pending)}</InfoRow>
|
|
||||||
<InfoRow label="С ошибкой">{numberFormatter.format(derivatives.failed)}</InfoRow>
|
|
||||||
<InfoRow label="Суммарно">{formatBytes(derivatives.total_bytes)}</InfoRow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
import { useAdminSystem } from "~/shared/services/admin";
|
|
||||||
import { Section, InfoRow, Badge, CopyButton } from "../components";
|
|
||||||
import { useAdminContext } from "../context";
|
|
||||||
import { formatDate, formatUnknown, numberFormatter } from "../utils/format";
|
|
||||||
|
|
||||||
export const AdminSystemPage = () => {
|
|
||||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
|
||||||
|
|
||||||
const systemQuery = useAdminSystem({
|
|
||||||
enabled: isAuthorized,
|
|
||||||
refetchInterval: 60_000,
|
|
||||||
onError: (error) => handleRequestError(error, "Не удалось загрузить системную информацию"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!systemQuery.data) {
|
|
||||||
return systemQuery.isLoading ? (
|
|
||||||
<Section id="system" title="Система" description="Переменные окружения и конфиг">
|
|
||||||
Загрузка…
|
|
||||||
</Section>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { env, service_config, services, blockchain_tasks, latest_index_items, telegram_bots } = systemQuery.data;
|
|
||||||
const bots = telegram_bots ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section id="system" title="Система" description="Ключевые переменные и внутренние конфиги">
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Окружение</h3>
|
|
||||||
<dl className="mt-3 space-y-2">
|
|
||||||
{Object.entries(env).map(([key, value]) => (
|
|
||||||
<InfoRow key={key} label={key}>
|
|
||||||
{formatUnknown(value)}
|
|
||||||
</InfoRow>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Telegram-боты</h3>
|
|
||||||
{bots.length === 0 ? (
|
|
||||||
<p className="mt-3 text-xs text-slate-500">Боты не настроены.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="mt-3 space-y-3 text-sm text-slate-200">
|
|
||||||
{bots.map((bot) => (
|
|
||||||
<li
|
|
||||||
key={`${bot.role}-${bot.username}`}
|
|
||||||
className="flex flex-col gap-2 rounded-lg border border-slate-800 bg-slate-900/50 px-3 py-3"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<Badge tone="neutral">{bot.role}</Badge>
|
|
||||||
<span className="font-semibold">@{bot.username}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={bot.username}
|
|
||||||
aria-label="Скопировать username бота"
|
|
||||||
successMessage="Username скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-400">
|
|
||||||
<span>t.me/{bot.username}</span>
|
|
||||||
<a
|
|
||||||
href={bot.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-lg border border-sky-500/40 px-3 py-1 text-[11px] font-semibold text-sky-200 transition hover:border-sky-400 hover:text-white"
|
|
||||||
>
|
|
||||||
Открыть бота
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Задачи блокчейна</h3>
|
|
||||||
<dl className="mt-3 grid grid-cols-2 gap-3">
|
|
||||||
{Object.entries(blockchain_tasks).map(([key, value]) => (
|
|
||||||
<InfoRow key={key} label={key}>
|
|
||||||
{numberFormatter.format(value)}
|
|
||||||
</InfoRow>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
<h4 className="mt-4 text-xs uppercase tracking-wide text-slate-500">Последние индексы</h4>
|
|
||||||
<ul className="mt-2 space-y-2">
|
|
||||||
{latest_index_items.length === 0 ? (
|
|
||||||
<li className="text-xs text-slate-500">Список пуст</li>
|
|
||||||
) : (
|
|
||||||
latest_index_items.map((item) => (
|
|
||||||
<li key={item.encrypted_cid || item.updated_at} className="rounded-lg bg-slate-900/60 px-3 py-2 text-xs">
|
|
||||||
<div className="font-mono text-slate-200">{item.encrypted_cid ?? "—"}</div>
|
|
||||||
<div className="text-slate-500">{formatDate(item.updated_at)}</div>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">ServiceConfig</h3>
|
|
||||||
<div className="mt-3 overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
|
||||||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-3">Ключ</th>
|
|
||||||
<th className="px-3 py-3">Значение</th>
|
|
||||||
<th className="px-3 py-3">RAW</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-900/60">
|
|
||||||
{service_config.map((item) => (
|
|
||||||
<tr key={item.key}>
|
|
||||||
<td className="px-3 py-2 font-mono text-xs text-slate-300">{item.key}</td>
|
|
||||||
<td className="px-3 py-2">{formatUnknown(item.value)}</td>
|
|
||||||
<td className="px-3 py-2 font-mono text-xs text-slate-500">{formatUnknown(item.raw)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Сервисы</h3>
|
|
||||||
<ul className="mt-2 space-y-2">
|
|
||||||
{services.map((service) => (
|
|
||||||
<li key={service.name} className="flex items-center justify-between rounded-lg bg-slate-900/40 px-3 py-2">
|
|
||||||
<span className="text-sm text-slate-200">{service.name}</span>
|
|
||||||
<Badge tone={service.status?.includes("working") ? "success" : "neutral"}>{service.status ?? "—"}</Badge>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,706 +0,0 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
|
|
||||||
import {
|
|
||||||
useAdminUploads,
|
|
||||||
type AdminUploadsContent,
|
|
||||||
type AdminUploadsContentFlags,
|
|
||||||
} from "~/shared/services/admin";
|
|
||||||
import { Section, Badge, InfoRow, CopyButton } from "../components";
|
|
||||||
import { useAdminContext } from "../context";
|
|
||||||
import { formatBytes, formatDate, numberFormatter } from "../utils/format";
|
|
||||||
|
|
||||||
type UploadCategory = "issues" | "processing" | "ready" | "unindexed";
|
|
||||||
type UploadFilter = "all" | UploadCategory;
|
|
||||||
|
|
||||||
type UploadDecoration = {
|
|
||||||
item: AdminUploadsContent;
|
|
||||||
categories: Set<UploadCategory>;
|
|
||||||
searchText: string;
|
|
||||||
hasIssue: boolean;
|
|
||||||
isReady: boolean;
|
|
||||||
isProcessing: boolean;
|
|
||||||
isUnindexed: boolean;
|
|
||||||
flags: AdminUploadsContentFlags | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const numberCompact = new Intl.NumberFormat("ru-RU", { notation: "compact" });
|
|
||||||
|
|
||||||
const computeFlags = (item: AdminUploadsContent): AdminUploadsContentFlags => {
|
|
||||||
const normalize = (value: string | null | undefined) => (value ?? "").toLowerCase();
|
|
||||||
const uploadState = normalize(item.status.upload_state);
|
|
||||||
const conversionState = normalize(item.status.conversion_state);
|
|
||||||
const ipfsState = normalize(item.status.ipfs_state);
|
|
||||||
const pinState = normalize(item.ipfs?.pin_state);
|
|
||||||
const derivativeStates = item.derivatives.map((derivative) => normalize(derivative.status));
|
|
||||||
const statusValues = [uploadState, conversionState, ipfsState, pinState, ...derivativeStates];
|
|
||||||
|
|
||||||
const hasIssue =
|
|
||||||
statusValues.some((value) => value.includes("fail") || value.includes("error") || value.includes("timeout")) ||
|
|
||||||
item.upload_history.some((event) => Boolean(event.error)) ||
|
|
||||||
item.derivatives.some((derivative) => Boolean(derivative.error)) ||
|
|
||||||
Boolean(item.ipfs?.pin_error);
|
|
||||||
|
|
||||||
const isOnchainIndexed = item.status.onchain?.indexed ?? false;
|
|
||||||
const isUnindexed = !isOnchainIndexed;
|
|
||||||
|
|
||||||
const conversionDone =
|
|
||||||
conversionState.includes("converted") ||
|
|
||||||
conversionState.includes("ready") ||
|
|
||||||
derivativeStates.some((state) => state.includes("ready") || state.includes("converted") || state.includes("complete"));
|
|
||||||
|
|
||||||
const ipfsDone =
|
|
||||||
ipfsState.includes("pinned") ||
|
|
||||||
ipfsState.includes("ready") ||
|
|
||||||
pinState.includes("pinned") ||
|
|
||||||
pinState.includes("ready");
|
|
||||||
|
|
||||||
const isReady = !hasIssue && conversionDone && ipfsDone && isOnchainIndexed;
|
|
||||||
|
|
||||||
const hasProcessingKeywords =
|
|
||||||
uploadState.includes("pending") ||
|
|
||||||
uploadState.includes("process") ||
|
|
||||||
uploadState.includes("queue") ||
|
|
||||||
uploadState.includes("upload") ||
|
|
||||||
conversionState.includes("pending") ||
|
|
||||||
conversionState.includes("process") ||
|
|
||||||
conversionState.includes("queue") ||
|
|
||||||
conversionState.includes("convert") ||
|
|
||||||
ipfsState.includes("pin") ||
|
|
||||||
ipfsState.includes("sync") ||
|
|
||||||
pinState.includes("pin") ||
|
|
||||||
pinState.includes("sync") ||
|
|
||||||
derivativeStates.some((state) => state.includes("pending") || state.includes("process") || state.includes("queue"));
|
|
||||||
|
|
||||||
const isProcessingCandidate = !isReady && !hasIssue && hasProcessingKeywords;
|
|
||||||
const isProcessing = isProcessingCandidate || (!isReady && !hasIssue && !isProcessingCandidate);
|
|
||||||
|
|
||||||
return {
|
|
||||||
issues: hasIssue,
|
|
||||||
processing: isProcessing,
|
|
||||||
ready: isReady,
|
|
||||||
unindexed: isUnindexed,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const classifyUpload = (item: AdminUploadsContent): UploadDecoration => {
|
|
||||||
const flags = item.flags ?? computeFlags(item);
|
|
||||||
const categories = new Set<UploadCategory>();
|
|
||||||
if (flags.issues) {
|
|
||||||
categories.add("issues");
|
|
||||||
}
|
|
||||||
if (flags.processing) {
|
|
||||||
categories.add("processing");
|
|
||||||
}
|
|
||||||
if (flags.ready) {
|
|
||||||
categories.add("ready");
|
|
||||||
}
|
|
||||||
if (flags.unindexed) {
|
|
||||||
categories.add("unindexed");
|
|
||||||
}
|
|
||||||
if (categories.size === 0) {
|
|
||||||
if (!(item.status.onchain?.indexed ?? false)) {
|
|
||||||
categories.add("unindexed");
|
|
||||||
}
|
|
||||||
categories.add("processing");
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchParts: Array<string | number | null | undefined> = [
|
|
||||||
item.title,
|
|
||||||
item.description,
|
|
||||||
item.encrypted_cid,
|
|
||||||
item.metadata_cid,
|
|
||||||
item.content_hash,
|
|
||||||
item.status.onchain?.item_address,
|
|
||||||
item.stored?.owner_address,
|
|
||||||
];
|
|
||||||
if (item.stored?.user) {
|
|
||||||
const user = item.stored.user;
|
|
||||||
searchParts.push(user.id, user.telegram_id, user.username, user.first_name, user.last_name);
|
|
||||||
}
|
|
||||||
item.distribution?.nodes?.forEach((node) => {
|
|
||||||
searchParts.push(node.host, node.public_host, node.content.encrypted_cid);
|
|
||||||
});
|
|
||||||
const searchText = searchParts
|
|
||||||
.filter((value) => value !== null && value !== undefined && `${value}`.length > 0)
|
|
||||||
.map((value) => `${value}`.toLowerCase())
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
return {
|
|
||||||
item,
|
|
||||||
categories,
|
|
||||||
searchText,
|
|
||||||
hasIssue: Boolean(flags.issues),
|
|
||||||
isReady: Boolean(flags.ready),
|
|
||||||
isProcessing: Boolean(flags.processing),
|
|
||||||
isUnindexed: Boolean(flags.unindexed),
|
|
||||||
flags: item.flags ?? flags,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getProblemMessages = (item: AdminUploadsContent): string[] => {
|
|
||||||
const messages: string[] = [];
|
|
||||||
for (let index = item.upload_history.length - 1; index >= 0; index -= 1) {
|
|
||||||
const event = item.upload_history[index];
|
|
||||||
if (event.error) {
|
|
||||||
messages.push(`Загрузка: ${event.error}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const derivativeError = item.derivatives.find((derivative) => Boolean(derivative.error));
|
|
||||||
if (derivativeError?.error) {
|
|
||||||
messages.push(`Дериватив ${derivativeError.kind}: ${derivativeError.error}`);
|
|
||||||
}
|
|
||||||
if (item.ipfs?.pin_error) {
|
|
||||||
messages.push(`IPFS: ${item.ipfs.pin_error}`);
|
|
||||||
}
|
|
||||||
const conversionState = item.status.conversion_state;
|
|
||||||
if (!messages.length && conversionState && conversionState.toLowerCase().includes("fail")) {
|
|
||||||
messages.push(`Конверсия: ${conversionState}`);
|
|
||||||
}
|
|
||||||
const uploadState = item.status.upload_state;
|
|
||||||
if (!messages.length && uploadState && uploadState.toLowerCase().includes("fail")) {
|
|
||||||
messages.push(`Загрузка: ${uploadState}`);
|
|
||||||
}
|
|
||||||
return messages;
|
|
||||||
};
|
|
||||||
|
|
||||||
const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
|
|
||||||
const { item, flags } = decoration;
|
|
||||||
const problemMessages = decoration.hasIssue ? getProblemMessages(item) : [];
|
|
||||||
const derivativeDownloads = item.links.download_derivatives ?? [];
|
|
||||||
const distributionNodes = item.distribution?.nodes ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-950/40 p-5 shadow-inner shadow-black/40">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-100">{item.title || "Без названия"}</h3>
|
|
||||||
<p className="text-xs text-slate-400">{item.description || "Описание не задано"}</p>
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-wide text-slate-500">
|
|
||||||
{flags?.ready ? <Badge tone="success">готово</Badge> : null}
|
|
||||||
{flags?.issues ? <Badge tone="danger">проблемы</Badge> : null}
|
|
||||||
{flags?.processing ? <Badge tone="warn">в обработке</Badge> : null}
|
|
||||||
{flags?.unindexed ? <Badge tone="neutral">без индекса</Badge> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-1 text-xs text-slate-400">
|
|
||||||
<span>{formatDate(item.created_at)}</span>
|
|
||||||
<span>{formatDate(item.updated_at)}</span>
|
|
||||||
{item.size.plain ? <span>{formatBytes(item.size.plain)}</span> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 lg:grid-cols-[1.3fr_1fr]">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-xl bg-slate-900/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">On-chain</h4>
|
|
||||||
<div className="mt-2 grid gap-2 text-xs text-slate-300 sm:grid-cols-2">
|
|
||||||
<InfoRow label="Encrypted CID" copyValue={item.encrypted_cid}>
|
|
||||||
<span className="break-all font-mono">{item.encrypted_cid}</span>
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="Metadata CID" copyValue={item.metadata_cid ?? null}>
|
|
||||||
<span className="break-all font-mono">{item.metadata_cid ?? "—"}</span>
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="Content hash" copyValue={item.content_hash ?? null}>
|
|
||||||
<span className="break-all font-mono">{item.content_hash ?? "—"}</span>
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="On-chain индекс">
|
|
||||||
{item.status.onchain?.indexed ? (
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<Badge tone="success">indexed</Badge>
|
|
||||||
{item.status.onchain?.onchain_index ?? "—"}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Badge tone="warn">не опубликовано</Badge>
|
|
||||||
)}
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="On-chain адрес" copyValue={item.status.onchain?.item_address ?? null}>
|
|
||||||
<span className="break-all font-mono text-xs">{item.status.onchain?.item_address ?? "—"}</span>
|
|
||||||
</InfoRow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl bg-slate-900/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Статусы</h4>
|
|
||||||
<div className="mt-2 grid gap-3 text-xs text-slate-300 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-slate-200">Загрузка</p>
|
|
||||||
<p>{item.status.upload_state ?? "—"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-slate-200">Конверсия</p>
|
|
||||||
<p>{item.status.conversion_state ?? "—"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-slate-200">IPFS</p>
|
|
||||||
<p>{item.status.ipfs_state ?? "—"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{distributionNodes.length > 0 ? (
|
|
||||||
<div className="rounded-xl bg-slate-900/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Синхронизация</h4>
|
|
||||||
<ul className="mt-3 space-y-3 text-xs text-slate-300">
|
|
||||||
{distributionNodes.map((node) => {
|
|
||||||
const nodeTitle = node.is_local ? "Эта нода" : node.public_host ?? node.host ?? "Неизвестная нода";
|
|
||||||
const sizeLabel = node.content.size_bytes ? formatBytes(node.content.size_bytes) : null;
|
|
||||||
const updatedLabel = node.content.updated_at ? formatDate(node.content.updated_at) : null;
|
|
||||||
const lastSeenLabel = node.last_seen ? formatDate(node.last_seen) : null;
|
|
||||||
return (
|
|
||||||
<li key={`${item.encrypted_cid}-${node.node_id ?? "local"}`} className="rounded-lg border border-slate-800 bg-slate-950/50 p-3">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-wide text-slate-400">
|
|
||||||
<span className="font-semibold text-slate-200">{nodeTitle}</span>
|
|
||||||
<Badge tone={node.is_local ? "success" : "neutral"}>{node.is_local ? "локально" : "удаленно"}</Badge>
|
|
||||||
{node.role ? <Badge tone="neutral">{node.role}</Badge> : null}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 text-[11px] text-slate-400">
|
|
||||||
{sizeLabel ? <p>Размер: {sizeLabel}</p> : null}
|
|
||||||
{updatedLabel ? <p>Обновлено: {updatedLabel}</p> : null}
|
|
||||||
{lastSeenLabel && !node.is_local ? <p>Нода активна: {lastSeenLabel}</p> : null}
|
|
||||||
{node.version ? <p>Версия: {node.version}</p> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{node.links.web_view ? (
|
|
||||||
<a
|
|
||||||
href={node.links.web_view}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-lg border border-emerald-500/40 px-3 py-1 text-[11px] font-semibold text-emerald-200 transition hover:border-emerald-400 hover:text-emerald-100"
|
|
||||||
>
|
|
||||||
Web
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
{node.links.api_view ? (
|
|
||||||
<>
|
|
||||||
<a
|
|
||||||
href={node.links.api_view}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-lg border border-indigo-500/40 px-3 py-1 text-[11px] font-semibold text-indigo-200 transition hover:border-indigo-400 hover:text-indigo-100"
|
|
||||||
>
|
|
||||||
API
|
|
||||||
</a>
|
|
||||||
<CopyButton
|
|
||||||
className="border border-indigo-500/40 text-indigo-200 hover:border-indigo-400 hover:text-indigo-100"
|
|
||||||
value={node.links.api_view}
|
|
||||||
>
|
|
||||||
копировать API
|
|
||||||
</CopyButton>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{node.links.gateway_view ? (
|
|
||||||
<a
|
|
||||||
href={node.links.gateway_view}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-lg border border-amber-500/40 px-3 py-1 text-[11px] font-semibold text-amber-200 transition hover:border-amber-400 hover:text-amber-100"
|
|
||||||
>
|
|
||||||
Gateway
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="rounded-xl bg-slate-900/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">История загрузки</h4>
|
|
||||||
<ul className="mt-2 space-y-2 text-xs text-slate-300">
|
|
||||||
{item.upload_history.map((event) => (
|
|
||||||
<li key={`${item.encrypted_cid}-${event.filename}-${event.state}`} className="rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<span className="font-semibold text-slate-200">{event.state}</span>
|
|
||||||
<span className="text-[11px] text-slate-500">{formatDate(event.at)}</span>
|
|
||||||
</div>
|
|
||||||
{event.error ? <p className="mt-1 text-rose-300">{event.error}</p> : null}
|
|
||||||
{event.filename ? <p className="mt-1 text-slate-400">{event.filename}</p> : null}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{problemMessages.length > 0 ? (
|
|
||||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-4 text-xs text-rose-100">
|
|
||||||
<h4 className="font-semibold uppercase tracking-wide">Проблемы</h4>
|
|
||||||
<ul className="mt-2 space-y-1">
|
|
||||||
{problemMessages.map((message, index) => (
|
|
||||||
<li key={`${item.encrypted_cid}-issue-${index}`}>{message}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Деривативы</h4>
|
|
||||||
<ul className="mt-2 space-y-2 text-xs text-slate-300">
|
|
||||||
{item.derivatives.map((derivative) => (
|
|
||||||
<li key={`${item.encrypted_cid}-${derivative.kind}`} className="rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-semibold text-slate-200">{derivative.kind}</span>
|
|
||||||
<Badge tone={derivative.status === "ready" ? "success" : derivative.status === "failed" ? "danger" : "warn"}>
|
|
||||||
{derivative.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 space-y-1 text-[11px] text-slate-400">
|
|
||||||
<div>Размер: {formatBytes(derivative.size_bytes)}</div>
|
|
||||||
<div>Попытки: {derivative.attempts}</div>
|
|
||||||
<div>Создан: {formatDate(derivative.created_at)}</div>
|
|
||||||
<div>Обновлён: {formatDate(derivative.updated_at)}</div>
|
|
||||||
{derivative.error ? <div className="text-rose-300">{derivative.error}</div> : null}
|
|
||||||
{derivative.download_url ? (
|
|
||||||
<a
|
|
||||||
href={derivative.download_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center text-sky-300 hover:text-sky-200"
|
|
||||||
>
|
|
||||||
Скачать
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">IPFS</h4>
|
|
||||||
{item.ipfs ? (
|
|
||||||
<div className="mt-2 text-xs text-slate-300">
|
|
||||||
<div>Статус: {item.ipfs.pin_state ?? "—"}</div>
|
|
||||||
<div>Ошибка: {item.ipfs.pin_error ?? "—"}</div>
|
|
||||||
<div>Fetched: {formatBytes(item.ipfs.bytes_fetched)}</div>
|
|
||||||
<div>Total: {formatBytes(item.ipfs.bytes_total)}</div>
|
|
||||||
<div className="text-slate-500">{formatDate(item.ipfs.updated_at)}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="mt-2 text-xs text-slate-500">Нет информации.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Хранение</h4>
|
|
||||||
{item.stored ? (
|
|
||||||
<div className="mt-2 space-y-1 text-xs text-slate-300">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-semibold text-slate-200">ID:</span>
|
|
||||||
<span className="break-all font-mono">{item.stored.stored_id ?? "—"}</span>
|
|
||||||
{item.stored.stored_id ? (
|
|
||||||
<CopyButton
|
|
||||||
value={item.stored.stored_id}
|
|
||||||
aria-label="Скопировать ID хранения"
|
|
||||||
successMessage="ID хранения скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-semibold text-slate-200">Владелец:</span>
|
|
||||||
<span className="break-all font-mono text-[11px] text-slate-300">{item.stored.owner_address ?? "—"}</span>
|
|
||||||
{item.stored.owner_address ? (
|
|
||||||
<CopyButton
|
|
||||||
value={item.stored.owner_address}
|
|
||||||
aria-label="Скопировать адрес владельца хранения"
|
|
||||||
successMessage="Адрес владельца скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div>Тип: {item.stored.type ?? "—"}</div>
|
|
||||||
{item.stored.user ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-slate-400">
|
|
||||||
<span>Пользователь #{item.stored.user.id}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={item.stored.user.id}
|
|
||||||
aria-label="Скопировать ID пользователя"
|
|
||||||
successMessage="ID пользователя скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
<span>· TG {item.stored.user.telegram_id}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={item.stored.user.telegram_id}
|
|
||||||
aria-label="Скопировать Telegram ID"
|
|
||||||
successMessage="Telegram ID скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{item.stored.download_url ? (
|
|
||||||
<a
|
|
||||||
href={item.stored.download_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 text-xs font-semibold text-sky-300 hover:text-sky-200"
|
|
||||||
>
|
|
||||||
Скачать оригинал
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="mt-2 text-xs text-slate-500">В хранилище не найден.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{derivativeDownloads.length > 0 ? (
|
|
||||||
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Прямые ссылки</h4>
|
|
||||||
<ul className="mt-2 space-y-1 text-xs text-slate-300">
|
|
||||||
{derivativeDownloads.map((entry) => (
|
|
||||||
<li key={`${item.encrypted_cid}-link-${entry.kind}`}>
|
|
||||||
<a href={entry.url} target="_blank" rel="noreferrer" className="text-sky-300 hover:text-sky-200">
|
|
||||||
{entry.kind} · {entry.size_bytes ? formatBytes(entry.size_bytes) : "размер неизвестен"}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdminUploadsPage = () => {
|
|
||||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
|
||||||
const location = useLocation();
|
|
||||||
const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("all");
|
|
||||||
const initialUploadsSearch = useMemo(() => {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
return params.get("search") ?? "";
|
|
||||||
}, [location.search]);
|
|
||||||
const [uploadsSearch, setUploadsSearch] = useState(initialUploadsSearch);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setUploadsSearch(initialUploadsSearch);
|
|
||||||
}, [initialUploadsSearch]);
|
|
||||||
|
|
||||||
const normalizedUploadsSearch = uploadsSearch.trim();
|
|
||||||
const hasUploadsSearch = normalizedUploadsSearch.length > 0;
|
|
||||||
const isUploadsFilterActive = uploadsFilter !== "all";
|
|
||||||
const uploadsScanLimit = isUploadsFilterActive || hasUploadsSearch ? 200 : 100;
|
|
||||||
|
|
||||||
const uploadsQuery = useAdminUploads(
|
|
||||||
{
|
|
||||||
filter: isUploadsFilterActive ? uploadsFilter : undefined,
|
|
||||||
search: hasUploadsSearch ? normalizedUploadsSearch : undefined,
|
|
||||||
limit: 40,
|
|
||||||
scan: uploadsScanLimit,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: isAuthorized,
|
|
||||||
keepPreviousData: true,
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
onError: (error) => handleRequestError(error, "Не удалось загрузить список загрузок"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const uploadContents = uploadsQuery.data?.contents ?? [];
|
|
||||||
const decoratedContents = useMemo(() => uploadContents.map((item) => classifyUpload(item)), [uploadContents]);
|
|
||||||
|
|
||||||
if (uploadsQuery.isLoading && !uploadsQuery.data) {
|
|
||||||
return (
|
|
||||||
<Section id="uploads" title="Загрузки" description="Мониторинг загрузок и конвертации">
|
|
||||||
Загрузка…
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!uploadsQuery.data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { states, total, recent, matching_total: matchingTotalRaw, category_totals: categoryTotalsRaw } = uploadsQuery.data;
|
|
||||||
|
|
||||||
const categoryTotals = categoryTotalsRaw ?? {};
|
|
||||||
const categoryCounts: Record<UploadCategory, number> = {
|
|
||||||
issues: categoryTotals.issues ?? 0,
|
|
||||||
processing: categoryTotals.processing ?? 0,
|
|
||||||
ready: categoryTotals.ready ?? 0,
|
|
||||||
unindexed: categoryTotals.unindexed ?? 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchNeedle = normalizedUploadsSearch.toLowerCase();
|
|
||||||
const filteredEntries = decoratedContents.filter((entry) => {
|
|
||||||
if (uploadsFilter !== "all" && !entry.categories.has(uploadsFilter)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (searchNeedle && !entry.searchText.includes(searchNeedle)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredContents = filteredEntries.map((entry) => entry.item);
|
|
||||||
const totalMatches = typeof matchingTotalRaw === "number" ? matchingTotalRaw : filteredEntries.length;
|
|
||||||
const filtersActive = isUploadsFilterActive || hasUploadsSearch;
|
|
||||||
const noMatches = totalMatches === 0;
|
|
||||||
const issueEntries = filteredEntries.filter((entry) => entry.categories.has("issues")).slice(0, 3);
|
|
||||||
const filterOptions: Array<{ id: UploadFilter; label: string; count: number }> = [
|
|
||||||
{ id: "all", label: "Все", count: totalMatches },
|
|
||||||
{ id: "issues", label: "Ошибки", count: categoryCounts.issues },
|
|
||||||
{ id: "processing", label: "В обработке", count: categoryCounts.processing },
|
|
||||||
{ id: "ready", label: "Готово", count: categoryCounts.ready },
|
|
||||||
{ id: "unindexed", label: "Без on-chain", count: categoryCounts.unindexed },
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleResetFilters = () => {
|
|
||||||
setUploadsFilter("all");
|
|
||||||
setUploadsSearch("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
id="uploads"
|
|
||||||
title="Загрузки"
|
|
||||||
description="Полная картина обработки контента: цепочка загрузки, конверсия, IPFS и публикация"
|
|
||||||
actions={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
|
|
||||||
onClick={() => uploadsQuery.refetch()}
|
|
||||||
disabled={uploadsQuery.isFetching}
|
|
||||||
>
|
|
||||||
{uploadsQuery.isFetching ? "Обновляем…" : "Обновить"}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{Object.entries(states ?? {}).map(([key, value]) => (
|
|
||||||
<div key={key} className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">{key}</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold text-slate-100">{numberFormatter.format(value)}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">Всего</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold text-slate-100">{numberFormatter.format(total)}</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">Отфильтровано: {numberFormatter.format(filteredContents.length)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">Активные задачи</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold text-slate-100">{numberCompact.format(categoryCounts.processing)}</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">{numberFormatter.format(categoryCounts.issues)} с ошибками</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{filterOptions.map((option) => {
|
|
||||||
const isActive = option.id === uploadsFilter;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`upload-filter-${option.id}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setUploadsFilter(option.id)}
|
|
||||||
className={clsx(
|
|
||||||
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold transition",
|
|
||||||
isActive
|
|
||||||
? "border-sky-500 bg-sky-500/20 text-sky-100 shadow-inner shadow-sky-500/20"
|
|
||||||
: "border-slate-700 text-slate-300 hover:border-slate-500 hover:text-slate-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
<span className="rounded-full bg-slate-900/60 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-slate-300">
|
|
||||||
{numberFormatter.format(option.count)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{filtersActive ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleResetFilters}
|
|
||||||
className="inline-flex items-center rounded-full border border-slate-700 px-3 py-1 text-xs font-semibold text-slate-300 transition hover:border-slate-500 hover:text-slate-100"
|
|
||||||
>
|
|
||||||
Сбросить
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="relative w-full md:w-72">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={uploadsSearch}
|
|
||||||
onChange={(event) => setUploadsSearch(event.target.value)}
|
|
||||||
placeholder="Название, CID, пользователь…"
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{issueEntries.length > 0 ? (
|
|
||||||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 p-4 text-xs text-amber-100">
|
|
||||||
<h3 className="text-sm font-semibold text-amber-50">Последние проблемы</h3>
|
|
||||||
<ul className="mt-2 space-y-1">
|
|
||||||
{issueEntries.map((entry) => (
|
|
||||||
<li key={`issue-${entry.item.encrypted_cid}`} className="flex items-center justify-between gap-2">
|
|
||||||
<span className="truncate text-sm text-amber-50">{entry.item.title || entry.item.encrypted_cid}</span>
|
|
||||||
<span className="text-[11px] text-amber-200">{formatDate(entry.item.updated_at)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{noMatches ? (
|
|
||||||
<div className="rounded-xl border border-slate-800 bg-slate-950/40 p-6 text-sm text-slate-400">
|
|
||||||
Под подходящие условия не найдено загрузок.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-5">
|
|
||||||
{filteredEntries.map((entry) => (
|
|
||||||
<UploadCard key={entry.item.encrypted_cid} decoration={entry} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="rounded-xl bg-slate-950/40 p-4 ring-1 ring-slate-800">
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Последние события</h3>
|
|
||||||
<div className="mt-2 overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-slate-800 text-left text-xs">
|
|
||||||
<thead className="bg-slate-950/70 text-[11px] uppercase tracking-wide text-slate-400">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2">ID</th>
|
|
||||||
<th className="px-3 py-2">Файл</th>
|
|
||||||
<th className="px-3 py-2">Размер</th>
|
|
||||||
<th className="px-3 py-2">Состояние</th>
|
|
||||||
<th className="px-3 py-2">CID</th>
|
|
||||||
<th className="px-3 py-2">Ошибка</th>
|
|
||||||
<th className="px-3 py-2">Обновлено</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-900/60">
|
|
||||||
{recent.map((task) => (
|
|
||||||
<tr key={task.id} className="hover:bg-slate-900/50">
|
|
||||||
<td className="px-3 py-2 font-mono text-[10px] text-slate-400">{task.id}</td>
|
|
||||||
<td className="px-3 py-2">{task.filename ?? "—"}</td>
|
|
||||||
<td className="px-3 py-2">{formatBytes(task.size_bytes)}</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<Badge tone={task.state === "done" ? "success" : task.state === "failed" ? "danger" : "warn"}>
|
|
||||||
{task.state}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 font-mono text-[10px] text-slate-500">{task.encrypted_cid ?? "—"}</td>
|
|
||||||
<td className="px-3 py-2 text-rose-200">{task.error ?? "—"}</td>
|
|
||||||
<td className="px-3 py-2 text-slate-400">{formatDate(task.updated_at)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,406 +0,0 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
|
|
||||||
import { useAdminUsers, useAdminSetUserAdmin } from "~/shared/services/admin";
|
|
||||||
import { Section, PaginationControls, CopyButton, Badge } from "../components";
|
|
||||||
import { useAdminContext } from "../context";
|
|
||||||
import { formatDate, formatStars, numberFormatter } from "../utils/format";
|
|
||||||
|
|
||||||
export const AdminUsersPage = () => {
|
|
||||||
const { isAuthorized, handleRequestError, pushFlash } = useAdminContext();
|
|
||||||
const location = useLocation();
|
|
||||||
const initialSearchValue = useMemo(() => {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
return params.get("search") ?? "";
|
|
||||||
}, [location.search]);
|
|
||||||
const [search, setSearch] = useState(initialSearchValue);
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const [pendingUserId, setPendingUserId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const limit = 50;
|
|
||||||
const normalizedSearch = search.trim();
|
|
||||||
|
|
||||||
const usersQuery = useAdminUsers(
|
|
||||||
{
|
|
||||||
limit,
|
|
||||||
offset: page * limit,
|
|
||||||
search: normalizedSearch || undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: isAuthorized,
|
|
||||||
keepPreviousData: true,
|
|
||||||
refetchInterval: 60_000,
|
|
||||||
onError: (error) => handleRequestError(error, "Не удалось загрузить пользователей"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const setUserAdmin = useAdminSetUserAdmin({
|
|
||||||
onMutate: ({ user_id }) => {
|
|
||||||
setPendingUserId(user_id);
|
|
||||||
},
|
|
||||||
onSuccess: ({ user }) => {
|
|
||||||
pushFlash({
|
|
||||||
type: "success",
|
|
||||||
message: user.is_admin ? "Пользователь назначен администратором" : "Права администратора сняты",
|
|
||||||
});
|
|
||||||
void usersQuery.refetch();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
handleRequestError(error, "Не удалось обновить права администратора");
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
setPendingUserId(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleToggleAdmin = (userId: number, nextValue: boolean) => {
|
|
||||||
setUserAdmin.mutate({ user_id: userId, is_admin: nextValue });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSearch(initialSearchValue);
|
|
||||||
}, [initialSearchValue]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(0);
|
|
||||||
}, [normalizedSearch]);
|
|
||||||
|
|
||||||
if (usersQuery.isLoading && !usersQuery.data) {
|
|
||||||
return (
|
|
||||||
<Section id="users" title="Пользователи" description="Мониторинг пользователей, кошельков и активности">
|
|
||||||
Загрузка…
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!usersQuery.data) {
|
|
||||||
return (
|
|
||||||
<Section id="users" title="Пользователи" description="Мониторинг пользователей, кошельков и активности">
|
|
||||||
<p className="text-sm text-slate-400">Выберите вкладку «Пользователи», чтобы загрузить данные.</p>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { items, summary, total } = usersQuery.data;
|
|
||||||
const summaryCards = [
|
|
||||||
{
|
|
||||||
label: "Всего пользователей",
|
|
||||||
value: numberFormatter.format(total),
|
|
||||||
helper: `На странице: ${numberFormatter.format(summary.users_returned)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Администраторы",
|
|
||||||
value: numberFormatter.format(summary.admins_total ?? 0),
|
|
||||||
helper: summary.admins_total ? "Имеют доступ к /admin" : "Назначьте администраторов",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Кошельки (активные/всего)",
|
|
||||||
value: `${numberFormatter.format(summary.wallets_active)} / ${numberFormatter.format(summary.wallets_total)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Оплачено Stars",
|
|
||||||
value: formatStars(summary.stars_amount_paid),
|
|
||||||
helper: `${numberFormatter.format(summary.stars_paid)} платежей`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Неоплачено",
|
|
||||||
value: formatStars(summary.stars_amount_unpaid),
|
|
||||||
helper: `${numberFormatter.format(summary.stars_unpaid)} счетов`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
id="users"
|
|
||||||
title="Пользователи"
|
|
||||||
description="Просмотр учетных записей, кошельков и активности"
|
|
||||||
actions={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
|
|
||||||
onClick={() => usersQuery.refetch()}
|
|
||||||
disabled={usersQuery.isFetching}
|
|
||||||
>
|
|
||||||
{usersQuery.isFetching ? "Обновляем…" : "Обновить"}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{summaryCards.map((card) => (
|
|
||||||
<div
|
|
||||||
key={card.label}
|
|
||||||
className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40"
|
|
||||||
>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold text-slate-100">{card.value}</p>
|
|
||||||
{card.helper ? <p className="mt-1 text-xs text-slate-500">{card.helper}</p> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
|
|
||||||
<input
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
|
||||||
placeholder="ID, Telegram, username, адрес"
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:w-72"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
|
|
||||||
onClick={() => setSearch("")}
|
|
||||||
>
|
|
||||||
Сбросить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 hidden overflow-x-auto rounded-2xl border border-slate-800 md:block">
|
|
||||||
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
|
||||||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-3">Пользователь</th>
|
|
||||||
<th className="px-3 py-3">Кошельки</th>
|
|
||||||
<th className="px-3 py-3">Stars</th>
|
|
||||||
<th className="px-3 py-3">Лицензии</th>
|
|
||||||
<th className="px-3 py-3">Активность</th>
|
|
||||||
<th className="px-3 py-3">Дата</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-900/60">
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-3 py-6 text-center text-sm text-slate-400">
|
|
||||||
Нет пользователей, удовлетворяющих условиям.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
items.map((user) => (
|
|
||||||
<tr key={`admin-user-${user.id}`} className="hover:bg-slate-900/40">
|
|
||||||
<td className="px-3 py-3 align-top">
|
|
||||||
<div className="font-semibold text-slate-100">{user.username ? `@${user.username}` : "Без username"}</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
|
|
||||||
<span>ID {numberFormatter.format(user.id)}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={user.id}
|
|
||||||
aria-label="Скопировать ID пользователя"
|
|
||||||
successMessage="ID пользователя скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
<span>· TG {numberFormatter.format(user.telegram_id)}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={user.telegram_id}
|
|
||||||
aria-label="Скопировать Telegram ID"
|
|
||||||
successMessage="Telegram ID скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{user.first_name || user.last_name ? (
|
|
||||||
<div className="text-xs text-slate-500">
|
|
||||||
{(user.first_name ?? "")} {(user.last_name ?? "")}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
<Badge tone={user.is_admin ? "success" : "neutral"}>
|
|
||||||
{user.is_admin ? "Администратор" : "Пользователь"}
|
|
||||||
</Badge>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggleAdmin(user.id, !user.is_admin)}
|
|
||||||
className="rounded-lg border border-slate-700 px-3 py-1 text-[11px] font-semibold text-slate-200 transition hover:border-slate-500 hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
disabled={setUserAdmin.isLoading}
|
|
||||||
>
|
|
||||||
{pendingUserId === user.id && setUserAdmin.isLoading
|
|
||||||
? "Сохраняем…"
|
|
||||||
: user.is_admin
|
|
||||||
? "Снять права"
|
|
||||||
: "Назначить админом"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 align-top">
|
|
||||||
<div className="text-xs text-slate-400">
|
|
||||||
Активных {numberFormatter.format(user.wallets.active_count)} / всего{" "}
|
|
||||||
{numberFormatter.format(user.wallets.total_count)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 break-all text-xs text-slate-300">
|
|
||||||
<span className="break-all">{user.wallets.primary_address ?? "—"}</span>
|
|
||||||
{user.wallets.primary_address ? (
|
|
||||||
<CopyButton
|
|
||||||
value={user.wallets.primary_address}
|
|
||||||
aria-label="Скопировать адрес кошелька"
|
|
||||||
successMessage="Адрес кошелька скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 align-top">
|
|
||||||
<div className="font-semibold text-slate-100">{formatStars(user.stars.amount_total)}</div>
|
|
||||||
<div className="text-xs text-slate-400">Оплачено: {formatStars(user.stars.amount_paid)}</div>
|
|
||||||
<div className="text-xs text-slate-400">Неоплачено: {formatStars(user.stars.amount_unpaid)}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 align-top">
|
|
||||||
<div className="text-xs text-slate-300">Всего {numberFormatter.format(user.licenses.total)}</div>
|
|
||||||
<div className="text-xs text-slate-300">Активных {numberFormatter.format(user.licenses.active)}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 align-top">
|
|
||||||
{user.ip_activity.last ? (
|
|
||||||
<div className="flex flex-col gap-1 text-xs text-slate-300">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="break-all">{user.ip_activity.last.ip ?? "—"}</span>
|
|
||||||
{user.ip_activity.last.ip ? (
|
|
||||||
<CopyButton
|
|
||||||
value={user.ip_activity.last.ip}
|
|
||||||
aria-label="Скопировать IP-адрес"
|
|
||||||
successMessage="IP-адрес скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<span>{formatDate(user.ip_activity.last.seen_at)}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-slate-500">Нет данных</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 align-top text-xs text-slate-400">
|
|
||||||
<div>Создан: {formatDate(user.created_at)}</div>
|
|
||||||
<div>Последний вход: {formatDate(user.last_use)}</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-4 md:hidden">
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/40 px-4 py-6 text-center text-sm text-slate-400">
|
|
||||||
Нет пользователей, удовлетворяющих условиям.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
items.map((user) => (
|
|
||||||
<div key={`user-card-${user.id}`} className="rounded-2xl border border-slate-800 bg-slate-950/50 p-4 shadow-inner shadow-black/30">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-slate-100">
|
|
||||||
<span>{user.username ? `@${user.username}` : "Без username"}</span>
|
|
||||||
<CopyButton
|
|
||||||
value={user.id}
|
|
||||||
aria-label="Скопировать ID пользователя"
|
|
||||||
successMessage="ID пользователя скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
<CopyButton
|
|
||||||
value={user.telegram_id}
|
|
||||||
aria-label="Скопировать Telegram ID"
|
|
||||||
successMessage="Telegram ID скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{user.first_name || user.last_name ? (
|
|
||||||
<div className="mt-1 text-xs text-slate-400">
|
|
||||||
{(user.first_name ?? "")} {(user.last_name ?? "")}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="mt-3 grid gap-3 text-xs text-slate-300">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">ID · Telegram</span>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span>ID {numberFormatter.format(user.id)}</span>
|
|
||||||
<span>· TG {numberFormatter.format(user.telegram_id)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Кошельки</span>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-slate-300">
|
|
||||||
<span>
|
|
||||||
Активных {numberFormatter.format(user.wallets.active_count)} / всего{" "}
|
|
||||||
{numberFormatter.format(user.wallets.total_count)}
|
|
||||||
</span>
|
|
||||||
{user.wallets.primary_address ? (
|
|
||||||
<CopyButton
|
|
||||||
value={user.wallets.primary_address}
|
|
||||||
aria-label="Скопировать адрес кошелька"
|
|
||||||
successMessage="Адрес кошелька скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<span className="break-all text-[11px] text-slate-400">{user.wallets.primary_address ?? "—"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Stars</span>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-semibold text-slate-100">{formatStars(user.stars.amount_total)}</span>
|
|
||||||
<span>Оплачено: {formatStars(user.stars.amount_paid)}</span>
|
|
||||||
<span>Неоплачено: {formatStars(user.stars.amount_unpaid)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Лицензии</span>
|
|
||||||
<span>
|
|
||||||
Всего {numberFormatter.format(user.licenses.total)} · Активных{" "}
|
|
||||||
{numberFormatter.format(user.licenses.active)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Роль</span>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge tone={user.is_admin ? "success" : "neutral"}>
|
|
||||||
{user.is_admin ? "Администратор" : "Пользователь"}
|
|
||||||
</Badge>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggleAdmin(user.id, !user.is_admin)}
|
|
||||||
className="rounded-lg border border-slate-700 px-3 py-1 text-[11px] font-semibold text-slate-200 transition hover:border-slate-500 hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
disabled={setUserAdmin.isLoading}
|
|
||||||
>
|
|
||||||
{pendingUserId === user.id && setUserAdmin.isLoading
|
|
||||||
? "Сохраняем…"
|
|
||||||
: user.is_admin
|
|
||||||
? "Снять права"
|
|
||||||
: "Назначить админом"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-500">Последняя активность</span>
|
|
||||||
{user.ip_activity.last ? (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="break-all">{user.ip_activity.last.ip ?? "—"}</span>
|
|
||||||
{user.ip_activity.last.ip ? (
|
|
||||||
<CopyButton
|
|
||||||
value={user.ip_activity.last.ip}
|
|
||||||
aria-label="Скопировать IP-адрес"
|
|
||||||
successMessage="IP-адрес скопирован"
|
|
||||||
className="h-6 w-6"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<span className="text-slate-400">{formatDate(user.ip_activity.last.seen_at)}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-slate-500">Нет данных</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-[11px] text-slate-500">
|
|
||||||
<div>Создан: {formatDate(user.created_at)}</div>
|
|
||||||
<div>Последний вход: {formatDate(user.last_use)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<PaginationControls total={total} limit={limit} page={page} onPageChange={setPage} />
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
export { AdminOverviewPage } from "./Overview";
|
|
||||||
export { AdminStoragePage } from "./Storage";
|
|
||||||
export { AdminUploadsPage } from "./Uploads";
|
|
||||||
export { AdminEventsPage } from "./Events";
|
|
||||||
export { AdminUsersPage } from "./Users";
|
|
||||||
export { AdminLicensesPage } from "./Licenses";
|
|
||||||
export { AdminStarsPage } from "./Stars";
|
|
||||||
export { AdminSystemPage } from "./System";
|
|
||||||
export { AdminBlockchainPage } from "./Blockchain";
|
|
||||||
export { AdminNodesPage } from "./Nodes";
|
|
||||||
export { AdminStatusPage } from "./Status";
|
|
||||||
export { AdminNetworkPage } from "./Network";
|
|
||||||
export { AdminNetworkSettingsPage } from "./NetworkSettings";
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
export type FlashMessage = {
|
|
||||||
type: "success" | "error" | "info";
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AuthState = "checking" | "authorized" | "unauthorized";
|
|
||||||
|
|
||||||
export type AdminContextValue = {
|
|
||||||
authState: AuthState;
|
|
||||||
isAuthorized: boolean;
|
|
||||||
pushFlash: (message: FlashMessage) => void;
|
|
||||||
clearFlash: () => void;
|
|
||||||
handleRequestError: (error: unknown, fallbackMessage: string) => void;
|
|
||||||
invalidateAll: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
const numberFormatter = new Intl.NumberFormat("ru-RU");
|
|
||||||
|
|
||||||
const dateTimeFormatter = new Intl.DateTimeFormat("ru-RU", {
|
|
||||||
dateStyle: "short",
|
|
||||||
timeStyle: "medium",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const formatBytes = (input?: number | null) => {
|
|
||||||
if (!input) {
|
|
||||||
return "0 B";
|
|
||||||
}
|
|
||||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"] as const;
|
|
||||||
let value = input;
|
|
||||||
let unitIndex = 0;
|
|
||||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
||||||
value /= 1024;
|
|
||||||
unitIndex += 1;
|
|
||||||
}
|
|
||||||
const precision = value >= 10 || value < 0.1 ? 0 : 1;
|
|
||||||
return `${value.toFixed(precision)} ${units[unitIndex]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatStars = (input?: number | null) => {
|
|
||||||
const value = Number(input ?? 0);
|
|
||||||
return `${numberFormatter.format(value)} ⭑`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatDate = (iso?: string | null) => {
|
|
||||||
if (!iso) {
|
|
||||||
return "—";
|
|
||||||
}
|
|
||||||
const date = new Date(iso);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
return dateTimeFormatter.format(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatUnknown = (value: unknown): string => {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return "—";
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value || "—";
|
|
||||||
}
|
|
||||||
if (typeof value === "number" || typeof value === "bigint") {
|
|
||||||
return numberFormatter.format(Number(value));
|
|
||||||
}
|
|
||||||
if (typeof value === "boolean") {
|
|
||||||
return value ? "Да" : "Нет";
|
|
||||||
}
|
|
||||||
if (value instanceof Date) {
|
|
||||||
return formatDate(value.toISOString());
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch (error) {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export { numberFormatter, dateTimeFormatter };
|
|
||||||
|
|
@ -8,7 +8,7 @@ import ReactPlayer from "react-player/lazy";
|
||||||
import { Button } from "~/shared/ui/button";
|
import { Button } from "~/shared/ui/button";
|
||||||
import { useRootStore } from "~/shared/stores/root";
|
import { useRootStore } from "~/shared/stores/root";
|
||||||
import { FormLabel } from "~/shared/ui/form-label";
|
import { FormLabel } from "~/shared/ui/form-label";
|
||||||
import { useLegacyUploadFile, useTusUpload } from "~/shared/services/file";
|
import { useUploadFile } from "~/shared/services/file";
|
||||||
import { Progress } from "~/shared/ui/progress";
|
import { Progress } from "~/shared/ui/progress";
|
||||||
import { useCreateNewContent } from "~/shared/services/content";
|
import { useCreateNewContent } from "~/shared/services/content";
|
||||||
import { BackButton } from "~/shared/ui/back-button";
|
import { BackButton } from "~/shared/ui/back-button";
|
||||||
|
|
@ -31,8 +31,8 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
|
||||||
|
|
||||||
const [isCoverExpanded, setCoverExpanded] = useState(false);
|
const [isCoverExpanded, setCoverExpanded] = useState(false);
|
||||||
|
|
||||||
const uploadCover = useLegacyUploadFile();
|
const uploadCover = useUploadFile();
|
||||||
const uploadFile = useTusUpload();
|
const uploadFile = useUploadFile();
|
||||||
|
|
||||||
const createContent = useCreateNewContent();
|
const createContent = useCreateNewContent();
|
||||||
|
|
||||||
|
|
@ -42,85 +42,28 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
|
||||||
setIsErrorUploadModal(false);
|
setIsErrorUploadModal(false);
|
||||||
uploadFile.resetUploadError();
|
uploadFile.resetUploadError();
|
||||||
uploadCover.resetUploadError();
|
uploadCover.resetUploadError();
|
||||||
console.info("[App2Client][Presubmit] Пользователь закрыл модалку ошибок загрузки");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (uploadFile.uploadError || uploadCover.uploadError) {
|
if (uploadFile.uploadError || uploadCover.uploadError) {
|
||||||
console.error("[App2Client][Presubmit] Обнаружены ошибки загрузки", {
|
|
||||||
fileError: uploadFile.uploadError,
|
|
||||||
coverError: uploadCover.uploadError,
|
|
||||||
});
|
|
||||||
setIsErrorUploadModal(true);
|
setIsErrorUploadModal(true);
|
||||||
}
|
}
|
||||||
}, [uploadFile.uploadError, uploadCover.uploadError]);
|
}, [uploadFile.uploadError, uploadCover.uploadError]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
console.info("[App2Client][Presubmit] Начинаем отправку контента", {
|
|
||||||
name: rootStore.name,
|
|
||||||
author: rootStore.author,
|
|
||||||
fileName: rootStore.file?.name,
|
|
||||||
fileSize: rootStore.file?.size,
|
|
||||||
fileType: rootStore.file?.type,
|
|
||||||
allowCover: rootStore.allowCover,
|
|
||||||
hasCover: Boolean(rootStore.cover),
|
|
||||||
allowDownload: rootStore.allowDwnld,
|
|
||||||
hashtags: rootStore.hashtags,
|
|
||||||
royaltyCount: rootStore.royalty.length,
|
|
||||||
allowResale: rootStore.allowResale,
|
|
||||||
});
|
|
||||||
|
|
||||||
let coverUploadResult = { content_id_v1: "" };
|
let coverUploadResult = { content_id_v1: "" };
|
||||||
|
|
||||||
console.info("[App2Client][Presubmit] Загружаем основной файл через tus");
|
const fileUploadResult = await uploadFile.mutateAsync(
|
||||||
const fileUploadResult = await uploadFile.mutateAsync({
|
rootStore.file as File,
|
||||||
file: rootStore.file as File,
|
);
|
||||||
metadata: {
|
|
||||||
title: rootStore.name,
|
|
||||||
description: "",
|
|
||||||
content_type: rootStore.file?.type || "application/octet-stream",
|
|
||||||
preview_start_ms: 0,
|
|
||||||
preview_duration_ms: 30000,
|
|
||||||
downloadable: rootStore.allowDwnld ? "1" : "0",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fileUploadResult.encryptedCid) {
|
|
||||||
throw new Error("Tus upload did not return encrypted cid");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info("[App2Client][Presubmit] Основной файл загружен", {
|
|
||||||
encryptedCid: fileUploadResult.encryptedCid,
|
|
||||||
uploadId: fileUploadResult.uploadId,
|
|
||||||
state: fileUploadResult.state,
|
|
||||||
sizeBytes: fileUploadResult.sizeBytes,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (rootStore.allowCover && rootStore.cover) {
|
if (rootStore.allowCover && rootStore.cover) {
|
||||||
console.info("[App2Client][Presubmit] Начинаем загрузку обложки", {
|
|
||||||
fileName: rootStore.cover.name,
|
|
||||||
fileSize: rootStore.cover.size,
|
|
||||||
fileType: rootStore.cover.type,
|
|
||||||
});
|
|
||||||
coverUploadResult = await uploadCover.mutateAsync(
|
coverUploadResult = await uploadCover.mutateAsync(
|
||||||
rootStore.cover as File,
|
rootStore.cover as File,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.info("[App2Client][Presubmit] Обложка загружена", {
|
|
||||||
contentIdV1: coverUploadResult.content_id_v1,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.info("[App2Client][Presubmit] Обложка не загружается", {
|
|
||||||
allowCover: rootStore.allowCover,
|
|
||||||
hasCoverFile: Boolean(rootStore.cover),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info("[App2Client][Presubmit] Отправляем метаданные контента", {
|
|
||||||
downloadable: rootStore.allowDwnld,
|
|
||||||
coverIncluded: Boolean(coverUploadResult.content_id_v1),
|
|
||||||
});
|
|
||||||
const createContentResponse = await createContent.mutateAsync({
|
const createContentResponse = await createContent.mutateAsync({
|
||||||
title: rootStore.name,
|
title: rootStore.name,
|
||||||
|
|
||||||
|
|
@ -133,7 +76,7 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
|
||||||
// следует отметить что вы должны еще откомментровать AuthorsStep в RootPage
|
// следует отметить что вы должны еще откомментровать AuthorsStep в RootPage
|
||||||
// authors: rootStore.authors,
|
// authors: rootStore.authors,
|
||||||
downloadable: rootStore.allowDwnld,
|
downloadable: rootStore.allowDwnld,
|
||||||
content: fileUploadResult.encryptedCid,
|
content: fileUploadResult.content_id_v1,
|
||||||
image: coverUploadResult.content_id_v1,
|
image: coverUploadResult.content_id_v1,
|
||||||
price: String(rootStore.price * 10 ** 9),
|
price: String(rootStore.price * 10 ** 9),
|
||||||
hashtags: rootStore.hashtags,
|
hashtags: rootStore.hashtags,
|
||||||
|
|
@ -157,10 +100,6 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
|
||||||
|
|
||||||
if (createContentResponse.data) {
|
if (createContentResponse.data) {
|
||||||
if (createContentResponse.data.address != "free") {
|
if (createContentResponse.data.address != "free") {
|
||||||
console.info("[App2Client][Presubmit] Отправляем транзакцию через TonConnect", {
|
|
||||||
address: createContentResponse.data.address,
|
|
||||||
amount: createContentResponse.data.amount,
|
|
||||||
});
|
|
||||||
const transactionResponse = await tonConnectUI.sendTransaction({
|
const transactionResponse = await tonConnectUI.sendTransaction({
|
||||||
validUntil: Math.floor(Date.now() / 1000) + 120,
|
validUntil: Math.floor(Date.now() / 1000) + 120,
|
||||||
messages: [
|
messages: [
|
||||||
|
|
@ -172,29 +111,19 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (transactionResponse.boc) {
|
if (transactionResponse.boc) {
|
||||||
console.info("[App2Client][Presubmit] Транзакция успешно отправлена", {
|
|
||||||
bocLength: transactionResponse.boc.length,
|
|
||||||
});
|
|
||||||
WebApp.close();
|
WebApp.close();
|
||||||
} else {
|
} else {
|
||||||
console.error("Transaction failed:", transactionResponse);
|
console.error("Transaction failed:", transactionResponse);
|
||||||
console.error("[App2Client][Presubmit] Транзакция не отправлена", {
|
|
||||||
response: transactionResponse,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info("[App2Client][Presubmit] Завершаем процесс и закрываем WebApp");
|
|
||||||
WebApp.close();
|
WebApp.close();
|
||||||
// @ts-expect-error Type issues
|
// @ts-expect-error Type issues
|
||||||
} catch (error: never) {
|
} catch (error: never) {
|
||||||
|
|
||||||
|
|
||||||
console.error("An error occurred during the submission process:", error);
|
console.error("An error occurred during the submission process:", error);
|
||||||
console.error("[App2Client][Presubmit] Ошибка во время отправки", {
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error?.status === 400) {
|
if (error?.status === 400) {
|
||||||
alert(
|
alert(
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
import ReactPlayer from 'react-player/lazy';
|
import ReactPlayer from 'react-player/lazy';
|
||||||
import { useTonConnectUI } from '@tonconnect/ui-react';
|
import { useTonConnectUI } from '@tonconnect/ui-react';
|
||||||
import { useWebApp } from '@vkruglikov/react-telegram-web-app';
|
import { useWebApp } from '@vkruglikov/react-telegram-web-app';
|
||||||
|
|
||||||
import { Button } from '~/shared/ui/button';
|
import { Button } from '~/shared/ui/button';
|
||||||
import { usePurchaseContent, useViewContent } from '~/shared/services/content';
|
import { usePurchaseContent, useViewContent } from '~/shared/services/content';
|
||||||
import { fromNanoTON } from '~/shared/utils';
|
import { fromNanoTON } from '~/shared/utils';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { AudioPlayer } from '~/shared/ui/audio-player';
|
import { AudioPlayer } from '~/shared/ui/audio-player';
|
||||||
import { useAuth } from '~/shared/services/auth';
|
import { useAuth } from '~/shared/services/auth';
|
||||||
import { CongratsModal } from './components/congrats-modal';
|
import { CongratsModal } from './components/congrats-modal';
|
||||||
import { ErrorModal } from './components/error-modal';
|
import { ErrorModal } from './components/error-modal';
|
||||||
import { resolveStartPayload } from '~/shared/utils/start-payload';
|
import { useRootStore } from '~/shared/stores/root';
|
||||||
|
import { useTgFullscreen } from '~/shared/hooks/use-tgfullscreen';
|
||||||
type InvoiceStatus = 'paid' | 'failed' | 'cancelled' | 'pending';
|
type InvoiceStatus = 'paid' | 'failed' | 'cancelled' | 'pending';
|
||||||
|
|
||||||
// Add type for invoice event
|
// Add type for invoice event
|
||||||
|
|
@ -22,118 +21,63 @@ interface InvoiceEvent {
|
||||||
|
|
||||||
export const ViewContentPage = () => {
|
export const ViewContentPage = () => {
|
||||||
const WebApp = useWebApp();
|
const WebApp = useWebApp();
|
||||||
const { contentId } = resolveStartPayload();
|
const { addNotification } = useRootStore();
|
||||||
|
|
||||||
const { data: content, refetch: refetchContent } = useViewContent(
|
const { data: content, refetch: refetchContent } = useViewContent(
|
||||||
contentId
|
WebApp.initDataUnsafe?.start_param
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: purchaseContent } = usePurchaseContent();
|
const { mutateAsync: purchaseContent } = usePurchaseContent();
|
||||||
|
|
||||||
const [tonConnectUI] = useTonConnectUI();
|
const [tonConnectUI] = useTonConnectUI();
|
||||||
|
|
||||||
|
const { isFullscreen, toggleFullscreen } = useTgFullscreen();
|
||||||
|
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [isCongratsModal, setIsCongratsModal] = useState(false);
|
const [isCongratsModal, setIsCongratsModal] = useState(false);
|
||||||
const [isErrorModal, setIsErrorModal] = useState(false);
|
const [isErrorModal, setIsErrorModal] = useState(false);
|
||||||
|
|
||||||
const statusState = content?.data?.status?.state ?? "uploaded";
|
|
||||||
const conversionState = content?.data?.conversion?.state;
|
|
||||||
const uploadState = content?.data?.upload?.state;
|
|
||||||
const statusMessage = useMemo(() => {
|
|
||||||
switch (statusState) {
|
|
||||||
case "processing":
|
|
||||||
return "Контент обрабатывается";
|
|
||||||
case "failed":
|
|
||||||
return "Ошибка обработки";
|
|
||||||
case "ready":
|
|
||||||
return null;
|
|
||||||
default:
|
|
||||||
return "Файл загружен";
|
|
||||||
}
|
|
||||||
}, [statusState]);
|
|
||||||
|
|
||||||
const haveLicense = useMemo(() => (
|
|
||||||
content?.data?.have_licenses?.includes('listen') ||
|
|
||||||
content?.data?.have_licenses?.includes('resale')
|
|
||||||
), [content]);
|
|
||||||
|
|
||||||
const contentMime = content?.data?.content_mime ?? null;
|
|
||||||
const contentKind = content?.data?.display_options?.content_kind ?? null;
|
|
||||||
const mediaUrl = content?.data?.display_options?.content_url ?? null;
|
|
||||||
const isAudio = contentKind === 'audio' || Boolean(content?.data?.content_type?.startsWith('audio'));
|
|
||||||
const isVideo = contentKind === 'video' || Boolean(content?.data?.content_type?.startsWith('video'));
|
|
||||||
const isBinary = contentKind === 'binary' || (!isAudio && !isVideo);
|
|
||||||
const hasInlinePlayer = Boolean(mediaUrl) && (isAudio || isVideo);
|
|
||||||
const binaryDownloadReady = Boolean(mediaUrl) && isBinary;
|
|
||||||
const isReadyState = statusState === "ready";
|
|
||||||
const previewAvailable = Boolean(content?.data?.display_options?.has_preview);
|
|
||||||
const coverImage = content?.data?.display_options?.metadata?.image ?? null;
|
|
||||||
const metadataName = content?.data?.display_options?.metadata?.name;
|
|
||||||
const contentTitle = metadataName || content?.data?.encrypted?.title || 'Контент';
|
|
||||||
const processingDetails = useMemo(() => {
|
|
||||||
if (!statusMessage) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
conversion: conversionState,
|
|
||||||
upload: uploadState,
|
|
||||||
};
|
|
||||||
}, [conversionState, statusMessage, uploadState]);
|
|
||||||
|
|
||||||
const canDownload = Boolean(mediaUrl) && haveLicense && ((content?.data?.downloadable ?? false) || isBinary);
|
|
||||||
const binaryAwaitingAccess = isBinary && !binaryDownloadReady;
|
|
||||||
const isFailed = statusState === "failed";
|
|
||||||
|
|
||||||
const handleBuyContentTON = useCallback(async () => {
|
const handleBuyContentTON = useCallback(async () => {
|
||||||
if (!contentId) {
|
|
||||||
console.error('No content identifier available for purchase');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
// Если не подключен, начинаем процесс подключения через auth
|
// Helper function to wait for wallet connection
|
||||||
if (!tonConnectUI.connected) {
|
const waitForConnection = async (timeoutMs = 30000, intervalMs = 500) => {
|
||||||
console.log('DEBUG: Wallet not connected, using auth flow first');
|
const startTime = Date.now();
|
||||||
|
|
||||||
// Вызываем auth.mutateAsync() до открытия модального окна
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
// Это настроит параметры подключения с TonProof
|
if (tonConnectUI.connected) {
|
||||||
try {
|
addNotification('Кошелек подключен', 'success');
|
||||||
await auth.mutateAsync();
|
return true;
|
||||||
|
|
||||||
// Проверяем, установилось ли подключение после auth
|
|
||||||
if (!tonConnectUI.connected) {
|
|
||||||
console.log('DEBUG: Auth did not establish connection, returning');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
|
}
|
||||||
|
addNotification('Время ожидания подключения кошелька истекло', 'warning');
|
||||||
|
return false; // Timed out
|
||||||
|
};
|
||||||
|
|
||||||
console.log('DEBUG: Connection and authentication successful');
|
// If not connected, start connection process
|
||||||
} catch (error) {
|
if (!tonConnectUI.connected) {
|
||||||
console.log('Ошибка подключения кошелька', 'danger');
|
console.log('DEBUG: Wallet not connected, opening modal');
|
||||||
console.error('DEBUG: Auth failed during connection:', error);
|
|
||||||
|
// Open connection modal
|
||||||
|
await tonConnectUI.openModal();
|
||||||
|
|
||||||
|
// Wait for connection
|
||||||
|
const connected = await waitForConnection();
|
||||||
|
if (!connected) {
|
||||||
|
console.log('DEBUG: Connection timed out or was cancelled');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('DEBUG: Connection successful, authenticating');
|
||||||
|
await auth.mutateAsync();
|
||||||
} else {
|
} else {
|
||||||
// Если уже подключены, просто аутентифицируемся
|
// Already connected, just authenticate
|
||||||
console.log('DEBUG: Already connected, authenticating');
|
|
||||||
await auth.mutateAsync();
|
await auth.mutateAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверка наличия TonProof
|
// Proceed with purchase
|
||||||
if (
|
|
||||||
tonConnectUI.wallet?.connectItems?.tonProof &&
|
|
||||||
!('error' in tonConnectUI.wallet.connectItems.tonProof)
|
|
||||||
) {
|
|
||||||
console.log(
|
|
||||||
'DEBUG: TonProof available:',
|
|
||||||
tonConnectUI.wallet.connectItems.tonProof
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.warn('DEBUG: TonProof not available after connection');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Теперь продолжаем с покупкой
|
|
||||||
console.log('DEBUG: Proceeding with purchase');
|
console.log('DEBUG: Proceeding with purchase');
|
||||||
const contentResponse = await purchaseContent({
|
const contentResponse = await purchaseContent({
|
||||||
content_address: contentId,
|
content_address: WebApp.initDataUnsafe?.start_param,
|
||||||
license_type: 'resale',
|
license_type: 'resale',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -160,7 +104,7 @@ export const ViewContentPage = () => {
|
||||||
setIsErrorModal(true);
|
setIsErrorModal(true);
|
||||||
console.error('Error handling Ton Connect subscription:', error);
|
console.error('Error handling Ton Connect subscription:', error);
|
||||||
}
|
}
|
||||||
}, [auth, contentId, purchaseContent, refetchContent, tonConnectUI]);
|
}, [auth, purchaseContent, refetchContent, tonConnectUI, WebApp.initDataUnsafe?.start_param]);
|
||||||
|
|
||||||
const handleBuyContentStars = useCallback(async () => {
|
const handleBuyContentStars = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -202,34 +146,21 @@ export const ViewContentPage = () => {
|
||||||
}
|
}
|
||||||
}, [content, refetchContent]);
|
}, [content, refetchContent]);
|
||||||
|
|
||||||
const hadLicenseRef = useRef<boolean>(haveLicense);
|
const haveLicense = useMemo(() => {
|
||||||
|
document.title = content?.data?.display_options?.metadata?.name;
|
||||||
|
return (
|
||||||
|
content?.data?.have_licenses?.includes('listen') ||
|
||||||
|
content?.data?.have_licenses?.includes('resale')
|
||||||
|
);
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (haveLicense && !hadLicenseRef.current) {
|
|
||||||
hadLicenseRef.current = true;
|
|
||||||
void refetchContent();
|
|
||||||
} else if (!haveLicense) {
|
|
||||||
hadLicenseRef.current = false;
|
|
||||||
}
|
|
||||||
}, [haveLicense, refetchContent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (contentTitle) {
|
|
||||||
document.title = contentTitle;
|
|
||||||
}
|
|
||||||
}, [contentTitle]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!contentId) {
|
|
||||||
return () => undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
void refetchContent();
|
void refetchContent();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [contentId, refetchContent]);
|
}, []);
|
||||||
|
|
||||||
const handleConfirmCongrats = () => {
|
const handleConfirmCongrats = () => {
|
||||||
setIsCongratsModal(!isCongratsModal);
|
setIsCongratsModal(!isCongratsModal);
|
||||||
|
|
@ -242,15 +173,11 @@ export const ViewContentPage = () => {
|
||||||
const handleDwnldContent = async () => {
|
const handleDwnldContent = async () => {
|
||||||
try {
|
try {
|
||||||
const fileUrl = content?.data?.display_options?.content_url;
|
const fileUrl = content?.data?.display_options?.content_url;
|
||||||
if (!fileUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fileName = content?.data?.display_options?.metadata?.name || 'content';
|
const fileName = content?.data?.display_options?.metadata?.name || 'content';
|
||||||
const rawExt = content?.data?.content_ext ?? 'bin';
|
const fileFormat = content?.data?.content_ext || '.raw';
|
||||||
const normalizedExt = rawExt.replace(/^\.+/, '');
|
|
||||||
await WebApp.downloadFile({
|
await WebApp.downloadFile({
|
||||||
url: fileUrl,
|
url: fileUrl,
|
||||||
file_name: `${fileName}.${normalizedExt}`,
|
file_name: fileName + '.' + fileFormat,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading content:', error);
|
console.error('Error downloading content:', error);
|
||||||
|
|
@ -258,151 +185,123 @@ export const ViewContentPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={'min-h-screen flex w-full flex-col gap-[40px] px-4 '}>
|
<main className={'min-h-screen flex w-full flex-col gap-[50px] px-4 '}>
|
||||||
{isCongratsModal && <CongratsModal onConfirm={handleConfirmCongrats} />}
|
{isCongratsModal && <CongratsModal onConfirm={handleConfirmCongrats} />}
|
||||||
{isErrorModal && <ErrorModal onConfirm={handleErrorModal} />}
|
{isErrorModal && <ErrorModal onConfirm={handleErrorModal} />}
|
||||||
{coverImage && (
|
<div
|
||||||
<div className="mt-[30px] flex w-full justify-center">
|
className={`${isFullscreen ? 'left-[50%] translate-x-[-50%] fixed top-11 lg:top-2' : 'left-4 absolute top-2'} z-50`}
|
||||||
<div className="relative aspect-square w-full max-w-[320px] rounded-3xl border border-slate-900/60 bg-transparent">
|
>
|
||||||
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
|
<button
|
||||||
<img
|
onClick={toggleFullscreen}
|
||||||
alt={'content cover'}
|
className="bg-primary bg-opacity-80 w-8 h-8 flex items-center justify-center rounded-full text-white"
|
||||||
className={'max-h-full max-w-full object-contain'}
|
>
|
||||||
src={coverImage}
|
<svg
|
||||||
loading="lazy"
|
width="20"
|
||||||
/>
|
height="20"
|
||||||
</div>
|
viewBox="0 0 14 14"
|
||||||
</div>
|
fill="none"
|
||||||
</div>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
)}
|
>
|
||||||
|
<path
|
||||||
{isReadyState ? (
|
d="M2 6V2H6M8 2H12V6M12 8V12H8M6 12H2V8"
|
||||||
<>
|
stroke="currentColor"
|
||||||
{hasInlinePlayer && (
|
strokeWidth="1.5"
|
||||||
isAudio ? (
|
strokeLinecap="round"
|
||||||
<AudioPlayer src={mediaUrl ?? ''} />
|
strokeLinejoin="round"
|
||||||
) : (
|
|
||||||
<ReactPlayer
|
|
||||||
playsinline={true}
|
|
||||||
controls={true}
|
|
||||||
width="100%"
|
|
||||||
config={{
|
|
||||||
file: {
|
|
||||||
attributes: {
|
|
||||||
playsInline: true,
|
|
||||||
autoPlay: true,
|
|
||||||
poster: content?.data?.display_options?.metadata?.image || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
url={mediaUrl ?? ''}
|
|
||||||
light={coverImage || undefined}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{binaryDownloadReady && (
|
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-6 py-5 shadow-inner shadow-black/20">
|
|
||||||
<h2 className="text-base font-semibold text-slate-100">Файл готов к скачиванию</h2>
|
|
||||||
<p className="mt-2 text-sm text-slate-300">
|
|
||||||
{contentMime ? `Тип файла: ${contentMime}` : 'Скачайте оригинальные данные на устройство.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className={'flex flex-col'}>
|
|
||||||
<h1 className={'text-[20px] font-bold'}>{metadataName}</h1>
|
|
||||||
<p className={'mt-2 text-[12px]'}>
|
|
||||||
{content?.data?.display_options?.metadata?.description}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{binaryAwaitingAccess && (
|
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-6 py-5 text-sm text-slate-300">
|
|
||||||
<h2 className="text-base font-semibold text-slate-100">Предпросмотр недоступен</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Этот файл нельзя открыть в браузере. Получите доступ, чтобы скачать оригинал на устройство.
|
|
||||||
</p>
|
|
||||||
{!previewAvailable && (
|
|
||||||
<p className="mt-2 text-xs text-slate-500">
|
|
||||||
Предпросмотр не формируется для бинарных данных.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-auto pb-2">
|
|
||||||
{canDownload && (
|
|
||||||
<Button
|
|
||||||
onClick={() => handleDwnldContent()}
|
|
||||||
className={'h-[48px] mb-4'}
|
|
||||||
label={`Скачать ${isBinary ? 'файл' : 'контент'}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!haveLicense && (
|
|
||||||
<div className="flex gap-4 pb-2 flex-nowrap overflow-hidden">
|
|
||||||
<Button
|
|
||||||
onClick={handleBuyContentTON}
|
|
||||||
className={'mb-4 h-[48px] px-2 flex-1 min-w-0 w-auto truncate'}
|
|
||||||
label={`Купить за ${fromNanoTON(content?.data?.encrypted?.license?.resale?.price)} ТОН`}
|
|
||||||
includeArrows={content?.data?.invoice ? false : true}
|
|
||||||
/>
|
|
||||||
{content?.data?.invoice && (
|
|
||||||
<Button
|
|
||||||
onClick={handleBuyContentStars}
|
|
||||||
className={'mb-4 h-[48px] px-2 flex-1 min-w-0 w-auto truncate'}
|
|
||||||
label={`Купить за ${content?.data?.invoice?.amount} ⭐️`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
WebApp.openTelegramLink(`https://t.me/MY_UploaderRobot`);
|
|
||||||
}}
|
|
||||||
className={'h-[48px] bg-darkred'}
|
|
||||||
label={`Загрузить свой контент`}
|
|
||||||
/>
|
/>
|
||||||
{tonConnectUI.connected && (
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-[50px]">
|
||||||
|
{content?.data?.content_type.startsWith('audio') &&
|
||||||
|
content?.data?.display_options?.metadata?.image && (
|
||||||
|
<div className={'mt-[30px] h-[314px] w-full'}>
|
||||||
|
<img
|
||||||
|
alt={'content_image'}
|
||||||
|
className={'h-full w-full object-cover object-center'}
|
||||||
|
src={content?.data?.display_options?.metadata?.image}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{content?.data?.content_type.startsWith('audio') ? (
|
||||||
|
<AudioPlayer src={content?.data?.display_options?.content_url} />
|
||||||
|
) : (
|
||||||
|
<div className="w-full video-container">
|
||||||
|
<ReactPlayer
|
||||||
|
playsinline={true}
|
||||||
|
controls={true}
|
||||||
|
width="100%"
|
||||||
|
height="auto"
|
||||||
|
className="react-player"
|
||||||
|
allowFullScreen={true}
|
||||||
|
url={content?.data?.display_options?.content_url}
|
||||||
|
config={{
|
||||||
|
file: {
|
||||||
|
attributes: {
|
||||||
|
playsInline: true,
|
||||||
|
autoPlay: true,
|
||||||
|
poster:
|
||||||
|
content?.data?.display_options?.metadata?.image ||
|
||||||
|
undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<section className={'flex flex-col'}>
|
||||||
|
<h1 className={'text-[20px] font-bold'}>
|
||||||
|
{content?.data?.display_options?.metadata?.name}
|
||||||
|
</h1>
|
||||||
|
{/*<h2>Russian</h2>*/}
|
||||||
|
{/*<h2>2022</h2>*/}
|
||||||
|
<p className={'mt-2 text-[12px]'}>
|
||||||
|
{content?.data?.display_options?.metadata?.description}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<div className="mt-auto pb-2">
|
||||||
|
{content?.data?.downloadable && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDwnldContent()}
|
||||||
|
className={'h-[48px] mb-4'}
|
||||||
|
label={`Скачать контент`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!haveLicense && (
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleBuyContentTON}
|
||||||
|
className={'mb-4 h-[48px] px-2'}
|
||||||
|
label={`Купить за ${fromNanoTON(content?.data?.encrypted?.license?.resale?.price)} ТОН`}
|
||||||
|
includeArrows={content?.data?.invoice ? false : true}
|
||||||
|
/>
|
||||||
|
{content?.data?.invoice && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={handleBuyContentStars}
|
||||||
tonConnectUI.disconnect();
|
className={'mb-4 h-[48px] px-2'}
|
||||||
}}
|
label={`Купить за ${content?.data?.invoice?.amount} ⭐️`}
|
||||||
className={'h-[48px] bg-darkred mt-4'}
|
|
||||||
label={`Отключить кошелек`}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
) : (
|
<Button
|
||||||
<div className="flex flex-1 flex-col items-center justify-center py-16">
|
onClick={() => {
|
||||||
<div className="max-w-md rounded-2xl border border-slate-800 bg-slate-950/70 px-6 py-8 text-center shadow-lg shadow-black/30">
|
WebApp.openTelegramLink(`https://t.me/MY_UploaderRobot`);
|
||||||
<h1 className="text-lg font-semibold text-slate-100">
|
}}
|
||||||
{isFailed ? 'Не удалось подготовить контент' : 'Контент скоро будет здесь'}
|
className={'h-[48px] bg-darkred'}
|
||||||
</h1>
|
label={`Загрузить свой контент`}
|
||||||
<p className="mt-3 text-sm text-slate-300">
|
/>
|
||||||
{isFailed
|
{tonConnectUI.connected && (
|
||||||
? 'При обработке файла произошла ошибка. Попробуйте обновить страницу или повторно загрузить контент.'
|
<Button
|
||||||
: 'Мы уже обрабатываем загруженный файл и обновим страницу автоматически, как только появится доступ к полному контенту.'}
|
onClick={() => {
|
||||||
</p>
|
tonConnectUI.disconnect();
|
||||||
{statusMessage && (
|
}}
|
||||||
<p className="mt-4 text-[12px] text-slate-500">
|
className={'h-[48px] bg-darkred mt-4'}
|
||||||
Текущее состояние: {statusMessage}
|
label={`Отключить кошелек`}
|
||||||
</p>
|
/>
|
||||||
)}
|
)}
|
||||||
{processingDetails?.conversion && (
|
</div>
|
||||||
<p className="mt-2 text-[12px] text-slate-500">
|
|
||||||
Статус конвертера: {processingDetails.conversion}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{processingDetails?.upload && (
|
|
||||||
<p className="mt-2 text-[12px] text-slate-500">
|
|
||||||
Загрузка: {processingDetails.upload}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useWebApp } from '@vkruglikov/react-telegram-web-app';
|
||||||
|
import { viewport } from '@telegram-apps/sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing Telegram WebApp fullscreen state
|
||||||
|
* @returns {Object} Object containing isFullscreen state and toggleFullscreen function
|
||||||
|
*/
|
||||||
|
export const useTgFullscreen = () => {
|
||||||
|
const WebApp = useWebApp();
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
// Initialize viewport once on mount
|
||||||
|
useEffect(() => {
|
||||||
|
// Mount viewport if available
|
||||||
|
if (viewport.mount.isAvailable() && !viewport.isMounted()) {
|
||||||
|
viewport.mount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check current fullscreen state
|
||||||
|
if (viewport.isFullscreen && typeof viewport.isFullscreen === 'function') {
|
||||||
|
setIsFullscreen(viewport.isFullscreen());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the WebApp event system for tracking changes
|
||||||
|
const handleViewportChanged = () => {
|
||||||
|
if (WebApp.isExpanded) {
|
||||||
|
setIsFullscreen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
WebApp.onEvent('viewportChanged', handleViewportChanged);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not add viewportChanged event listener:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
WebApp.offEvent('viewportChanged', handleViewportChanged);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not remove viewportChanged event listener:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [WebApp]);
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (!isFullscreen) {
|
||||||
|
// Try to enter fullscreen
|
||||||
|
if (viewport.requestFullscreen && viewport.requestFullscreen.isAvailable()) {
|
||||||
|
await viewport.requestFullscreen();
|
||||||
|
setIsFullscreen(true);
|
||||||
|
} else if (viewport.expand && viewport.expand.isAvailable()) {
|
||||||
|
viewport.expand();
|
||||||
|
setIsFullscreen(true);
|
||||||
|
} else {
|
||||||
|
WebApp.expand();
|
||||||
|
setIsFullscreen(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try to exit fullscreen
|
||||||
|
if (viewport.exitFullscreen && viewport.exitFullscreen.isAvailable()) {
|
||||||
|
await viewport.exitFullscreen();
|
||||||
|
setIsFullscreen(false);
|
||||||
|
} else {
|
||||||
|
setIsFullscreen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling fullscreen:', error);
|
||||||
|
|
||||||
|
if (!isFullscreen) {
|
||||||
|
try {
|
||||||
|
WebApp.expand();
|
||||||
|
setIsFullscreen(true);
|
||||||
|
} catch {
|
||||||
|
console.error('All fullscreen methods failed');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsFullscreen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [WebApp, isFullscreen]);
|
||||||
|
|
||||||
|
return { isFullscreen, toggleFullscreen };
|
||||||
|
};
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
const TOKEN_STORAGE_KEY = '__admin_panel_token__';
|
|
||||||
const HEADER_STORAGE_KEY = '__admin_panel_header__';
|
|
||||||
const COOKIE_STORAGE_KEY = '__admin_panel_cookie__';
|
|
||||||
const DEFAULT_HEADER_NAME = 'X-Admin-Token';
|
|
||||||
const DEFAULT_COOKIE_NAME = 'admin_session';
|
|
||||||
const DEFAULT_MAX_AGE = 172800; // 48h
|
|
||||||
|
|
||||||
const isBrowser = typeof window !== 'undefined';
|
|
||||||
|
|
||||||
export type AdminAuthSnapshot = {
|
|
||||||
token: string | null;
|
|
||||||
headerName: string;
|
|
||||||
cookieName: string;
|
|
||||||
maxAge: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAdminAuthSnapshot = (): AdminAuthSnapshot => {
|
|
||||||
if (!isBrowser) {
|
|
||||||
return {
|
|
||||||
token: null,
|
|
||||||
headerName: DEFAULT_HEADER_NAME,
|
|
||||||
cookieName: DEFAULT_COOKIE_NAME,
|
|
||||||
maxAge: DEFAULT_MAX_AGE,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = window.localStorage.getItem(TOKEN_STORAGE_KEY);
|
|
||||||
const headerName = window.localStorage.getItem(HEADER_STORAGE_KEY) || DEFAULT_HEADER_NAME;
|
|
||||||
const cookieName = window.localStorage.getItem(COOKIE_STORAGE_KEY) || DEFAULT_COOKIE_NAME;
|
|
||||||
return {
|
|
||||||
token,
|
|
||||||
headerName,
|
|
||||||
cookieName,
|
|
||||||
maxAge: DEFAULT_MAX_AGE,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const persistAdminAuth = ({
|
|
||||||
token,
|
|
||||||
headerName,
|
|
||||||
cookieName,
|
|
||||||
maxAge,
|
|
||||||
}: {
|
|
||||||
token: string;
|
|
||||||
headerName?: string | null;
|
|
||||||
cookieName?: string | null;
|
|
||||||
maxAge?: number | null;
|
|
||||||
}) => {
|
|
||||||
if (!isBrowser) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
|
||||||
if (headerName) {
|
|
||||||
window.localStorage.setItem(HEADER_STORAGE_KEY, headerName);
|
|
||||||
}
|
|
||||||
if (cookieName) {
|
|
||||||
window.localStorage.setItem(COOKIE_STORAGE_KEY, cookieName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Best effort: mirror cookie locally to support same-origin deployments.
|
|
||||||
const targetCookieName = cookieName || DEFAULT_COOKIE_NAME;
|
|
||||||
const cookieParts = [
|
|
||||||
`${targetCookieName}=${encodeURIComponent(token)}`,
|
|
||||||
'Path=/',
|
|
||||||
`Max-Age=${maxAge ?? DEFAULT_MAX_AGE}`,
|
|
||||||
'SameSite=Lax',
|
|
||||||
];
|
|
||||||
if (window.location.protocol === 'https:') {
|
|
||||||
cookieParts.push('Secure');
|
|
||||||
}
|
|
||||||
document.cookie = cookieParts.join('; ');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearAdminAuth = () => {
|
|
||||||
if (!isBrowser) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = getAdminAuthSnapshot();
|
|
||||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
|
|
||||||
window.localStorage.removeItem(HEADER_STORAGE_KEY);
|
|
||||||
window.localStorage.removeItem(COOKIE_STORAGE_KEY);
|
|
||||||
|
|
||||||
if (snapshot.cookieName) {
|
|
||||||
const cookieParts = [
|
|
||||||
`${snapshot.cookieName}=`,
|
|
||||||
'Path=/',
|
|
||||||
'Max-Age=0',
|
|
||||||
'SameSite=Lax',
|
|
||||||
];
|
|
||||||
if (window.location.protocol === 'https:') {
|
|
||||||
cookieParts.push('Secure');
|
|
||||||
}
|
|
||||||
document.cookie = cookieParts.join('; ');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,67 +1,17 @@
|
||||||
import axios, { InternalAxiosRequestConfig } from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
import { getAdminAuthSnapshot } from '~/shared/libs/admin-auth';
|
export const APP_API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
|
||||||
const API_BASE_PATH = '/api/v1';
|
export const request = axios.create({
|
||||||
|
baseURL: APP_API_BASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
const isBrowser = typeof window !== 'undefined';
|
request.interceptors.request.use((config) => {
|
||||||
|
const auth_v1_token = localStorage.getItem('auth_v1_token');
|
||||||
|
|
||||||
const readStorage = (key: string) => {
|
if (auth_v1_token) {
|
||||||
if (!isBrowser) {
|
config.headers.Authorization = auth_v1_token;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return window.localStorage.getItem(key);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to access localStorage', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
type RequestConfig = InternalAxiosRequestConfig;
|
|
||||||
|
|
||||||
const resolveDefaultBaseUrl = () => {
|
|
||||||
const envBase = (import.meta.env.VITE_API_BASE_URL ?? '').trim();
|
|
||||||
if (envBase) {
|
|
||||||
return envBase.replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isBrowser) {
|
|
||||||
return `${window.location.origin.replace(/\/$/, '')}${API_BASE_PATH}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return API_BASE_PATH;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_API_BASE_URL = resolveDefaultBaseUrl();
|
|
||||||
|
|
||||||
export const request = axios.create({ withCredentials: true });
|
|
||||||
|
|
||||||
request.interceptors.request.use((config: RequestConfig) => {
|
|
||||||
config.headers = config.headers ?? {};
|
|
||||||
const headers = config.headers as Record<string, any>;
|
|
||||||
const authToken = readStorage('auth_v1_token');
|
|
||||||
|
|
||||||
if (authToken && !headers.Authorization) {
|
|
||||||
headers.Authorization = authToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlPath = config.url ?? '';
|
|
||||||
if (urlPath.startsWith('/admin')) {
|
|
||||||
const { token, headerName } = getAdminAuthSnapshot();
|
|
||||||
if (token) {
|
|
||||||
const headerKey = headerName || 'X-Admin-Token';
|
|
||||||
if (!headers[headerKey]) {
|
|
||||||
headers[headerKey] = token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.baseURL) {
|
|
||||||
config.baseURL = DEFAULT_API_BASE_URL;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
request.interceptors.response.use((response) => response);
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,11 +1,11 @@
|
||||||
import { useRef } from 'react';
|
import { useRef, useEffect } from 'react';
|
||||||
import { useTonConnectUI } from '@tonconnect/ui-react';
|
import { useTonConnectUI } from '@tonconnect/ui-react';
|
||||||
import { useMutation } from 'react-query';
|
import { useMutation } from 'react-query';
|
||||||
import { request } from '~/shared/libs';
|
import { request } from '~/shared/libs';
|
||||||
import { useWebApp } from '@vkruglikov/react-telegram-web-app';
|
import { useWebApp } from '@vkruglikov/react-telegram-web-app';
|
||||||
import { appendReferral } from '~/shared/utils/start-payload';
|
|
||||||
|
|
||||||
const sessionStorageKey = 'auth_v1_token';
|
const sessionStorageKey = 'auth_v1_token';
|
||||||
|
const tonProofStorageKey = 'stored_ton_proof';
|
||||||
const payloadTTLMS = 1000 * 60 * 20;
|
const payloadTTLMS = 1000 * 60 * 20;
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
|
|
@ -13,6 +13,25 @@ export const useAuth = () => {
|
||||||
const [tonConnectUI] = useTonConnectUI();
|
const [tonConnectUI] = useTonConnectUI();
|
||||||
const interval = useRef<ReturnType<typeof setInterval> | undefined>();
|
const interval = useRef<ReturnType<typeof setInterval> | undefined>();
|
||||||
|
|
||||||
|
// Store ton_proof when it becomes available
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
tonConnectUI.wallet?.connectItems?.tonProof &&
|
||||||
|
!('error' in tonConnectUI.wallet.connectItems.tonProof) &&
|
||||||
|
tonConnectUI.wallet.account
|
||||||
|
) {
|
||||||
|
console.log('DEBUG: Storing ton_proof for future use');
|
||||||
|
localStorage.setItem(
|
||||||
|
tonProofStorageKey,
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
account: tonConnectUI.wallet.account,
|
||||||
|
proof: tonConnectUI.wallet.connectItems.tonProof.proof,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [tonConnectUI.wallet?.connectItems?.tonProof, tonConnectUI.wallet?.account]);
|
||||||
|
|
||||||
const makeAuthRequest = async (params: {
|
const makeAuthRequest = async (params: {
|
||||||
twa_data: string;
|
twa_data: string;
|
||||||
ton_proof?: {
|
ton_proof?: {
|
||||||
|
|
@ -28,7 +47,7 @@ export const useAuth = () => {
|
||||||
ton_balance: string;
|
ton_balance: string;
|
||||||
};
|
};
|
||||||
auth_v1_token: string;
|
auth_v1_token: string;
|
||||||
}>('/auth.twa', appendReferral(params));
|
}>('/auth.twa', params);
|
||||||
|
|
||||||
if (res?.data?.auth_v1_token) {
|
if (res?.data?.auth_v1_token) {
|
||||||
localStorage.setItem(sessionStorageKey, res.data.auth_v1_token);
|
localStorage.setItem(sessionStorageKey, res.data.auth_v1_token);
|
||||||
|
|
@ -42,6 +61,11 @@ export const useAuth = () => {
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// If we were using ton_proof and it failed, clear stored proof
|
||||||
|
if (params.ton_proof) {
|
||||||
|
console.log('DEBUG: Auth with proof failed, clearing stored proof');
|
||||||
|
localStorage.removeItem(tonProofStorageKey);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -52,14 +76,15 @@ export const useAuth = () => {
|
||||||
return res;
|
return res;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Check for 404 error (wallet not found or invalid)
|
// Check for 404 error (wallet not found or invalid)
|
||||||
if (error.response?.status === 404) {
|
if (error.response?.status === 404) {
|
||||||
console.log('DEBUG: Wallet selection failed with 404, disconnecting');
|
console.log('DEBUG: Wallet selection failed with 404, disconnecting');
|
||||||
await tonConnectUI.disconnect();
|
await tonConnectUI.disconnect();
|
||||||
localStorage.removeItem(sessionStorageKey);
|
localStorage.removeItem(sessionStorageKey);
|
||||||
}
|
localStorage.removeItem(tonProofStorageKey);
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
};
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Helper to prepare the connection parameters with proof requirements
|
// Helper to prepare the connection parameters with proof requirements
|
||||||
const prepareConnectParams = async () => {
|
const prepareConnectParams = async () => {
|
||||||
|
|
@ -70,9 +95,9 @@ export const useAuth = () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the payload/token from backend
|
// Get the payload/token from backend
|
||||||
const value = await request.post<{ auth_v1_token: string }>('/auth.twa', appendReferral({
|
const value = await request.post<{ auth_v1_token: string }>('/auth.twa', {
|
||||||
twa_data: WebApp.initData,
|
twa_data: WebApp.initData,
|
||||||
}));
|
});
|
||||||
|
|
||||||
if (value?.data?.auth_v1_token) {
|
if (value?.data?.auth_v1_token) {
|
||||||
console.log('DEBUG: Got token for connect params');
|
console.log('DEBUG: Got token for connect params');
|
||||||
|
|
@ -104,26 +129,6 @@ export const useAuth = () => {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper для ожидания подключения с таймаутом
|
|
||||||
const waitForConnection = async (timeoutMs = 30000, checkIntervalMs = 500) => {
|
|
||||||
console.log('DEBUG: Waiting for wallet connection');
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
while (Date.now() - startTime < timeoutMs) {
|
|
||||||
if (tonConnectUI.connected) {
|
|
||||||
console.log('DEBUG: Connection detected');
|
|
||||||
// Даем дополнительное время для инициализации connectItems
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Проверяем статус каждые checkIntervalMs
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, checkIntervalMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('DEBUG: Connection wait timed out');
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
return useMutation(['auth'], async () => {
|
return useMutation(['auth'], async () => {
|
||||||
clearInterval(interval.current);
|
clearInterval(interval.current);
|
||||||
let authResult;
|
let authResult;
|
||||||
|
|
@ -145,34 +150,13 @@ export const useAuth = () => {
|
||||||
// Start periodic refresh of the payload
|
// Start periodic refresh of the payload
|
||||||
interval.current = setInterval(prepareConnectParams, payloadTTLMS);
|
interval.current = setInterval(prepareConnectParams, payloadTTLMS);
|
||||||
|
|
||||||
// Open the modal and wait for connection
|
// Open the modal - this will not resolve until connection or cancellation
|
||||||
try {
|
await tonConnectUI.openModal();
|
||||||
console.log('DEBUG: Opening wallet connect modal');
|
|
||||||
tonConnectUI.openModal();
|
|
||||||
|
|
||||||
// Ждем подключения кошелька
|
// Check if connection was successful
|
||||||
const connected = await waitForConnection();
|
if (!tonConnectUI.connected) {
|
||||||
|
console.log('DEBUG: Connection cancelled or failed');
|
||||||
if (!connected || !tonConnectUI.connected) {
|
throw new Error('Wallet connection cancelled or failed');
|
||||||
console.log('DEBUG: Connection cancelled or failed after waiting');
|
|
||||||
throw new Error('Wallet connection cancelled or failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Даем дополнительное время для полной инициализации wallet
|
|
||||||
if (!tonConnectUI.wallet || !tonConnectUI.wallet.connectItems) {
|
|
||||||
console.log('DEBUG: Wallet object not fully initialized, waiting...');
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('DEBUG: Connection successful, wallet details:', {
|
|
||||||
connected: tonConnectUI.connected,
|
|
||||||
hasWallet: !!tonConnectUI.wallet,
|
|
||||||
hasConnectItems: !!tonConnectUI.wallet?.connectItems,
|
|
||||||
hasTonProof: !!tonConnectUI.wallet?.connectItems?.tonProof,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('DEBUG: Error during wallet connection:', error);
|
|
||||||
throw new Error('Wallet connection process failed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have a proof after connection
|
// Check if we have a proof after connection
|
||||||
|
|
@ -204,13 +188,93 @@ export const useAuth = () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Case 2: Already connected
|
// Case 2: Already connected - try to use stored proof first
|
||||||
console.log('DEBUG: Already connected');
|
console.log('DEBUG: Already connected');
|
||||||
// TonConnect proofs are meant for initial wallet binding; reusing old proofs
|
|
||||||
// commonly fails server-side (replay/unknown payload). Use TWA auth without proof.
|
// Check if we have a valid stored proof
|
||||||
authResult = await makeAuthRequest({
|
const storedProofData = localStorage.getItem(tonProofStorageKey);
|
||||||
twa_data: WebApp.initData,
|
if (storedProofData) {
|
||||||
});
|
try {
|
||||||
|
const proofData = JSON.parse(storedProofData);
|
||||||
|
|
||||||
|
// Check if the proof matches current wallet and is not too old
|
||||||
|
if (tonConnectUI.wallet?.account?.address === proofData.account.address) {
|
||||||
|
console.log('DEBUG: Using stored proof');
|
||||||
|
|
||||||
|
// Try auth with stored proof but ignore errors
|
||||||
|
try {
|
||||||
|
authResult = await makeAuthRequest({
|
||||||
|
twa_data: WebApp.initData,
|
||||||
|
ton_proof: {
|
||||||
|
account: proofData.account,
|
||||||
|
ton_proof: proofData.proof,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If successful, remove stored proof as it's been used
|
||||||
|
localStorage.removeItem(tonProofStorageKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
'DEBUG: Auth with stored proof failed, proceeding without it'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fall back to auth without proof
|
||||||
|
authResult = await makeAuthRequest({
|
||||||
|
twa_data: WebApp.initData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('DEBUG: Stored proof address mismatch');
|
||||||
|
localStorage.removeItem(tonProofStorageKey);
|
||||||
|
|
||||||
|
// Auth without proof
|
||||||
|
authResult = await makeAuthRequest({
|
||||||
|
twa_data: WebApp.initData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DEBUG: Error parsing stored proof:', error);
|
||||||
|
localStorage.removeItem(tonProofStorageKey);
|
||||||
|
|
||||||
|
// Auth without proof
|
||||||
|
authResult = await makeAuthRequest({
|
||||||
|
twa_data: WebApp.initData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No stored proof, check if we have a live proof
|
||||||
|
if (
|
||||||
|
tonConnectUI.wallet?.connectItems?.tonProof &&
|
||||||
|
!('error' in tonConnectUI.wallet.connectItems.tonProof)
|
||||||
|
) {
|
||||||
|
console.log('DEBUG: Using live proof from wallet');
|
||||||
|
try {
|
||||||
|
// Try auth with the live proof
|
||||||
|
authResult = await makeAuthRequest({
|
||||||
|
twa_data: WebApp.initData,
|
||||||
|
ton_proof: {
|
||||||
|
account: tonConnectUI.wallet.account,
|
||||||
|
ton_proof: tonConnectUI.wallet.connectItems.tonProof.proof,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
'DEBUG: Auth with live proof failed, proceeding without it'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fall back to auth without proof
|
||||||
|
authResult = await makeAuthRequest({
|
||||||
|
twa_data: WebApp.initData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Connected without proof - already authenticated
|
||||||
|
console.log('DEBUG: Connected without proof, proceeding without it');
|
||||||
|
authResult = await makeAuthRequest({
|
||||||
|
twa_data: WebApp.initData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always try to select wallet after auth (this validates the connection)
|
// Always try to select wallet after auth (this validates the connection)
|
||||||
|
|
@ -226,6 +290,7 @@ export const useAuth = () => {
|
||||||
console.log('DEBUG: Connection validation failed, disconnecting');
|
console.log('DEBUG: Connection validation failed, disconnecting');
|
||||||
await tonConnectUI.disconnect();
|
await tonConnectUI.disconnect();
|
||||||
localStorage.removeItem(sessionStorageKey);
|
localStorage.removeItem(sessionStorageKey);
|
||||||
|
localStorage.removeItem(tonProofStorageKey);
|
||||||
throw new Error('Connection validation failed');
|
throw new Error('Connection validation failed');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
import { request } from "~/shared/libs";
|
import { request } from "~/shared/libs";
|
||||||
import { useWebApp } from "@vkruglikov/react-telegram-web-app";
|
import { useWebApp } from "@vkruglikov/react-telegram-web-app";
|
||||||
import { appendReferral } from "~/shared/utils/start-payload";
|
|
||||||
|
|
||||||
const sessionStorageKey = "auth_v1_token";
|
const sessionStorageKey = "auth_v1_token";
|
||||||
|
|
||||||
|
|
@ -11,9 +10,9 @@ export const useAuthTwa = () => {
|
||||||
const makeAuthRequest = async () => {
|
const makeAuthRequest = async () => {
|
||||||
const res = await request.post<{
|
const res = await request.post<{
|
||||||
auth_v1_token: string;
|
auth_v1_token: string;
|
||||||
}>("/auth.twa", appendReferral({
|
}>("/auth.twa", {
|
||||||
twa_data: WebApp.initData,
|
twa_data: WebApp.initData,
|
||||||
}));
|
});
|
||||||
|
|
||||||
if (res?.data?.auth_v1_token) {
|
if (res?.data?.auth_v1_token) {
|
||||||
localStorage.setItem(sessionStorageKey, res.data.auth_v1_token);
|
localStorage.setItem(sessionStorageKey, res.data.auth_v1_token);
|
||||||
|
|
@ -24,4 +23,4 @@ export const useAuthTwa = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return useMutation(["auth"], makeAuthRequest);
|
return useMutation(["auth"], makeAuthRequest);
|
||||||
};
|
};
|
||||||
|
|
@ -3,15 +3,6 @@ import { useMutation, useQuery } from "react-query";
|
||||||
import { request } from "~/shared/libs";
|
import { request } from "~/shared/libs";
|
||||||
import { Royalty } from "~/shared/stores/root";
|
import { Royalty } from "~/shared/stores/root";
|
||||||
|
|
||||||
const VIEW_CONTENT_NODE_ORIGINS = [
|
|
||||||
"https://my-public-node-103.projscale.dev",
|
|
||||||
"https://my-public-node-7.projscale.dev",
|
|
||||||
"https://my-public-node-9.projscale.dev",
|
|
||||||
"https://my-public-node-10.projscale.dev",
|
|
||||||
"http://localhost:3000",
|
|
||||||
] as const;
|
|
||||||
const VIEW_CONTENT_API_PATH = "/api/v1";
|
|
||||||
|
|
||||||
type UseCreateNewContentPayload = {
|
type UseCreateNewContentPayload = {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
|
@ -29,44 +20,16 @@ type UseCreateNewContentPayload = {
|
||||||
export const useCreateNewContent = () => {
|
export const useCreateNewContent = () => {
|
||||||
return useMutation(
|
return useMutation(
|
||||||
["create-new-content"],
|
["create-new-content"],
|
||||||
async (payload: UseCreateNewContentPayload) => {
|
(payload: UseCreateNewContentPayload) => {
|
||||||
console.info("[App2Client][Content] Отправляем контент на создание", {
|
return request.post<{
|
||||||
title: payload.title,
|
address: string;
|
||||||
hasImage: Boolean(payload.image),
|
amount: string;
|
||||||
contentCid: payload.content,
|
payload: string;
|
||||||
hashtags: payload.hashtags,
|
}>("/blockchain.sendNewContentMessage", payload, {
|
||||||
authorsCount: payload.authors.length,
|
headers: {
|
||||||
royaltyCount: payload.royaltyParams.length,
|
Authorization: localStorage.getItem('auth_v1_token') ?? ""
|
||||||
downloadable: payload.downloadable,
|
}
|
||||||
allowResale: payload.allowResale,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await request.post<{
|
|
||||||
address: string;
|
|
||||||
amount: string;
|
|
||||||
payload: string;
|
|
||||||
}>("/blockchain.sendNewContentMessage", payload, {
|
|
||||||
headers: {
|
|
||||||
Authorization: localStorage.getItem('auth_v1_token') ?? ""
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.info("[App2Client][Content] Сервер вернул данные для транзакции", {
|
|
||||||
title: payload.title,
|
|
||||||
hasAddress: Boolean(response.data.address),
|
|
||||||
amount: response.data.amount,
|
|
||||||
payloadLength: response.data.payload?.length ?? 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[App2Client][Content] Ошибка при создании контента", {
|
|
||||||
title: payload.title,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -82,42 +45,14 @@ export const useCreateNewContent = () => {
|
||||||
// );
|
// );
|
||||||
// };
|
// };
|
||||||
|
|
||||||
export const useViewContent = (contentId: string | null | undefined) => {
|
export const useViewContent = (contentId: string) => {
|
||||||
return useQuery(
|
return useQuery(["view", "content", contentId], () => {
|
||||||
["view", "content", contentId],
|
return request.get(`/content.view/${contentId}`, {
|
||||||
async () => {
|
headers: {
|
||||||
if (!contentId) {
|
Authorization: localStorage.getItem('auth_v1_token') ?? ""
|
||||||
throw new Error("contentId is required");
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
let lastError: unknown = null;
|
});
|
||||||
for (const origin of VIEW_CONTENT_NODE_ORIGINS) {
|
|
||||||
const baseURL = `${origin.replace(/\/$/, "")}${VIEW_CONTENT_API_PATH}`;
|
|
||||||
try {
|
|
||||||
const response = await request.get(`/content.view/${contentId}`, {
|
|
||||||
baseURL,
|
|
||||||
});
|
|
||||||
if (origin !== VIEW_CONTENT_NODE_ORIGINS[0]) {
|
|
||||||
console.info("[App2Client][ContentView] Используем резервную ноду", {
|
|
||||||
origin,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
console.warn("[App2Client][ContentView] Нода недоступна", {
|
|
||||||
origin,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError ?? new Error("All content nodes are unreachable");
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: Boolean(contentId),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePurchaseContent = () => {
|
export const usePurchaseContent = () => {
|
||||||
|
|
|
||||||
|
|
@ -1,722 +1,201 @@
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
import { useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Upload } from "tus-js-client";
|
|
||||||
|
|
||||||
import { request } from "~/shared/libs";
|
import { request } from "~/shared/libs";
|
||||||
|
|
||||||
const resolveApiBaseUrl = () => {
|
const STORAGE_API_URL = import.meta.env.VITE_API_BASE_STORAGE_URL;
|
||||||
const envBase = (import.meta.env.VITE_API_BASE_URL ?? "").trim();
|
|
||||||
if (envBase) {
|
|
||||||
return envBase.replace(/\/$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
return `${window.location.origin.replace(/\/$/, "")}/api/v1`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "/api/v1";
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_API_BASE_URL = resolveApiBaseUrl();
|
|
||||||
|
|
||||||
const resolveUploadEndpoints = () => {
|
|
||||||
try {
|
|
||||||
const apiUrl = new URL(DEFAULT_API_BASE_URL);
|
|
||||||
const origin = apiUrl.origin.replace(/\/$/, "");
|
|
||||||
return {
|
|
||||||
storage: `${origin}/api/v1.5/storage`,
|
|
||||||
tus: `${origin}/tus/files`,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const origin = window.location.origin.replace(/\/$/, "");
|
|
||||||
return {
|
|
||||||
storage: `${origin}/api/v1.5/storage`,
|
|
||||||
tus: `${origin}/tus/files`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn("[App2Client][Upload] Cannot derive upload endpoints from VITE_API_BASE_URL", {
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
storage: '/api/v1.5/storage',
|
|
||||||
tus: '/tus/files',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { storage: STORAGE_API_URL, tus: TUS_ENDPOINT } = resolveUploadEndpoints();
|
|
||||||
const MAX_CHUNK_SIZE = 80 * 1024 * 1024; // 80 MB
|
const MAX_CHUNK_SIZE = 80 * 1024 * 1024; // 80 MB
|
||||||
|
|
||||||
const TUS_STATUS_POLL_INTERVAL_MS = 2000;
|
export const useUploadFile = () => {
|
||||||
const TUS_STATUS_POLL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise<void>((resolve) => {
|
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
|
|
||||||
const normalizeError = (error: unknown, fallbackMessage: string) => {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof error === "string") {
|
|
||||||
return new Error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && typeof error === "object" && "message" in error) {
|
|
||||||
const message = (error as { message?: unknown }).message;
|
|
||||||
if (typeof message === "string") {
|
|
||||||
return new Error(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Error(fallbackMessage);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeMetadataKey = (key: string) => {
|
|
||||||
return key.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeMetadata = (
|
|
||||||
metadata?: Record<string, string | number | boolean | undefined>,
|
|
||||||
) => {
|
|
||||||
if (!metadata) {
|
|
||||||
return {} as Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(metadata).reduce((acc, [rawKey, rawValue]) => {
|
|
||||||
if (rawValue === undefined || rawValue === null) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = sanitizeMetadataKey(rawKey);
|
|
||||||
if (!key) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
acc[key] = String(rawValue);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, string>);
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractUploadId = (uploadUrl: string) => {
|
|
||||||
try {
|
|
||||||
const url = new URL(uploadUrl);
|
|
||||||
const parts = url.pathname.split("/").filter(Boolean);
|
|
||||||
const uploadId = parts[parts.length - 1];
|
|
||||||
if (!uploadId) {
|
|
||||||
throw new Error("Empty upload id");
|
|
||||||
}
|
|
||||||
return uploadId;
|
|
||||||
} catch (error) {
|
|
||||||
const normalized = uploadUrl.split("/").filter(Boolean);
|
|
||||||
const uploadId = normalized[normalized.length - 1];
|
|
||||||
if (!uploadId) {
|
|
||||||
throw new Error("Unable to extract upload id from tus Location");
|
|
||||||
}
|
|
||||||
return uploadId;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
type UploadSessionState = "uploading" | "processing" | "pinned" | "failed";
|
|
||||||
|
|
||||||
type UploadStatusResponse = {
|
|
||||||
id: string;
|
|
||||||
state: UploadSessionState;
|
|
||||||
encrypted_cid?: string | null;
|
|
||||||
size_bytes?: number | null;
|
|
||||||
error?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pollUploadStatus = async (
|
|
||||||
uploadId: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<UploadStatusResponse> => {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
let attempt = 0;
|
|
||||||
|
|
||||||
console.info("[App2Client][TusUpload] Запускаем polling статуса", {
|
|
||||||
uploadId,
|
|
||||||
intervalMs: TUS_STATUS_POLL_INTERVAL_MS,
|
|
||||||
timeoutMs: TUS_STATUS_POLL_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
attempt += 1;
|
|
||||||
if (signal?.aborted) {
|
|
||||||
throw new DOMException("Tus status polling aborted", "AbortError");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data } = await request.get<UploadStatusResponse>(
|
|
||||||
`/upload.status/${uploadId}`,
|
|
||||||
{
|
|
||||||
headers: { "Cache-Control": "no-store" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
console.info("[App2Client][TusUpload] Получен статус tus", {
|
|
||||||
uploadId,
|
|
||||||
attempt,
|
|
||||||
state: data.state,
|
|
||||||
hasEncryptedCid: Boolean(data.encrypted_cid),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.state === "failed") {
|
|
||||||
throw new Error(data.error || "Tus upload failed on server");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.state === "pinned" && data.encrypted_cid) {
|
|
||||||
console.info("[App2Client][TusUpload] Статус tus завершен", {
|
|
||||||
uploadId,
|
|
||||||
state: data.state,
|
|
||||||
encryptedCid: data.encrypted_cid,
|
|
||||||
sizeBytes: data.size_bytes,
|
|
||||||
attempts: attempt,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const maybeAxios = error as { response?: { status?: number } };
|
|
||||||
if (maybeAxios?.response?.status === 404) {
|
|
||||||
// Hook has not recorded the upload session yet; continue polling.
|
|
||||||
console.info("[App2Client][TusUpload] Статус tus еще не готов (404)", {
|
|
||||||
uploadId,
|
|
||||||
attempt,
|
|
||||||
});
|
|
||||||
} else if (error instanceof DOMException && error.name === "AbortError") {
|
|
||||||
console.warn("[App2Client][TusUpload] Поллинг tus прерван", {
|
|
||||||
uploadId,
|
|
||||||
attempt,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
} else {
|
|
||||||
console.error("[App2Client][TusUpload] Ошибка во время polling", {
|
|
||||||
uploadId,
|
|
||||||
attempt,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
throw normalizeError(error, "Tus status polling failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Date.now() - startedAt > TUS_STATUS_POLL_TIMEOUT_MS) {
|
|
||||||
console.error("[App2Client][TusUpload] Поллинг tus превысил таймаут", {
|
|
||||||
uploadId,
|
|
||||||
attempts: attempt,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
});
|
|
||||||
throw new Error("Timed out waiting for tus upload finalization");
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(TUS_STATUS_POLL_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
type TusUploadArgs = {
|
|
||||||
file: File;
|
|
||||||
metadata?: Record<string, string | number | boolean | undefined>;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TusUploadResult = {
|
|
||||||
kind: "tus";
|
|
||||||
uploadId: string;
|
|
||||||
encryptedCid: string;
|
|
||||||
sizeBytes?: number;
|
|
||||||
state: UploadSessionState;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useTusUpload = () => {
|
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadError, setUploadError] = useState<Error | null>(null);
|
const [uploadError, setUploadError] = useState<Error | null>(null);
|
||||||
const activeUploadRef = useRef<Upload | null>(null);
|
|
||||||
const lastLoggedProgressRef = useRef(-10);
|
|
||||||
|
|
||||||
const mutation = useMutation<TusUploadResult, Error, TusUploadArgs>(
|
const mutation = useMutation(["upload-file"], async (file: File) => {
|
||||||
["upload-file", "tus"],
|
console.log(`Начинаем загрузку файла: ${file.name} (${file.size} байт)`);
|
||||||
async ({ file, metadata, signal }) => {
|
|
||||||
if (!file) {
|
|
||||||
throw new Error("File is required for tus upload");
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
setUploadProgress(0);
|
|
||||||
setUploadError(null);
|
|
||||||
let lastError: Error | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!TUS_ENDPOINT) {
|
|
||||||
throw new Error("Tus endpoint is not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await new Promise<TusUploadResult>((resolve, reject) => {
|
|
||||||
const token = localStorage.getItem("auth_v1_token") ?? "";
|
|
||||||
const normalizedMeta = normalizeMetadata(metadata);
|
|
||||||
const headers: Record<string, string> = { "Cache-Control": "no-store" };
|
|
||||||
if (token) {
|
|
||||||
headers.Authorization = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info("[App2Client][TusUpload] Начинаем загрузку", {
|
|
||||||
fileName: file.name,
|
|
||||||
fileSize: file.size,
|
|
||||||
fileType: file.type,
|
|
||||||
metadata: normalizedMeta,
|
|
||||||
});
|
|
||||||
|
|
||||||
const upload = new Upload(file, {
|
|
||||||
endpoint: TUS_ENDPOINT,
|
|
||||||
metadata: {
|
|
||||||
filename: file.name,
|
|
||||||
content_type: file.type || "application/octet-stream",
|
|
||||||
...normalizedMeta,
|
|
||||||
},
|
|
||||||
headers,
|
|
||||||
retryDelays: [0, 1000, 3000, 5000],
|
|
||||||
removeFingerprintOnSuccess: true,
|
|
||||||
uploadDataDuringCreation: true,
|
|
||||||
onError: (err) => {
|
|
||||||
const normalized = normalizeError(err, "Tus upload failed");
|
|
||||||
lastError = normalized;
|
|
||||||
setUploadError(normalized);
|
|
||||||
setIsUploading(false);
|
|
||||||
setUploadProgress(0);
|
|
||||||
activeUploadRef.current = null;
|
|
||||||
reject(normalized);
|
|
||||||
},
|
|
||||||
onProgress: (bytesUploaded, bytesTotal) => {
|
|
||||||
const percent = bytesTotal
|
|
||||||
? Math.floor((bytesUploaded / bytesTotal) * 100)
|
|
||||||
: 0;
|
|
||||||
setUploadProgress(Math.min(99, Math.max(0, percent)));
|
|
||||||
|
|
||||||
if (percent >= lastLoggedProgressRef.current + 5 || percent === 100) {
|
|
||||||
lastLoggedProgressRef.current = percent;
|
|
||||||
console.info("[App2Client][TusUpload] Прогресс загрузки", {
|
|
||||||
fileName: file.name,
|
|
||||||
bytesUploaded,
|
|
||||||
bytesTotal,
|
|
||||||
percent,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
setUploadProgress(99);
|
|
||||||
const uploadUrl = upload.url;
|
|
||||||
if (!uploadUrl) {
|
|
||||||
throw new Error("Tus upload finished without Location header");
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadId = extractUploadId(uploadUrl);
|
|
||||||
console.info("[App2Client][TusUpload] Загрузка завершена на tus, начинаем финализацию", {
|
|
||||||
fileName: file.name,
|
|
||||||
uploadId,
|
|
||||||
});
|
|
||||||
const status = await pollUploadStatus(uploadId, signal);
|
|
||||||
const encryptedCid = status.encrypted_cid;
|
|
||||||
if (!encryptedCid) {
|
|
||||||
throw new Error("Tus upload finalized without encrypted CID");
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadProgress(100);
|
|
||||||
setIsUploading(false);
|
|
||||||
activeUploadRef.current = null;
|
|
||||||
|
|
||||||
console.info("[App2Client][TusUpload] Финализация tus завершена", {
|
|
||||||
fileName: file.name,
|
|
||||||
uploadId,
|
|
||||||
encryptedCid,
|
|
||||||
finalState: status.state,
|
|
||||||
sizeBytes: status.size_bytes,
|
|
||||||
});
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
kind: "tus",
|
|
||||||
uploadId,
|
|
||||||
encryptedCid,
|
|
||||||
sizeBytes: status.size_bytes ?? undefined,
|
|
||||||
state: status.state,
|
|
||||||
});
|
|
||||||
} catch (statusError) {
|
|
||||||
const normalized = normalizeError(
|
|
||||||
statusError,
|
|
||||||
"Failed to finalize tus upload",
|
|
||||||
);
|
|
||||||
lastError = normalized;
|
|
||||||
console.error("[App2Client][TusUpload] Ошибка финализации tus", {
|
|
||||||
fileName: file.name,
|
|
||||||
error: normalized,
|
|
||||||
});
|
|
||||||
setUploadError(normalized);
|
|
||||||
setIsUploading(false);
|
|
||||||
activeUploadRef.current = null;
|
|
||||||
reject(normalized);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
activeUploadRef.current = upload;
|
|
||||||
lastLoggedProgressRef.current = 0;
|
|
||||||
|
|
||||||
console.info("[App2Client][TusUpload] Запрашиваем предыдущие загрузки для возобновления", {
|
|
||||||
fileName: file.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (signal) {
|
|
||||||
signal.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => {
|
|
||||||
upload.abort();
|
|
||||||
const abortError = new DOMException(
|
|
||||||
"Tus upload aborted",
|
|
||||||
"AbortError",
|
|
||||||
);
|
|
||||||
console.warn("[App2Client][TusUpload] Загрузка прервана внешним сигналом", {
|
|
||||||
fileName: file.name,
|
|
||||||
});
|
|
||||||
lastError = abortError;
|
|
||||||
setUploadError(abortError);
|
|
||||||
setIsUploading(false);
|
|
||||||
activeUploadRef.current = null;
|
|
||||||
reject(abortError);
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
upload
|
|
||||||
.findPreviousUploads()
|
|
||||||
.then((previousUploads) => {
|
|
||||||
console.info("[App2Client][TusUpload] Результат поиска предыдущих загрузок", {
|
|
||||||
fileName: file.name,
|
|
||||||
count: previousUploads.length,
|
|
||||||
});
|
|
||||||
if (previousUploads.length > 0) {
|
|
||||||
upload.resumeFromPreviousUpload(previousUploads[0]);
|
|
||||||
console.info("[App2Client][TusUpload] Возобновляем загрузку", {
|
|
||||||
fileName: file.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
upload.start();
|
|
||||||
console.info("[App2Client][TusUpload] Стартуем загрузку", {
|
|
||||||
fileName: file.name,
|
|
||||||
chunkSize: upload.options.chunkSize,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((resumeError) => {
|
|
||||||
const normalized = normalizeError(
|
|
||||||
resumeError,
|
|
||||||
"Failed to resume tus upload",
|
|
||||||
);
|
|
||||||
lastError = normalized;
|
|
||||||
console.error("[App2Client][TusUpload] Не удалось возобновить загрузку", {
|
|
||||||
fileName: file.name,
|
|
||||||
error: normalized,
|
|
||||||
});
|
|
||||||
setUploadError(normalized);
|
|
||||||
setIsUploading(false);
|
|
||||||
activeUploadRef.current = null;
|
|
||||||
reject(normalized);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
const normalized = normalizeError(error, "Tus upload failed");
|
|
||||||
if (!lastError) {
|
|
||||||
lastError = normalized;
|
|
||||||
console.error("[App2Client][TusUpload] Ошибка во время загрузки", {
|
|
||||||
fileName: file.name,
|
|
||||||
error: normalized,
|
|
||||||
});
|
|
||||||
setUploadError(normalized);
|
|
||||||
}
|
|
||||||
setIsUploading(false);
|
|
||||||
throw normalized;
|
|
||||||
} finally {
|
|
||||||
activeUploadRef.current = null;
|
|
||||||
setIsUploading(false);
|
|
||||||
console.info("[App2Client][TusUpload] Завершили работу загрузчика", {
|
|
||||||
fileName: file?.name,
|
|
||||||
hasError: Boolean(lastError),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetUploadError = () => setUploadError(null);
|
|
||||||
|
|
||||||
const abortUpload = () => {
|
|
||||||
if (activeUploadRef.current) {
|
|
||||||
activeUploadRef.current.abort();
|
|
||||||
activeUploadRef.current = null;
|
|
||||||
setIsUploading(false);
|
|
||||||
setUploadProgress(0);
|
|
||||||
console.warn("[App2Client][TusUpload] Принудительно остановили загрузку", {
|
|
||||||
reason: "manual",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...mutation,
|
|
||||||
uploadProgress,
|
|
||||||
isUploading,
|
|
||||||
uploadError,
|
|
||||||
resetUploadError,
|
|
||||||
abortUpload,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useLegacyUploadFile = () => {
|
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const [uploadError, setUploadError] = useState<Error | null>(null);
|
|
||||||
const lastLoggedProgressRef = useRef(-10);
|
|
||||||
|
|
||||||
const mutation = useMutation(["upload-file", "legacy"], async (file: File) => {
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setUploadError(null);
|
setUploadError(null); // Сбрасываем ошибку перед началом новой загрузки
|
||||||
lastLoggedProgressRef.current = 0;
|
|
||||||
let lastError: Error | null = null;
|
|
||||||
|
|
||||||
console.info("[App2Client][LegacyUpload] Начинаем загрузку", {
|
|
||||||
fileName: file.name,
|
|
||||||
fileSize: file.size,
|
|
||||||
fileType: file.type,
|
|
||||||
isChunked: file.size > MAX_CHUNK_SIZE,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Для маленьких файлов используем обычную загрузку, но с теми же заголовками
|
||||||
if (file.size <= MAX_CHUNK_SIZE) {
|
if (file.size <= MAX_CHUNK_SIZE) {
|
||||||
|
console.log("Используем обычную загрузку (файл <= MAX_CHUNK_SIZE)");
|
||||||
|
|
||||||
|
// Подготавливаем заголовки - такие же, как для чанковой загрузки
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"X-File-Name": btoa(unescape(encodeURIComponent(file.name))),
|
"X-File-Name": btoa(unescape(encodeURIComponent(file.name))), // Имя файла в base64
|
||||||
"X-Chunk-Start": "0",
|
"X-Chunk-Start": "0", // Начинаем с позиции 0
|
||||||
"Content-Type": file.type || "application/octet-stream",
|
"Content-Type": file.type || "application/octet-stream",
|
||||||
"X-Last-Chunk": "1",
|
"X-Last-Chunk": "1" // Это единственный и последний чанк
|
||||||
};
|
};
|
||||||
|
|
||||||
const authToken = localStorage.getItem("auth_v1_token");
|
// Добавляем заголовок авторизации
|
||||||
|
const authToken = localStorage.getItem('auth_v1_token');
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
headers.Authorization = authToken;
|
headers["Authorization"] = authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await request.post<{
|
console.log("Заголовки запроса:", headers);
|
||||||
upload_id?: string;
|
|
||||||
content_sha256?: string;
|
try {
|
||||||
content_id?: string;
|
const response = await request.post<{
|
||||||
content_id_v1?: string;
|
upload_id?: string;
|
||||||
content_url?: string;
|
content_sha256?: string;
|
||||||
}>("", file, {
|
content_id?: string;
|
||||||
baseURL: STORAGE_API_URL,
|
content_id_v1?: string;
|
||||||
headers,
|
content_url?: string;
|
||||||
onUploadProgress: (progressEvent) => {
|
}>("", file, { // Отправляем файл напрямую вместо FormData
|
||||||
const total = progressEvent?.total ?? file.size;
|
baseURL: STORAGE_API_URL,
|
||||||
const percentCompleted = Math.round(
|
headers,
|
||||||
(progressEvent.loaded * 100) / total,
|
onUploadProgress: (progressEvent) => {
|
||||||
);
|
const percentCompleted = Math.round(
|
||||||
setUploadProgress(Math.min(99, percentCompleted));
|
(progressEvent.loaded * 100) / (progressEvent?.total as number || file.size)
|
||||||
|
);
|
||||||
if (
|
setUploadProgress(Math.min(99, percentCompleted));
|
||||||
percentCompleted >= lastLoggedProgressRef.current + 5 ||
|
},
|
||||||
percentCompleted === 100
|
|
||||||
) {
|
|
||||||
lastLoggedProgressRef.current = percentCompleted;
|
|
||||||
console.info("[App2Client][LegacyUpload] Прогресс загрузки", {
|
|
||||||
fileName: file.name,
|
|
||||||
percent: percentCompleted,
|
|
||||||
loaded: progressEvent.loaded,
|
|
||||||
total,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setUploadProgress(100);
|
|
||||||
|
|
||||||
console.info("[App2Client][LegacyUpload] Загрузка завершена", {
|
|
||||||
fileName: file.name,
|
|
||||||
response: {
|
|
||||||
hasContentId: Boolean(
|
|
||||||
response.data.content_id_v1 || response.data.content_id,
|
|
||||||
),
|
|
||||||
hasSha256: Boolean(response.data.content_sha256),
|
|
||||||
uploadId: response.data.upload_id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
content_sha256: response.data.content_sha256 || "",
|
|
||||||
content_id_v1:
|
|
||||||
response.data.content_id_v1 || response.data.content_id || "",
|
|
||||||
content_url: response.data.content_url || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let offset = 0;
|
|
||||||
let uploadId: string | null = null;
|
|
||||||
|
|
||||||
while (offset < file.size) {
|
|
||||||
const chunkEnd = Math.min(offset + MAX_CHUNK_SIZE, file.size);
|
|
||||||
const chunk = file.slice(offset, chunkEnd);
|
|
||||||
const isLastChunk = chunkEnd === file.size;
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"X-File-Name": btoa(unescape(encodeURIComponent(file.name))),
|
|
||||||
"X-Chunk-Start": offset.toString(),
|
|
||||||
"Content-Type": file.type || "application/octet-stream",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLastChunk) {
|
|
||||||
headers["X-Last-Chunk"] = "1";
|
|
||||||
}
|
|
||||||
|
|
||||||
const authToken = localStorage.getItem("auth_v1_token");
|
|
||||||
if (authToken) {
|
|
||||||
headers.Authorization = authToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uploadId) {
|
|
||||||
headers["X-Upload-ID"] = uploadId;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info("[App2Client][LegacyUpload] Отправляем chunk", {
|
|
||||||
fileName: file.name,
|
|
||||||
chunkStart: offset,
|
|
||||||
chunkEnd,
|
|
||||||
chunkSize: chunk.size,
|
|
||||||
isLastChunk,
|
|
||||||
uploadId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request.post<{
|
|
||||||
upload_id?: string;
|
|
||||||
current_size?: number;
|
|
||||||
content_id?: string;
|
|
||||||
content_sha256?: string;
|
|
||||||
content_id_v1?: string;
|
|
||||||
content_url?: string;
|
|
||||||
}>("", chunk, {
|
|
||||||
baseURL: STORAGE_API_URL,
|
|
||||||
headers,
|
|
||||||
onUploadProgress: (progressEvent) => {
|
|
||||||
const overallProgress = offset + progressEvent.loaded;
|
|
||||||
const percentCompleted = Math.round(
|
|
||||||
(overallProgress / file.size) * 100,
|
|
||||||
);
|
|
||||||
setUploadProgress(Math.min(99, percentCompleted));
|
|
||||||
|
|
||||||
if (
|
|
||||||
percentCompleted >= lastLoggedProgressRef.current + 5 ||
|
|
||||||
percentCompleted === 100
|
|
||||||
) {
|
|
||||||
lastLoggedProgressRef.current = percentCompleted;
|
|
||||||
console.info("[App2Client][LegacyUpload] Прогресс загрузки", {
|
|
||||||
fileName: file.name,
|
|
||||||
percent: percentCompleted,
|
|
||||||
loaded: overallProgress,
|
|
||||||
total: file.size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!uploadId && response.data.upload_id) {
|
|
||||||
uploadId = response.data.upload_id;
|
|
||||||
console.info("[App2Client][LegacyUpload] Получили upload_id", {
|
|
||||||
fileName: file.name,
|
|
||||||
uploadId,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
console.log("Ответ на обычную загрузку:", response.data);
|
||||||
if (response.data.content_id) {
|
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
|
|
||||||
console.info("[App2Client][LegacyUpload] Сервер вернул итоговые данные", {
|
|
||||||
fileName: file.name,
|
|
||||||
uploadId,
|
|
||||||
hasContentId: Boolean(
|
|
||||||
response.data.content_id_v1 || response.data.content_id,
|
|
||||||
),
|
|
||||||
hasSha256: Boolean(response.data.content_sha256),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content_sha256: response.data.content_sha256 || "",
|
content_sha256: response.data.content_sha256 || "",
|
||||||
content_id_v1:
|
content_id_v1: response.data.content_id_v1 || response.data.content_id || "",
|
||||||
response.data.content_id_v1 || response.data.content_id || "",
|
content_url: response.data.content_url || ""
|
||||||
content_url: response.data.content_url || "",
|
|
||||||
};
|
};
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error("Ошибка при обычной загрузке:", error);
|
||||||
if (response.data.current_size !== undefined) {
|
setUploadError(error instanceof Error ? error : new Error('Ошибка при загрузке файла'));
|
||||||
offset = response.data.current_size;
|
|
||||||
console.info("[App2Client][LegacyUpload] Продолжаем загрузку", {
|
|
||||||
fileName: file.name,
|
|
||||||
nextOffset: offset,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const error = new Error("Missing current_size in response");
|
|
||||||
setUploadError(error);
|
|
||||||
lastError = error;
|
|
||||||
console.error("[App2Client][LegacyUpload] Сервер не вернул current_size", {
|
|
||||||
fileName: file.name,
|
|
||||||
response,
|
|
||||||
});
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = new Error(
|
// Для больших файлов используем чанковую загрузку
|
||||||
"All chunks uploaded but server did not return content_id",
|
console.log("Используем чанкированную загрузку (файл > MAX_CHUNK_SIZE)");
|
||||||
);
|
|
||||||
|
let offset = 0;
|
||||||
|
let uploadId: string | null = null;
|
||||||
|
let chunkNumber = 0;
|
||||||
|
|
||||||
|
// Загружаем файл по чанкам
|
||||||
|
while (offset < file.size) {
|
||||||
|
chunkNumber++;
|
||||||
|
const chunkEnd = Math.min(offset + MAX_CHUNK_SIZE, file.size);
|
||||||
|
const chunk = file.slice(offset, chunkEnd);
|
||||||
|
console.log(`Загрузка чанка #${chunkNumber} начиная с байта ${offset}`);
|
||||||
|
|
||||||
|
// Определяем, является ли текущий чанк последним
|
||||||
|
const isLastChunk = chunkEnd === file.size;
|
||||||
|
|
||||||
|
// Подготавливаем заголовки
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"X-File-Name": btoa(unescape(encodeURIComponent(file.name))), // Имя файла в base64
|
||||||
|
"X-Chunk-Start": offset.toString(),
|
||||||
|
"Content-Type": file.type || "application/octet-stream"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Добавляем маркер последнего чанка, если это последний чанк
|
||||||
|
if (isLastChunk) {
|
||||||
|
headers["X-Last-Chunk"] = "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем заголовок авторизации
|
||||||
|
const authToken = localStorage.getItem('auth_v1_token');
|
||||||
|
if (authToken) {
|
||||||
|
headers["Authorization"] = authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если есть uploadId, добавляем его в заголовки
|
||||||
|
if (uploadId) {
|
||||||
|
headers["X-Upload-ID"] = uploadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Заголовки запроса:", headers);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.post<{
|
||||||
|
upload_id?: string;
|
||||||
|
current_size?: number;
|
||||||
|
content_id?: string;
|
||||||
|
content_sha256?: string;
|
||||||
|
content_id_v1?: string;
|
||||||
|
content_url?: string;
|
||||||
|
}>("", chunk, {
|
||||||
|
baseURL: STORAGE_API_URL,
|
||||||
|
headers,
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
// Прогресс загрузки текущего чанка
|
||||||
|
const overallProgress = offset + progressEvent.loaded;
|
||||||
|
const percentCompleted = Math.round((overallProgress / file.size) * 100);
|
||||||
|
setUploadProgress(Math.min(99, percentCompleted));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Ответ на чанк #${chunkNumber}:`, response.data);
|
||||||
|
|
||||||
|
// Сохраняем uploadId из первого ответа, если не установлен
|
||||||
|
if (!uploadId && response.data.upload_id) {
|
||||||
|
uploadId = response.data.upload_id;
|
||||||
|
console.log("Получен upload_id:", uploadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка на наличие content_id
|
||||||
|
if (response.data.content_id) {
|
||||||
|
console.log("Загрузка завершена. ID файла:", response.data.content_id);
|
||||||
|
setUploadProgress(100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content_sha256: response.data.content_sha256 || "",
|
||||||
|
content_id_v1: response.data.content_id_v1 || response.data.content_id || "",
|
||||||
|
content_url: response.data.content_url || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем смещение на основе ответа сервера
|
||||||
|
if (response.data.current_size !== undefined) {
|
||||||
|
offset = response.data.current_size;
|
||||||
|
console.log(`Сервер сообщает current_size: ${offset}`);
|
||||||
|
} else {
|
||||||
|
console.warn("Неожиданный ответ от сервера, отсутствует current_size");
|
||||||
|
const error = new Error("Missing current_size in response");
|
||||||
|
setUploadError(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Ошибка при загрузке чанка #${chunkNumber}:`, error);
|
||||||
|
if (error.response) {
|
||||||
|
console.error("Ответ сервера:", error.response.status, error.response.data);
|
||||||
|
}
|
||||||
|
setUploadError(error instanceof Error ? error : new Error(`Ошибка при загрузке чанка #${chunkNumber}`));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error("Ошибка загрузки файла: все чанки загружены, но content_id не получен");
|
||||||
|
console.error(error.message);
|
||||||
setUploadError(error);
|
setUploadError(error);
|
||||||
lastError = error;
|
|
||||||
console.error("[App2Client][LegacyUpload] Все чанки отправлены, но content_id отсутствует", {
|
|
||||||
fileName: file.name,
|
|
||||||
});
|
|
||||||
throw error;
|
throw error;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
const normalized = normalizeError(
|
console.error("Ошибка при загрузке:", error);
|
||||||
error,
|
if (error.response) {
|
||||||
"Unknown error during legacy upload",
|
console.error("Ответ сервера:", error.response.status, error.response.data);
|
||||||
);
|
}
|
||||||
setUploadError(normalized);
|
setUploadError(error instanceof Error ? error : new Error("Неизвестная ошибка при загрузке"));
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
lastError = normalized;
|
throw error;
|
||||||
console.error("[App2Client][LegacyUpload] Ошибка во время загрузки", {
|
|
||||||
fileName: file.name,
|
|
||||||
error: normalized,
|
|
||||||
});
|
|
||||||
throw normalized;
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
console.info("[App2Client][LegacyUpload] Завершили работу загрузчика", {
|
|
||||||
fileName: file.name,
|
|
||||||
hasError: Boolean(lastError),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Сбросить ошибку
|
||||||
const resetUploadError = () => setUploadError(null);
|
const resetUploadError = () => setUploadError(null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mutation,
|
...mutation,
|
||||||
uploadProgress,
|
uploadProgress,
|
||||||
isUploading,
|
isUploading,
|
||||||
uploadError,
|
uploadError,
|
||||||
resetUploadError,
|
resetUploadError
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export { useTusUpload as useUploadFile };
|
|
||||||
export type { TusUploadArgs, TusUploadResult, UploadSessionState };
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
export const START_PARAM_SEPARATOR = '!';
|
|
||||||
const REF_STORAGE_KEY = 'ref_id';
|
|
||||||
|
|
||||||
type StartPayload = {
|
|
||||||
contentId: string | null;
|
|
||||||
referralId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const safeSessionStorage = (): Storage | null => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return window.sessionStorage;
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resolveStartPayload = (): StartPayload => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return { contentId: null, referralId: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
|
||||||
const telegramApp = (window as any)?.Telegram?.WebApp;
|
|
||||||
const rawStartParam = telegramApp?.initDataUnsafe?.start_param ?? null;
|
|
||||||
|
|
||||||
let contentId: string | null = null;
|
|
||||||
let referralId: string | null = null;
|
|
||||||
|
|
||||||
const parsePayload = (payload: string | null) => {
|
|
||||||
if (!payload) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const [contentPart, refPart] = payload.split(START_PARAM_SEPARATOR, 2);
|
|
||||||
if (contentPart) {
|
|
||||||
contentId = contentPart;
|
|
||||||
}
|
|
||||||
if (refPart) {
|
|
||||||
referralId = refPart;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
parsePayload(rawStartParam);
|
|
||||||
|
|
||||||
if (!contentId) {
|
|
||||||
const queryContent =
|
|
||||||
searchParams.get('content') ||
|
|
||||||
searchParams.get('cid') ||
|
|
||||||
searchParams.get('start_param');
|
|
||||||
if (queryContent) {
|
|
||||||
contentId = queryContent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!referralId) {
|
|
||||||
const queryRef = searchParams.get('ref') || searchParams.get('ref_id');
|
|
||||||
if (queryRef) {
|
|
||||||
referralId = queryRef;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const storage = safeSessionStorage();
|
|
||||||
if (storage) {
|
|
||||||
if (referralId) {
|
|
||||||
storage.setItem(REF_STORAGE_KEY, referralId);
|
|
||||||
} else {
|
|
||||||
const stored = storage.getItem(REF_STORAGE_KEY);
|
|
||||||
if (stored) {
|
|
||||||
referralId = stored;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { contentId, referralId };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const appendReferral = <T extends Record<string, unknown>>(payload: T): T => {
|
|
||||||
const { referralId } = resolveStartPayload();
|
|
||||||
if (referralId && !payload.ref_id) {
|
|
||||||
return { ...payload, ref_id: referralId } as T;
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
};
|
|
||||||
264
yarn.lock
264
yarn.lock
|
|
@ -225,10 +225,10 @@
|
||||||
"@babel/helper-validator-identifier" "^7.22.20"
|
"@babel/helper-validator-identifier" "^7.22.20"
|
||||||
to-fast-properties "^2.0.0"
|
to-fast-properties "^2.0.0"
|
||||||
|
|
||||||
"@esbuild/linux-x64@0.19.12":
|
"@esbuild/win32-x64@0.19.12":
|
||||||
version "0.19.12"
|
version "0.19.12"
|
||||||
resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz"
|
resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz"
|
||||||
integrity sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==
|
integrity sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==
|
||||||
|
|
||||||
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
|
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
|
||||||
version "4.4.0"
|
version "4.4.0"
|
||||||
|
|
@ -381,15 +381,10 @@
|
||||||
resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.15.2.tgz"
|
resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.15.2.tgz"
|
||||||
integrity sha512-+Rnav+CaoTE5QJc4Jcwh5toUpnVLKYbpU6Ys0zqbakqbaLQHeglLVHPfxOiQqdNmUy5C2lXz5dwC6tQNX2JW2Q==
|
integrity sha512-+Rnav+CaoTE5QJc4Jcwh5toUpnVLKYbpU6Ys0zqbakqbaLQHeglLVHPfxOiQqdNmUy5C2lXz5dwC6tQNX2JW2Q==
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-gnu@4.12.0":
|
"@rollup/rollup-win32-x64-msvc@4.12.0":
|
||||||
version "4.12.0"
|
version "4.12.0"
|
||||||
resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz"
|
resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz"
|
||||||
integrity sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==
|
integrity sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg==
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-musl@4.12.0":
|
|
||||||
version "4.12.0"
|
|
||||||
resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz"
|
|
||||||
integrity sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw==
|
|
||||||
|
|
||||||
"@sentry-internal/browser-utils@9.1.0":
|
"@sentry-internal/browser-utils@9.1.0":
|
||||||
version "9.1.0"
|
version "9.1.0"
|
||||||
|
|
@ -446,6 +441,96 @@
|
||||||
"@sentry/core" "9.1.0"
|
"@sentry/core" "9.1.0"
|
||||||
hoist-non-react-statics "^3.3.2"
|
hoist-non-react-statics "^3.3.2"
|
||||||
|
|
||||||
|
"@telegram-apps/bridge@^1.9.2":
|
||||||
|
version "1.9.2"
|
||||||
|
resolved "https://registry.npmjs.org/@telegram-apps/bridge/-/bridge-1.9.2.tgz"
|
||||||
|
integrity sha512-SJLcNWLXhbbZr9MiqFH/g2ceuitSJKMxUIZysK4zUNyTUNuonrQG80Q/yrO+XiNbKUj8WdDNM86NBARhuyyinQ==
|
||||||
|
dependencies:
|
||||||
|
"@telegram-apps/signals" "^1.1.1"
|
||||||
|
"@telegram-apps/toolkit" "^1.1.1"
|
||||||
|
"@telegram-apps/transformers" "^1.2.2"
|
||||||
|
"@telegram-apps/types" "^1.2.1"
|
||||||
|
|
||||||
|
"@telegram-apps/bridge@^2.4.0":
|
||||||
|
version "2.4.0"
|
||||||
|
resolved "https://registry.npmjs.org/@telegram-apps/bridge/-/bridge-2.4.0.tgz"
|
||||||
|
integrity sha512-Lp/vhspF7okK8zXvSWWirunKXOPE6Gr11o9VBne4VmKG/yHRhEW7Pf07ncPtXLLzI6wW8+VYc3khsHPABJymEw==
|
||||||
|
dependencies:
|
||||||
|
"@telegram-apps/signals" "^1.1.1"
|
||||||
|
"@telegram-apps/toolkit" "^2.0.0"
|
||||||
|
"@telegram-apps/transformers" "^2.2.0"
|
||||||
|
"@telegram-apps/types" "^2.0.0"
|
||||||
|
better-promises "^0.4.0"
|
||||||
|
error-kid "^0.0.4"
|
||||||
|
mitt "^3.0.1"
|
||||||
|
valibot "1.0.0-beta.14"
|
||||||
|
|
||||||
|
"@telegram-apps/navigation@^1.0.13":
|
||||||
|
version "1.0.13"
|
||||||
|
resolved "https://registry.npmjs.org/@telegram-apps/navigation/-/navigation-1.0.13.tgz"
|
||||||
|
integrity sha512-TsUueB5LQp77GQHoMa93nq26Uw7GJjrFCPbyseMVU7aBBxAc+8CV2IYytRwcVp5sv/q7ThK5X4JaKn2V1yBHDQ==
|
||||||
|
dependencies:
|
||||||
|
"@telegram-apps/bridge" "^1.9.2"
|
||||||
|
"@telegram-apps/signals" "^1.1.1"
|
||||||
|
"@telegram-apps/toolkit" "^1.1.1"
|
||||||
|
|
||||||
|
"@telegram-apps/sdk@^3.5.1":
|
||||||
|
version "3.5.1"
|
||||||
|
resolved "https://registry.npmjs.org/@telegram-apps/sdk/-/sdk-3.5.1.tgz"
|
||||||
|
integrity sha512-m/ynpSozXsqq6Kfb6M9fm8SD6x/+jqvTFT59FuuOBvC+G8eMd0F+fvZdr6Pj0I6IL9M67nrfm92JpTu/aVNEjw==
|
||||||
|
dependencies:
|
||||||
|
"@telegram-apps/bridge" "^2.4.0"
|
||||||
|
"@telegram-apps/navigation" "^1.0.13"
|
||||||
|
"@telegram-apps/signals" "^1.1.1"
|
||||||
|
"@telegram-apps/toolkit" "^2.0.0"
|
||||||
|
"@telegram-apps/transformers" "^2.2.0"
|
||||||
|
"@telegram-apps/types" "^2.0.0"
|
||||||
|
better-promises "^0.4.0"
|
||||||
|
error-kid "^0.0.4"
|
||||||
|
valibot "1.0.0-beta.14"
|
||||||
|
|
||||||
|
"@telegram-apps/signals@^1.1.1":
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.npmjs.org/@telegram-apps/signals/-/signals-1.1.1.tgz"
|
||||||
|
integrity sha512-vz37r8lemGpPzDiBRfqpXYBynzmy3SFnY6zfHsTZABTYYt0b0WQZyU5mFDqqqugGhka78Gy11xmr9csgy4YgGA==
|
||||||
|
|
||||||
|
"@telegram-apps/toolkit@^1.1.1":
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.npmjs.org/@telegram-apps/toolkit/-/toolkit-1.1.1.tgz"
|
||||||
|
integrity sha512-+vhKx6ngfvjyTE6Xagl3z1TPVbfx5s7xAkcYzCdHYUo6T60jLIqLgyZMcI1UPoIAMuMu1pHoO+p8QNCj/+tFmw==
|
||||||
|
|
||||||
|
"@telegram-apps/toolkit@^2.0.0":
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/@telegram-apps/toolkit/-/toolkit-2.0.0.tgz"
|
||||||
|
integrity sha512-1GKTLBNme1Phu/gFvgS9NWPq+LhPfzSIfnwhcF9I/6tCdufrLRcVaSMRiK9R4VDYD6iZUyj+a2l250qWAxxjQQ==
|
||||||
|
|
||||||
|
"@telegram-apps/transformers@^1.2.2":
|
||||||
|
version "1.2.2"
|
||||||
|
resolved "https://registry.npmjs.org/@telegram-apps/transformers/-/transformers-1.2.2.tgz"
|
||||||
|
integrity sha512-vvMwXckd1D7Ozc0h66PSUwF5QLrRV9HlGJFFeBuUex8QEk5mSPtsJkLiqB8aBbwuFDa91+TUSM/CxqPZO/e9YQ==
|
||||||
|
dependencies:
|
||||||
|
"@telegram-apps/toolkit" "^1.1.1"
|
||||||
|
"@telegram-apps/types" "^1.2.1"
|
||||||
|
|
||||||
|
"@telegram-apps/transformers@^2.2.0":
|
||||||
|
version "2.2.0"
|
||||||
|
resolved "https://registry.npmjs.org/@telegram-apps/transformers/-/transformers-2.2.0.tgz"
|
||||||
|
integrity sha512-wqXXOukhEjZKhzdq5vG1LkxWL11DApbmUKzk+3nA/ki3TLyD3awVTOXbpoNdOwFl2xliIooYcsUOEl4WCyyLGw==
|
||||||
|
dependencies:
|
||||||
|
"@telegram-apps/toolkit" "^2.0.0"
|
||||||
|
"@telegram-apps/types" "^2.0.0"
|
||||||
|
valibot "1.0.0-beta.14"
|
||||||
|
|
||||||
|
"@telegram-apps/types@^1.2.1":
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.npmjs.org/@telegram-apps/types/-/types-1.2.1.tgz"
|
||||||
|
integrity sha512-so4HLh7clur0YyMthi9KVIgWoGpZdXlFOuQjk3+Q5NAvJZ11nAheBSwPlGw/Ko92+zwvrSBE/lQyN2+p17RP+w==
|
||||||
|
|
||||||
|
"@telegram-apps/types@^2.0.0":
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/@telegram-apps/types/-/types-2.0.0.tgz"
|
||||||
|
integrity sha512-yF499FJK82a2IDNDAQdrmVH3sgFZl/QFNdVZKgWpgtunIVJ1fres5wi9+4aUBRVIdQwZOZZqB/AOvYYuYXsq3Q==
|
||||||
|
|
||||||
"@ton/core@^0.59.1":
|
"@ton/core@^0.59.1":
|
||||||
version "0.59.1"
|
version "0.59.1"
|
||||||
resolved "https://registry.npmjs.org/@ton/core/-/core-0.59.1.tgz"
|
resolved "https://registry.npmjs.org/@ton/core/-/core-0.59.1.tgz"
|
||||||
|
|
@ -925,6 +1010,13 @@ base64-js@^1.3.1:
|
||||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||||
|
|
||||||
|
better-promises@^0.4.0:
|
||||||
|
version "0.4.0"
|
||||||
|
resolved "https://registry.npmjs.org/better-promises/-/better-promises-0.4.0.tgz"
|
||||||
|
integrity sha512-AcKkTUSd4o1vMf41eBbHW1NkY7vrXeNI6etitGdQE54WFXsF2wkfonrKA06Za7lViRNyT/cMvj5z+DScqhYW8A==
|
||||||
|
dependencies:
|
||||||
|
error-kid "^0.0.4"
|
||||||
|
|
||||||
big-integer@^1.6.16:
|
big-integer@^1.6.16:
|
||||||
version "1.6.52"
|
version "1.6.52"
|
||||||
resolved "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz"
|
resolved "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz"
|
||||||
|
|
@ -981,11 +1073,6 @@ browserslist@^4.22.2, browserslist@^4.23.0, "browserslist@>= 4.21.0":
|
||||||
node-releases "^2.0.14"
|
node-releases "^2.0.14"
|
||||||
update-browserslist-db "^1.0.13"
|
update-browserslist-db "^1.0.13"
|
||||||
|
|
||||||
buffer-from@^1.1.2:
|
|
||||||
version "1.1.2"
|
|
||||||
resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz"
|
|
||||||
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
|
|
||||||
|
|
||||||
buffer@^6.0.3:
|
buffer@^6.0.3:
|
||||||
version "6.0.3"
|
version "6.0.3"
|
||||||
resolved "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz"
|
resolved "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz"
|
||||||
|
|
@ -1091,14 +1178,6 @@ color-name@1.1.3:
|
||||||
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
|
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
|
||||||
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
|
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
|
||||||
|
|
||||||
combine-errors@^3.0.3:
|
|
||||||
version "3.0.3"
|
|
||||||
resolved "https://registry.npmjs.org/combine-errors/-/combine-errors-3.0.3.tgz"
|
|
||||||
integrity sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==
|
|
||||||
dependencies:
|
|
||||||
custom-error-instance "2.1.1"
|
|
||||||
lodash.uniqby "4.5.0"
|
|
||||||
|
|
||||||
combined-stream@^1.0.8:
|
combined-stream@^1.0.8:
|
||||||
version "1.0.8"
|
version "1.0.8"
|
||||||
resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz"
|
resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz"
|
||||||
|
|
@ -1140,11 +1219,6 @@ csstype@^3.0.2, csstype@^3.1.1:
|
||||||
resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz"
|
resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz"
|
||||||
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
||||||
|
|
||||||
custom-error-instance@2.1.1:
|
|
||||||
version "2.1.1"
|
|
||||||
resolved "https://registry.npmjs.org/custom-error-instance/-/custom-error-instance-2.1.1.tgz"
|
|
||||||
integrity sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==
|
|
||||||
|
|
||||||
debug@^3.2.7:
|
debug@^3.2.7:
|
||||||
version "3.2.7"
|
version "3.2.7"
|
||||||
resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz"
|
resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz"
|
||||||
|
|
@ -1257,6 +1331,11 @@ emoji-regex@^9.2.2:
|
||||||
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz"
|
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz"
|
||||||
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
|
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
|
||||||
|
|
||||||
|
error-kid@^0.0.4:
|
||||||
|
version "0.0.4"
|
||||||
|
resolved "https://registry.npmjs.org/error-kid/-/error-kid-0.0.4.tgz"
|
||||||
|
integrity sha512-x+yQhY56SorLMnX6kOf+z3JCv2QBurcWEDcIjgxYtVr4fGeCfAtOdZOCyWttkHHDFPtL2PqnaRUmphbmALJd9w==
|
||||||
|
|
||||||
es-abstract@^1.22.1, es-abstract@^1.22.3:
|
es-abstract@^1.22.1, es-abstract@^1.22.3:
|
||||||
version "1.22.5"
|
version "1.22.5"
|
||||||
resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.5.tgz"
|
resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.5.tgz"
|
||||||
|
|
@ -1792,11 +1871,6 @@ gopd@^1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
get-intrinsic "^1.1.3"
|
get-intrinsic "^1.1.3"
|
||||||
|
|
||||||
graceful-fs@^4.2.4:
|
|
||||||
version "4.2.11"
|
|
||||||
resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz"
|
|
||||||
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
|
|
||||||
|
|
||||||
graphemer@^1.4.0:
|
graphemer@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz"
|
resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz"
|
||||||
|
|
@ -2003,11 +2077,6 @@ is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind "^1.0.7"
|
call-bind "^1.0.7"
|
||||||
|
|
||||||
is-stream@^2.0.0:
|
|
||||||
version "2.0.1"
|
|
||||||
resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz"
|
|
||||||
integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
|
|
||||||
|
|
||||||
is-string@^1.0.5, is-string@^1.0.7:
|
is-string@^1.0.5, is-string@^1.0.7:
|
||||||
version "1.0.7"
|
version "1.0.7"
|
||||||
resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz"
|
resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz"
|
||||||
|
|
@ -2060,11 +2129,6 @@ jiti@^1.19.1:
|
||||||
resolved "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz"
|
resolved "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz"
|
||||||
integrity sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==
|
integrity sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==
|
||||||
|
|
||||||
js-base64@^3.7.2:
|
|
||||||
version "3.7.8"
|
|
||||||
resolved "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz"
|
|
||||||
integrity sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==
|
|
||||||
|
|
||||||
js-sha3@0.8.0:
|
js-sha3@0.8.0:
|
||||||
version "0.8.0"
|
version "0.8.0"
|
||||||
resolved "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz"
|
resolved "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz"
|
||||||
|
|
@ -2166,61 +2230,11 @@ lodash-es@^4.17.21:
|
||||||
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
|
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
|
||||||
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
||||||
|
|
||||||
lodash._baseiteratee@~4.7.0:
|
|
||||||
version "4.7.0"
|
|
||||||
resolved "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz"
|
|
||||||
integrity sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==
|
|
||||||
dependencies:
|
|
||||||
lodash._stringtopath "~4.8.0"
|
|
||||||
|
|
||||||
lodash._basetostring@~4.12.0:
|
|
||||||
version "4.12.0"
|
|
||||||
resolved "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz"
|
|
||||||
integrity sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==
|
|
||||||
|
|
||||||
lodash._baseuniq@~4.6.0:
|
|
||||||
version "4.6.0"
|
|
||||||
resolved "https://registry.npmjs.org/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz"
|
|
||||||
integrity sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==
|
|
||||||
dependencies:
|
|
||||||
lodash._createset "~4.0.0"
|
|
||||||
lodash._root "~3.0.0"
|
|
||||||
|
|
||||||
lodash._createset@~4.0.0:
|
|
||||||
version "4.0.3"
|
|
||||||
resolved "https://registry.npmjs.org/lodash._createset/-/lodash._createset-4.0.3.tgz"
|
|
||||||
integrity sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==
|
|
||||||
|
|
||||||
lodash._root@~3.0.0:
|
|
||||||
version "3.0.1"
|
|
||||||
resolved "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz"
|
|
||||||
integrity sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==
|
|
||||||
|
|
||||||
lodash._stringtopath@~4.8.0:
|
|
||||||
version "4.8.0"
|
|
||||||
resolved "https://registry.npmjs.org/lodash._stringtopath/-/lodash._stringtopath-4.8.0.tgz"
|
|
||||||
integrity sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==
|
|
||||||
dependencies:
|
|
||||||
lodash._basetostring "~4.12.0"
|
|
||||||
|
|
||||||
lodash.merge@^4.6.2:
|
lodash.merge@^4.6.2:
|
||||||
version "4.6.2"
|
version "4.6.2"
|
||||||
resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
|
resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
|
||||||
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
||||||
|
|
||||||
lodash.throttle@^4.1.1:
|
|
||||||
version "4.1.1"
|
|
||||||
resolved "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz"
|
|
||||||
integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==
|
|
||||||
|
|
||||||
lodash.uniqby@4.5.0:
|
|
||||||
version "4.5.0"
|
|
||||||
resolved "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.5.0.tgz"
|
|
||||||
integrity sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==
|
|
||||||
dependencies:
|
|
||||||
lodash._baseiteratee "~4.7.0"
|
|
||||||
lodash._baseuniq "~4.6.0"
|
|
||||||
|
|
||||||
loose-envify@^1.1.0, loose-envify@^1.4.0:
|
loose-envify@^1.1.0, loose-envify@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
|
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
|
||||||
|
|
@ -2328,6 +2342,11 @@ minimist@^1.2.0, minimist@^1.2.6:
|
||||||
resolved "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz"
|
resolved "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz"
|
||||||
integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==
|
integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==
|
||||||
|
|
||||||
|
mitt@^3.0.1:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz"
|
||||||
|
integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==
|
||||||
|
|
||||||
ms@^2.1.1, ms@2.1.2:
|
ms@^2.1.1, ms@2.1.2:
|
||||||
version "2.1.2"
|
version "2.1.2"
|
||||||
resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
|
resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
|
||||||
|
|
@ -2627,15 +2646,6 @@ prop-types@^15.7.2:
|
||||||
object-assign "^4.1.1"
|
object-assign "^4.1.1"
|
||||||
react-is "^16.13.1"
|
react-is "^16.13.1"
|
||||||
|
|
||||||
proper-lockfile@^4.1.2:
|
|
||||||
version "4.1.2"
|
|
||||||
resolved "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz"
|
|
||||||
integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==
|
|
||||||
dependencies:
|
|
||||||
graceful-fs "^4.2.4"
|
|
||||||
retry "^0.12.0"
|
|
||||||
signal-exit "^3.0.2"
|
|
||||||
|
|
||||||
proxy-from-env@^1.1.0:
|
proxy-from-env@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
|
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
|
||||||
|
|
@ -2646,11 +2656,6 @@ punycode@^2.1.0:
|
||||||
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
||||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||||
|
|
||||||
querystringify@^2.1.1:
|
|
||||||
version "2.2.0"
|
|
||||||
resolved "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz"
|
|
||||||
integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==
|
|
||||||
|
|
||||||
queue-microtask@^1.2.2:
|
queue-microtask@^1.2.2:
|
||||||
version "1.2.3"
|
version "1.2.3"
|
||||||
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
|
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
|
||||||
|
|
@ -2794,11 +2799,6 @@ remove-accents@0.5.0:
|
||||||
resolved "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz"
|
resolved "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz"
|
||||||
integrity sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==
|
integrity sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==
|
||||||
|
|
||||||
requires-port@^1.0.0:
|
|
||||||
version "1.0.0"
|
|
||||||
resolved "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz"
|
|
||||||
integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==
|
|
||||||
|
|
||||||
resolve-from@^4.0.0:
|
resolve-from@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz"
|
resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz"
|
||||||
|
|
@ -2813,11 +2813,6 @@ resolve@^1.1.7, resolve@^1.22.2, resolve@^1.22.4:
|
||||||
path-parse "^1.0.7"
|
path-parse "^1.0.7"
|
||||||
supports-preserve-symlinks-flag "^1.0.0"
|
supports-preserve-symlinks-flag "^1.0.0"
|
||||||
|
|
||||||
retry@^0.12.0:
|
|
||||||
version "0.12.0"
|
|
||||||
resolved "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz"
|
|
||||||
integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==
|
|
||||||
|
|
||||||
reusify@^1.0.4:
|
reusify@^1.0.4:
|
||||||
version "1.0.4"
|
version "1.0.4"
|
||||||
resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz"
|
resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz"
|
||||||
|
|
@ -2941,11 +2936,6 @@ side-channel@^1.0.4:
|
||||||
get-intrinsic "^1.2.4"
|
get-intrinsic "^1.2.4"
|
||||||
object-inspect "^1.13.1"
|
object-inspect "^1.13.1"
|
||||||
|
|
||||||
signal-exit@^3.0.2:
|
|
||||||
version "3.0.7"
|
|
||||||
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz"
|
|
||||||
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
|
|
||||||
|
|
||||||
signal-exit@^4.0.1:
|
signal-exit@^4.0.1:
|
||||||
version "4.1.0"
|
version "4.1.0"
|
||||||
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz"
|
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz"
|
||||||
|
|
@ -3192,19 +3182,6 @@ tslib@^2.6.2:
|
||||||
resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz"
|
resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz"
|
||||||
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
|
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
|
||||||
|
|
||||||
tus-js-client@^4.3.1:
|
|
||||||
version "4.3.1"
|
|
||||||
resolved "https://registry.npmjs.org/tus-js-client/-/tus-js-client-4.3.1.tgz"
|
|
||||||
integrity sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==
|
|
||||||
dependencies:
|
|
||||||
buffer-from "^1.1.2"
|
|
||||||
combine-errors "^3.0.3"
|
|
||||||
is-stream "^2.0.0"
|
|
||||||
js-base64 "^3.7.2"
|
|
||||||
lodash.throttle "^4.1.1"
|
|
||||||
proper-lockfile "^4.1.2"
|
|
||||||
url-parse "^1.5.7"
|
|
||||||
|
|
||||||
tweetnacl-util@^0.15.1:
|
tweetnacl-util@^0.15.1:
|
||||||
version "0.15.1"
|
version "0.15.1"
|
||||||
resolved "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz"
|
resolved "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz"
|
||||||
|
|
@ -3271,7 +3248,7 @@ typed-array-length@^1.0.5:
|
||||||
is-typed-array "^1.1.13"
|
is-typed-array "^1.1.13"
|
||||||
possible-typed-array-names "^1.0.0"
|
possible-typed-array-names "^1.0.0"
|
||||||
|
|
||||||
typescript@^5.0.0, typescript@^5.2.2, typescript@>=4.2.0:
|
typescript@^5.0.0, typescript@^5.2.2, typescript@>=4.2.0, typescript@>=5:
|
||||||
version "5.3.3"
|
version "5.3.3"
|
||||||
resolved "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz"
|
resolved "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz"
|
||||||
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
|
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
|
||||||
|
|
@ -3319,14 +3296,6 @@ uri-js@^4.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode "^2.1.0"
|
punycode "^2.1.0"
|
||||||
|
|
||||||
url-parse@^1.5.7:
|
|
||||||
version "1.5.10"
|
|
||||||
resolved "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz"
|
|
||||||
integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==
|
|
||||||
dependencies:
|
|
||||||
querystringify "^2.1.1"
|
|
||||||
requires-port "^1.0.0"
|
|
||||||
|
|
||||||
use-sync-external-store@1.2.0:
|
use-sync-external-store@1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
|
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
|
||||||
|
|
@ -3337,6 +3306,11 @@ util-deprecate@^1.0.2:
|
||||||
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
||||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||||
|
|
||||||
|
valibot@1.0.0-beta.14:
|
||||||
|
version "1.0.0-beta.14"
|
||||||
|
resolved "https://registry.npmjs.org/valibot/-/valibot-1.0.0-beta.14.tgz"
|
||||||
|
integrity sha512-tLyV2rE5QL6U29MFy3xt4AqMrn+/HErcp2ZThASnQvPMwfSozjV1uBGKIGiegtZIGjinJqn0SlBdannf18wENA==
|
||||||
|
|
||||||
vite-tsconfig-paths@^4.3.1:
|
vite-tsconfig-paths@^4.3.1:
|
||||||
version "4.3.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.1.tgz"
|
resolved "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.1.tgz"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue